improve emoji coverage and add kaomoji
This commit is contained in:
parent
be6b9043b2
commit
1c934576fe
17 changed files with 565 additions and 50 deletions
203
e2e/specs/emoji-picker.spec.ts
Normal file
203
e2e/specs/emoji-picker.spec.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import {HOUR} from "@welshman/lib"
|
||||||
|
import type {Locator, Page} from "@playwright/test"
|
||||||
|
import {composer, emojiButton, expect, message, roomPath, test, users} from "../harness"
|
||||||
|
|
||||||
|
const openReactionPicker = async (page: Page, opener: Locator) => {
|
||||||
|
await opener.click()
|
||||||
|
|
||||||
|
const picker = page.locator("emoji-picker").filter({visible: true})
|
||||||
|
|
||||||
|
await expect(picker).toHaveCount(1)
|
||||||
|
|
||||||
|
return picker
|
||||||
|
}
|
||||||
|
|
||||||
|
const openComposerEmojiPicker = async (page: Page) => {
|
||||||
|
await page.getByRole("button", {name: "Add emoji"}).click()
|
||||||
|
|
||||||
|
const shell = page.locator('[data-component="EmojiPicker"]').filter({visible: true})
|
||||||
|
|
||||||
|
await expect(shell).toHaveCount(1)
|
||||||
|
|
||||||
|
return shell
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchPicker = async (picker: Locator, annotation: string) => {
|
||||||
|
await picker.locator("input.search").fill(annotation)
|
||||||
|
await expect(picker.getByRole("option", {name: new RegExp(annotation)}).first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Tippy reaction picker shows gestures and flags without CDN fetches", async ({seed, as}) => {
|
||||||
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
|
const space = relay("space")
|
||||||
|
|
||||||
|
space.room("general", {name: "General"})
|
||||||
|
space.join(user.alice, "general")
|
||||||
|
space.join(user.bob, "general")
|
||||||
|
space.message(user.bob, "general", "coverage check", at(2, HOUR))
|
||||||
|
})
|
||||||
|
|
||||||
|
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
|
||||||
|
const cdnRequests: string[] = []
|
||||||
|
|
||||||
|
await alice.context().route(/jsdelivr|skypack|fonts\.googleapis|fonts\.gstatic/i, route => {
|
||||||
|
cdnRequests.push(route.request().url())
|
||||||
|
return route.continue()
|
||||||
|
})
|
||||||
|
|
||||||
|
const opener = emojiButton(message(alice, "coverage check"))
|
||||||
|
const picker = await openReactionPicker(alice, opener)
|
||||||
|
|
||||||
|
for (const annotation of [
|
||||||
|
"heart hands",
|
||||||
|
"pinched fingers",
|
||||||
|
"flag: India",
|
||||||
|
"flag: United States",
|
||||||
|
]) {
|
||||||
|
await searchPicker(picker, annotation)
|
||||||
|
}
|
||||||
|
|
||||||
|
await alice.keyboard.press("Escape")
|
||||||
|
expect(cdnRequests).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reaction picker has no kaomoji and preserves skin tone", async ({seed, as}) => {
|
||||||
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
|
const space = relay("space")
|
||||||
|
|
||||||
|
space.room("general", {name: "General"})
|
||||||
|
space.join(user.alice, "general")
|
||||||
|
space.join(user.bob, "general")
|
||||||
|
space.message(user.bob, "general", "skin tone check", at(2, HOUR))
|
||||||
|
})
|
||||||
|
|
||||||
|
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
|
||||||
|
const opener = emojiButton(message(alice, "skin tone check"))
|
||||||
|
const picker = await openReactionPicker(alice, opener)
|
||||||
|
|
||||||
|
await expect(alice.getByRole("button", {name: "Kaomoji", exact: true})).toHaveCount(0)
|
||||||
|
|
||||||
|
await picker.getByRole("button", {name: /Choose a skin tone/}).click()
|
||||||
|
await picker.getByRole("option", {name: "Medium-Dark"}).click()
|
||||||
|
await searchPicker(picker, "thumbs up")
|
||||||
|
await picker
|
||||||
|
.getByRole("option", {name: /thumbs up/})
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
|
||||||
|
await expect(message(alice, "skin tone check").getByRole("button", {name: "👍🏾"})).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("composer inserts emoji and kaomoji at the caret and closes the popover", async ({
|
||||||
|
seed,
|
||||||
|
as,
|
||||||
|
}) => {
|
||||||
|
const scenario = await seed(({relay, user}) => {
|
||||||
|
const space = relay("space")
|
||||||
|
|
||||||
|
space.room("general", {name: "General"})
|
||||||
|
space.join(user.alice, "general")
|
||||||
|
})
|
||||||
|
|
||||||
|
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
|
||||||
|
const editor = composer(alice)
|
||||||
|
|
||||||
|
await expect(editor).toBeVisible()
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially("Hello world")
|
||||||
|
|
||||||
|
await editor.evaluate(node => {
|
||||||
|
const paragraph = node.querySelector("p")
|
||||||
|
const textNode = paragraph?.firstChild
|
||||||
|
|
||||||
|
if (!(textNode instanceof Text)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(textNode, "Hello ".length)
|
||||||
|
range.collapse(true)
|
||||||
|
const selection = window.getSelection()
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
selection?.addRange(range)
|
||||||
|
})
|
||||||
|
|
||||||
|
const shell = await openComposerEmojiPicker(alice)
|
||||||
|
const picker = shell.locator("emoji-picker")
|
||||||
|
|
||||||
|
await picker.locator("input.search").fill("grinning face")
|
||||||
|
await picker
|
||||||
|
.getByRole("option", {name: /grinning face/})
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
|
||||||
|
await expect(shell).toHaveCount(0)
|
||||||
|
await expect(editor).toBeFocused()
|
||||||
|
await expect(editor).toHaveText("Hello 😀world")
|
||||||
|
|
||||||
|
const kaomojiShell = await openComposerEmojiPicker(alice)
|
||||||
|
|
||||||
|
await kaomojiShell.getByRole("button", {name: "Kaomoji", exact: true}).click()
|
||||||
|
await kaomojiShell.getByRole("button", {name: "Shrug", exact: true}).click()
|
||||||
|
|
||||||
|
await expect(kaomojiShell).toHaveCount(0)
|
||||||
|
await expect(editor).toBeFocused()
|
||||||
|
await expect(editor).toContainText("¯\\_(ツ)_/¯")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Emoji → Kaomoji → Emoji still selects emoji", async ({seed, as}) => {
|
||||||
|
const scenario = await seed(({relay, user}) => {
|
||||||
|
const space = relay("space")
|
||||||
|
|
||||||
|
space.room("general", {name: "General"})
|
||||||
|
space.join(user.alice, "general")
|
||||||
|
})
|
||||||
|
|
||||||
|
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"))
|
||||||
|
const shell = await openComposerEmojiPicker(alice)
|
||||||
|
|
||||||
|
await shell.getByRole("button", {name: "Kaomoji", exact: true}).click()
|
||||||
|
await expect(shell.getByRole("button", {name: "Shrug", exact: true})).toBeVisible()
|
||||||
|
await shell.getByRole("button", {name: "Emoji", exact: true}).click()
|
||||||
|
|
||||||
|
const picker = shell.locator("emoji-picker")
|
||||||
|
|
||||||
|
await expect(picker).toBeVisible()
|
||||||
|
await picker.locator("input.search").fill("party popper")
|
||||||
|
await picker.getByRole("option", {name: /party popper/}).click()
|
||||||
|
|
||||||
|
await expect(composer(alice)).toContainText("🎉")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("mobile picker keeps a reduced column count", async ({seed, as}) => {
|
||||||
|
const scenario = await seed(({relay, user, at}) => {
|
||||||
|
const space = relay("space")
|
||||||
|
|
||||||
|
space.room("general", {name: "General"})
|
||||||
|
space.join(user.alice, "general")
|
||||||
|
space.join(user.bob, "general")
|
||||||
|
space.message(user.bob, "general", "mobile picker", at(2, HOUR))
|
||||||
|
})
|
||||||
|
|
||||||
|
const alice = await as(users.alice, roomPath(scenario.space("space").url, "general"), {
|
||||||
|
context: {viewport: {width: 390, height: 844}, hasTouch: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hover reaction buttons are desktop-only; phones open the picker from the message menu.
|
||||||
|
await expect(alice.getByRole("button", {name: "Open space menu"})).toBeVisible()
|
||||||
|
await message(alice, "mobile picker").click()
|
||||||
|
await alice.getByRole("button", {name: "React"}).click()
|
||||||
|
|
||||||
|
const picker = alice.locator("emoji-picker").filter({visible: true})
|
||||||
|
|
||||||
|
await expect(picker).toHaveCount(1)
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
picker.evaluate(el => getComputedStyle(el).getPropertyValue("--num-columns").trim()),
|
||||||
|
)
|
||||||
|
.toBe("6")
|
||||||
|
|
||||||
|
await searchPicker(picker, "party popper")
|
||||||
|
await picker.getByRole("option", {name: /party popper/}).click()
|
||||||
|
await expect(message(alice, "mobile picker")).toContainText("🎉")
|
||||||
|
})
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import DictationButton from "@app/components/DictationButton.svelte"
|
import DictationButton from "@app/components/DictationButton.svelte"
|
||||||
|
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
import {makeEditor} from "@app/editor"
|
import {makeEditor} from "@app/editor"
|
||||||
import {getDictation} from "@app/dictation"
|
import {getDictation} from "@app/dictation"
|
||||||
|
|
@ -163,6 +164,10 @@
|
||||||
<Icon icon={GallerySend} />
|
<Icon icon={GallerySend} />
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
|
<EditorEmojiButton
|
||||||
|
{editor}
|
||||||
|
disabled={$uploading || disabled}
|
||||||
|
class="button button-neutral button-square tip tip-right h-10 w-10 min-w-10 rounded-2xl transition-colors" />
|
||||||
<div class={editorClass} aria-disabled={disabled}>
|
<div class={editorClass} aria-disabled={disabled}>
|
||||||
<EditorContent {autofocus} {editor} />
|
<EditorContent {autofocus} {editor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
|
@ -13,8 +12,8 @@
|
||||||
|
|
||||||
const {event, pubkeys}: Props = $props()
|
const {event, pubkeys}: Props = $props()
|
||||||
|
|
||||||
const onEmoji = async (emoji: NativeEmoji) => {
|
const onEmoji = async (emoji: string) => {
|
||||||
const reaction = await $reactions.react(event, emoji.unicode)
|
const reaction = await $reactions.react(event, emoji)
|
||||||
|
|
||||||
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
import Pen from "@assets/icons/pen.svg?dataurl"
|
import Pen from "@assets/icons/pen.svg?dataurl"
|
||||||
|
|
@ -30,10 +29,10 @@
|
||||||
|
|
||||||
const tile = "button h-auto flex-col gap-1.5 py-4 text-xs"
|
const tile = "button h-auto flex-col gap-1.5 py-4 text-xs"
|
||||||
|
|
||||||
const onEmoji = async (emoji: NativeEmoji) => {
|
const onEmoji = async (emoji: string) => {
|
||||||
history.back()
|
history.back()
|
||||||
|
|
||||||
const reaction = await $reactions.react(event, emoji.unicode)
|
const reaction = await $reactions.react(event, emoji)
|
||||||
|
|
||||||
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/app/components/EditorEmojiButton.svelte
Normal file
31
src/app/components/EditorEmojiButton.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type {Editor} from "@welshman/editor"
|
||||||
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
|
import {escapeHtml} from "@lib/html"
|
||||||
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
editor: Promise<Editor>
|
||||||
|
class?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const {editor, class: className, disabled = false}: Props = $props()
|
||||||
|
|
||||||
|
const onEmoji = async (emoji: string) => {
|
||||||
|
const ed = await editor
|
||||||
|
|
||||||
|
ed.chain().focus().insertContent(escapeHtml(emoji)).run()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<EmojiButton
|
||||||
|
aria-label="Add emoji"
|
||||||
|
data-tip="Emoji"
|
||||||
|
{onEmoji}
|
||||||
|
allowKaomoji
|
||||||
|
{disabled}
|
||||||
|
class={className}>
|
||||||
|
<Icon icon={SmileCircle} />
|
||||||
|
</EmojiButton>
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {tagSpec, tagValue} from "@welshman/util"
|
import {tagSpec, tagValue} from "@welshman/util"
|
||||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
|
|
@ -21,8 +20,7 @@
|
||||||
|
|
||||||
const h = tagValue(tagSpec("h"), event.tags)
|
const h = tagValue(tagSpec("h"), event.tags)
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) =>
|
const onEmoji = (emoji: string) => publishReaction(event, {content: emoji, tags: []}, {url, h})
|
||||||
publishReaction(event, {content: emoji.unicode, tags: []}, {url, h})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if ENABLE_ZAPS && !hideZap}
|
{#if ENABLE_ZAPS && !hideZap}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
|
||||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||||
import {seen} from "@welshman/util"
|
import {seen} from "@welshman/util"
|
||||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||||
|
|
@ -28,7 +27,7 @@
|
||||||
const createReaction = async (values: EventContent) =>
|
const createReaction = async (values: EventContent) =>
|
||||||
publishReaction(event, values, {url, urls: await getRelays()})
|
publishReaction(event, values, {url, urls: await getRelays()})
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []})
|
const onEmoji = (emoji: string) => createReaction({content: emoji, tags: []})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ReactionSummary {url} {event} {context} {deleteReaction} {createReaction} {reactionClass}>
|
<ReactionSummary {url} {event} {context} {deleteReaction} {createReaction} {reactionClass}>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
import type {TippyController} from "@lib/components/Tippy.svelte"
|
import type {TippyController} from "@lib/components/Tippy.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
||||||
|
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
|
||||||
import DictationButton from "@app/components/DictationButton.svelte"
|
import DictationButton from "@app/components/DictationButton.svelte"
|
||||||
import CommandArgBar from "@app/components/CommandArgBar.svelte"
|
import CommandArgBar from "@app/components/CommandArgBar.svelte"
|
||||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||||
|
|
@ -187,6 +188,10 @@
|
||||||
<Icon icon={WidgetAdd} />
|
<Icon icon={WidgetAdd} />
|
||||||
</Tippy>
|
</Tippy>
|
||||||
</Button>
|
</Button>
|
||||||
|
<EditorEmojiButton
|
||||||
|
{editor}
|
||||||
|
disabled={$uploading}
|
||||||
|
class="join-item h-10 w-10 min-w-10 button button-neutral" />
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-editor grow overflow-hidden">
|
<div class="chat-editor grow overflow-hidden">
|
||||||
<EditorContent {autofocus} {editor} />
|
<EditorContent {autofocus} {editor} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import {tagSpec, tagValue} from "@welshman/util"
|
import {tagSpec, tagValue} from "@welshman/util"
|
||||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||||
|
|
@ -16,11 +15,11 @@
|
||||||
|
|
||||||
const shouldProtect = $relays.hasNip(url, 70)
|
const shouldProtect = $relays.hasNip(url, 70)
|
||||||
|
|
||||||
const onEmoji = async (emoji: NativeEmoji) => {
|
const onEmoji = async (emoji: string) => {
|
||||||
const protect = await shouldProtect
|
const protect = await shouldProtect
|
||||||
const room = tagValue(tagSpec("h"), event.tags)
|
const room = tagValue(tagSpec("h"), event.tags)
|
||||||
|
|
||||||
const command = await $reactions.react(event, emoji.unicode, writer => {
|
const command = await $reactions.react(event, emoji, writer => {
|
||||||
writer.setProtected(protect)
|
writer.setProtected(protect)
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
import * as nip19 from "nostr-tools/nip19"
|
import * as nip19 from "nostr-tools/nip19"
|
||||||
import {getIdOrAddress, tagSpec, tagValue, toNostrURI} from "@welshman/util"
|
import {getIdOrAddress, tagSpec, tagValue, toNostrURI} from "@welshman/util"
|
||||||
|
|
@ -54,11 +53,11 @@
|
||||||
const isPinned = $derived($pinIds.includes(event.id))
|
const isPinned = $derived($pinIds.includes(event.id))
|
||||||
const tile = "button h-auto flex-col gap-1.5 py-4 text-xs"
|
const tile = "button h-auto flex-col gap-1.5 py-4 text-xs"
|
||||||
|
|
||||||
const onEmoji = async (emoji: NativeEmoji) => {
|
const onEmoji = async (emoji: string) => {
|
||||||
history.back()
|
history.back()
|
||||||
|
|
||||||
const protect = await $relays.hasNip(url, 70)
|
const protect = await $relays.hasNip(url, 70)
|
||||||
const command = await $reactions.react(event, emoji.unicode, w => w.setProtected(protect))
|
const command = await $reactions.react(event, emoji, w => w.setProtected(protect))
|
||||||
|
|
||||||
command.publishToRelays([url])
|
command.publishToRelays([url])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
import type {Snippet} from "svelte"
|
import type {Snippet} from "svelte"
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
|
||||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
import Field from "@lib/components/Field.svelte"
|
import Field from "@lib/components/Field.svelte"
|
||||||
|
|
@ -22,8 +21,8 @@
|
||||||
|
|
||||||
let {amount = $bindable(), content = $bindable(), children}: Props = $props()
|
let {amount = $bindable(), content = $bindable(), children}: Props = $props()
|
||||||
|
|
||||||
const onEmoji = (emoji: NativeEmoji) => {
|
const onEmoji = (emoji: string) => {
|
||||||
content = emoji.unicode
|
content = emoji
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAmount = (preset: number) => {
|
const selectAmount = (preset: number) => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
import type {Snippet} from "svelte"
|
||||||
import {between, throttle} from "@welshman/lib"
|
import {between, throttle} from "@welshman/lib"
|
||||||
import type {Maybe} from "@welshman/lib"
|
import type {Maybe} from "@welshman/lib"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
|
@ -7,11 +7,27 @@
|
||||||
import type {TippyController} from "@lib/components/Tippy.svelte"
|
import type {TippyController} from "@lib/components/Tippy.svelte"
|
||||||
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
import EmojiPicker from "@lib/components/EmojiPicker.svelte"
|
||||||
|
|
||||||
const {tippyParams = {}, onEmoji, class: className, children, ...restProps} = $props()
|
type Props = {
|
||||||
|
tippyParams?: Record<string, unknown>
|
||||||
|
onEmoji: (emoji: string) => void
|
||||||
|
allowKaomoji?: boolean
|
||||||
|
class?: string
|
||||||
|
children?: Snippet
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
tippyParams = {},
|
||||||
|
onEmoji,
|
||||||
|
allowKaomoji = false,
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
const open = () => tippy?.show()
|
const open = () => tippy?.show()
|
||||||
|
|
||||||
const onClick = (emoji: NativeEmoji) => {
|
const onClick = (emoji: string) => {
|
||||||
onEmoji(emoji)
|
onEmoji(emoji)
|
||||||
tippy?.hide()
|
tippy?.hide()
|
||||||
}
|
}
|
||||||
|
|
@ -31,14 +47,14 @@
|
||||||
let tippy: Maybe<TippyController> = $state()
|
let tippy: Maybe<TippyController> = $state()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document onmousemove={tippy?.visible ? onMouseMove : undefined} />
|
<svelte:document onmousemove={tippy?.visible && !allowKaomoji ? onMouseMove : undefined} />
|
||||||
|
|
||||||
<Button onclick={open} class={className} {...restProps}>
|
<Button onclick={open} class={className} {...restProps}>
|
||||||
<Tippy
|
<Tippy
|
||||||
bind:controller={tippy}
|
bind:controller={tippy}
|
||||||
class="flex"
|
class="flex"
|
||||||
component={EmojiPicker}
|
component={EmojiPicker}
|
||||||
props={{onClick}}
|
props={{onClick, allowKaomoji}}
|
||||||
params={{trigger: "manual", interactive: true, ...tippyParams}}>
|
params={{trigger: "manual", interactive: true, ...tippyParams}}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</Tippy>
|
</Tippy>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<style>
|
<style>
|
||||||
@media (max-width: 450px) {
|
@media (max-width: 450px) {
|
||||||
emoji-picker {
|
emoji-picker {
|
||||||
max-width: 100%;
|
|
||||||
--num-columns: 6;
|
--num-columns: 6;
|
||||||
--category-emoji-size: 1.125rem;
|
--category-emoji-size: 1.125rem;
|
||||||
}
|
}
|
||||||
|
|
@ -9,22 +8,98 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import cx from "classnames"
|
||||||
import "emoji-picker-element"
|
import "emoji-picker-element"
|
||||||
import emojiDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url"
|
import emojiDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url"
|
||||||
import type {Emoji} from "emoji-picker-element/shared"
|
import type {EmojiClickEvent} from "emoji-picker-element/shared"
|
||||||
import {onMount} from "svelte"
|
import {createSearch} from "@welshman/app"
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import {kaomoji} from "@lib/kaomoji"
|
||||||
|
|
||||||
interface Props {
|
type Props = {
|
||||||
onClick: (emoji: Emoji) => void
|
onClick: (emoji: string) => void
|
||||||
|
allowKaomoji?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const {onClick}: Props = $props()
|
const {onClick, allowKaomoji = false}: Props = $props()
|
||||||
|
|
||||||
let element: Element | undefined = $state()
|
const kaomojiSearch = createSearch(kaomoji, {
|
||||||
|
getValue: entry => entry.value,
|
||||||
onMount(() => {
|
fuseOptions: {
|
||||||
element?.addEventListener("emoji-click", (event: any) => onClick(event.detail as Emoji))
|
keys: ["name", "keywords"],
|
||||||
|
threshold: 0.4,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showEmoji = () => {
|
||||||
|
showingKaomoji = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const showKaomoji = () => {
|
||||||
|
showingKaomoji = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEmojiClick = ({detail}: EmojiClickEvent) => {
|
||||||
|
if (detail.unicode) {
|
||||||
|
onClick(detail.unicode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listen = (picker: HTMLElementTagNameMap["emoji-picker"]) => {
|
||||||
|
picker.addEventListener("emoji-click", onEmojiClick)
|
||||||
|
|
||||||
|
return () => picker.removeEventListener("emoji-click", onEmojiClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
let showingKaomoji = $state(false)
|
||||||
|
let query = $state("")
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<emoji-picker bind:this={element} data-source={emojiDataUrl} class="m-auto"></emoji-picker>
|
<div data-component="EmojiPicker" class="emoji-picker">
|
||||||
|
{#if allowKaomoji}
|
||||||
|
<div class="flex gap-1 px-2 pt-2" role="group" aria-label="Emoji or kaomoji">
|
||||||
|
<Button
|
||||||
|
aria-pressed={!showingKaomoji}
|
||||||
|
class={cx("button button-sm rounded-full", {
|
||||||
|
"button-primary": !showingKaomoji,
|
||||||
|
"button-ghost": showingKaomoji,
|
||||||
|
})}
|
||||||
|
onclick={showEmoji}>
|
||||||
|
Emoji
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-pressed={showingKaomoji}
|
||||||
|
class={cx("button button-sm rounded-full", {
|
||||||
|
"button-primary": showingKaomoji,
|
||||||
|
"button-ghost": !showingKaomoji,
|
||||||
|
})}
|
||||||
|
onclick={showKaomoji}>
|
||||||
|
Kaomoji
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="h-100 max-h-[60vh]">
|
||||||
|
{#if showingKaomoji}
|
||||||
|
<div class="flex h-full flex-col gap-2 p-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="input input-sm"
|
||||||
|
placeholder="Search"
|
||||||
|
aria-label="Search kaomoji"
|
||||||
|
bind:value={query} />
|
||||||
|
<div class="grid min-h-0 grow grid-cols-2 content-start gap-1 overflow-y-auto">
|
||||||
|
{#each kaomojiSearch.searchOptions(query.trim()) as entry (entry.name)}
|
||||||
|
<Button
|
||||||
|
class="button button-ghost button-sm h-auto justify-start px-2 py-2 text-left font-normal break-all"
|
||||||
|
aria-label={entry.name}
|
||||||
|
onclick={() => onClick(entry.value)}>
|
||||||
|
{entry.value}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<emoji-picker {@attach listen} data-source={emojiDataUrl}></emoji-picker>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -425,32 +425,30 @@ body.keyboard-open .room {
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* emoji picker */
|
/* emoji picker — the .emoji-picker frame around it supplies the border */
|
||||||
emoji-picker {
|
emoji-picker {
|
||||||
--background: var(--surface);
|
--background: var(--surface);
|
||||||
--border-color: var(--surface-more);
|
--border-size: 0;
|
||||||
--border-radius: var(--radius-2xl);
|
|
||||||
--button-active-background: var(--surface-more);
|
--button-active-background: var(--surface-more);
|
||||||
--button-hover-background: var(--surface);
|
--button-hover-background: var(--surface-more);
|
||||||
--indicator-color: var(--primary);
|
--indicator-color: var(--primary);
|
||||||
--input-border-color: var(--line);
|
--input-border-color: var(--line);
|
||||||
|
--input-border-size: var(--border-thin);
|
||||||
|
--input-border-radius: var(--radius-xl);
|
||||||
|
--input-padding: 0.4rem 0.7rem;
|
||||||
|
--input-font-size: 0.85rem;
|
||||||
--input-font-color: var(--content);
|
--input-font-color: var(--content);
|
||||||
|
--input-placeholder-color: var(--content-subtle);
|
||||||
--outline-color: var(--primary);
|
--outline-color: var(--primary);
|
||||||
width: min(352px, calc(100vw - 2rem));
|
width: 100%;
|
||||||
max-width: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* In a popover the border is what separates the picker from the page, but in a dialog
|
[data-tippy-root]:has([data-component="EmojiPicker"]) {
|
||||||
it sits on a surface of its own color, so it reads as a stray box. */
|
|
||||||
.dialog emoji-picker {
|
|
||||||
--border-size: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-tippy-root]:has(emoji-picker) {
|
|
||||||
max-width: min(352px, calc(100vw - 2rem));
|
max-width: min(352px, calc(100vw - 2rem));
|
||||||
}
|
}
|
||||||
|
|
||||||
.tippy-box:has(emoji-picker) {
|
.tippy-box:has([data-component="EmojiPicker"]) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
src/lib/components/emoji-picker.css
Normal file
11
src/lib/components/emoji-picker.css
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
@layer components {
|
||||||
|
.emoji-picker {
|
||||||
|
@apply bg-surface flex max-w-full flex-col overflow-hidden rounded-3xl;
|
||||||
|
width: 352px;
|
||||||
|
border: var(--border-thin) solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog .emoji-picker {
|
||||||
|
@apply rounded-none border-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
@import "./content-search.css";
|
@import "./content-search.css";
|
||||||
@import "./controls.css";
|
@import "./controls.css";
|
||||||
@import "./divider.css";
|
@import "./divider.css";
|
||||||
|
@import "./emoji-picker.css";
|
||||||
@import "./input.css";
|
@import "./input.css";
|
||||||
@import "./join.css";
|
@import "./join.css";
|
||||||
@import "./link.css";
|
@import "./link.css";
|
||||||
|
|
|
||||||
178
src/lib/kaomoji.ts
Normal file
178
src/lib/kaomoji.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
export type Kaomoji = {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
keywords?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kaomoji: Kaomoji[] = [
|
||||||
|
{
|
||||||
|
name: "Shrug",
|
||||||
|
value: "¯\\_(ツ)_/¯",
|
||||||
|
keywords: ["shrug", "whatever", "idk"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Table flip",
|
||||||
|
value: "(╯°□°)╯︵ ┻━┻",
|
||||||
|
keywords: ["angry", "flip", "rage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Table restore",
|
||||||
|
value: "┬─┬ノ( º _ ºノ)",
|
||||||
|
keywords: ["calm", "fix", "restore"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Double table flip",
|
||||||
|
value: "┻━┻ ︵ヽ(`Д´)ノ︵ ┻━┻",
|
||||||
|
keywords: ["angry", "flip"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Lenny",
|
||||||
|
value: "( ͡° ͜ʖ ͡°)",
|
||||||
|
keywords: ["lenny", "smug", "wink"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Disapproval",
|
||||||
|
value: "ಠ_ಠ",
|
||||||
|
keywords: ["disapproval", "look", "judge"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Look of disapproval",
|
||||||
|
value: "ಠ╭╮ಠ",
|
||||||
|
keywords: ["disapproval", "angry"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bear",
|
||||||
|
value: "ʕ•ᴥ•ʔ",
|
||||||
|
keywords: ["bear", "cute", "happy"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Hug",
|
||||||
|
value: "(づ。◕‿‿◕。)づ",
|
||||||
|
keywords: ["hug", "love", "give"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Gimme",
|
||||||
|
value: "༼ つ ◕_◕ ༽つ",
|
||||||
|
keywords: ["give", "want", "hug"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Happy",
|
||||||
|
value: "ヽ(・∀・)ノ",
|
||||||
|
keywords: ["happy", "wave", "yay"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Very happy",
|
||||||
|
value: "\(^o^)/",
|
||||||
|
keywords: ["happy", "celebrate", "yay"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Love",
|
||||||
|
value: "♥‿♥",
|
||||||
|
keywords: ["love", "heart", "happy"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Kiss",
|
||||||
|
value: "(づ ̄ ³ ̄)づ",
|
||||||
|
keywords: ["kiss", "love", "hug"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cry",
|
||||||
|
value: "ಥ_ಥ",
|
||||||
|
keywords: ["cry", "sad", "tears"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sob",
|
||||||
|
value: "(ಥ﹏ಥ)",
|
||||||
|
keywords: ["cry", "sob", "sad"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Angry",
|
||||||
|
value: "(╬ಠ益ಠ)",
|
||||||
|
keywords: ["angry", "mad", "rage"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Fighting",
|
||||||
|
value: "(ง'̀-'́)ง",
|
||||||
|
keywords: ["fight", "ready", "challenge"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Confused",
|
||||||
|
value: "(・・?)",
|
||||||
|
keywords: ["confused", "huh", "what"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Thinking",
|
||||||
|
value: "(・・ ) ?",
|
||||||
|
keywords: ["think", "hmm", "confused"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Nervous",
|
||||||
|
value: "(´・_・`)",
|
||||||
|
keywords: ["nervous", "worried", "awkward"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Facepalm",
|
||||||
|
value: "(-‸ლ)",
|
||||||
|
keywords: ["facepalm", "oops", "fail"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Celebrate",
|
||||||
|
value: "٩(◕‿◕。)۶",
|
||||||
|
keywords: ["celebrate", "party", "happy"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sparkle happy",
|
||||||
|
value: "✧٩(ˊωˋ*)و✧",
|
||||||
|
keywords: ["happy", "sparkle", "cheer"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cool",
|
||||||
|
value: "(⌐■_■)",
|
||||||
|
keywords: ["cool", "deal with it", "sunglasses"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sleepy",
|
||||||
|
value: "(-_-) zzZ",
|
||||||
|
keywords: ["sleep", "tired", "zzz"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Shocked",
|
||||||
|
value: "Σ(°△°|||)︴",
|
||||||
|
keywords: ["shock", "surprise", "gasp"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Waves",
|
||||||
|
value: "ヾ(^∇^)",
|
||||||
|
keywords: ["wave", "hello", "hi"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Flex",
|
||||||
|
value: "ᕦ(ò_óˇ)ᕤ",
|
||||||
|
keywords: ["flex", "strong", "muscle"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Peek",
|
||||||
|
value: "(/◕ヮ◕)/",
|
||||||
|
keywords: ["peek", "hello", "cute"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blush",
|
||||||
|
value: "(⁄ ⁄•⁄ω⁄•⁄ ⁄)",
|
||||||
|
keywords: ["blush", "shy", "embarrassed"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Meh",
|
||||||
|
value: "¯\\(°_o)/¯",
|
||||||
|
keywords: ["meh", "shrug", "dunno"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Run",
|
||||||
|
value: "ε=ε=ε=┌(;*´Д`)ノ",
|
||||||
|
keywords: ["run", "flee", "panic"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Magic",
|
||||||
|
value: "(ノ◕ヮ◕)ノ*:・゚✧",
|
||||||
|
keywords: ["magic", "sparkle", "throw"],
|
||||||
|
},
|
||||||
|
]
|
||||||
Loading…
Reference in a new issue