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 Spinner from "@lib/components/Spinner.svelte"
|
||||
import DictationButton from "@app/components/DictationButton.svelte"
|
||||
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
import {makeEditor} from "@app/editor"
|
||||
import {getDictation} from "@app/dictation"
|
||||
|
|
@ -163,6 +164,10 @@
|
|||
<Icon icon={GallerySend} />
|
||||
{/if}
|
||||
</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}>
|
||||
<EditorContent {autofocus} {editor} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
import Icon from "@lib/components/Icon.svelte"
|
||||
|
|
@ -13,8 +12,8 @@
|
|||
|
||||
const {event, pubkeys}: Props = $props()
|
||||
|
||||
const onEmoji = async (emoji: NativeEmoji) => {
|
||||
const reaction = await $reactions.react(event, emoji.unicode)
|
||||
const onEmoji = async (emoji: string) => {
|
||||
const reaction = await $reactions.react(event, emoji)
|
||||
|
||||
return $wraps.publish({event: reaction.event, recipients: pubkeys, pow: 16})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import SmileCircle from "@assets/icons/smile-circle.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 onEmoji = async (emoji: NativeEmoji) => {
|
||||
const onEmoji = async (emoji: string) => {
|
||||
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})
|
||||
}
|
||||
|
|
|
|||
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">
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
|
|
@ -21,8 +20,7 @@
|
|||
|
||||
const h = tagValue(tagSpec("h"), event.tags)
|
||||
|
||||
const onEmoji = (emoji: NativeEmoji) =>
|
||||
publishReaction(event, {content: emoji.unicode, tags: []}, {url, h})
|
||||
const onEmoji = (emoji: string) => publishReaction(event, {content: emoji, tags: []}, {url, h})
|
||||
</script>
|
||||
|
||||
{#if ENABLE_ZAPS && !hideZap}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent, EventContent} from "@welshman/util"
|
||||
import {seen} from "@welshman/util"
|
||||
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
|
||||
|
|
@ -28,7 +27,7 @@
|
|||
const createReaction = async (values: EventContent) =>
|
||||
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>
|
||||
|
||||
<ReactionSummary {url} {event} {context} {deleteReaction} {createReaction} {reactionClass}>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import type {TippyController} from "@lib/components/Tippy.svelte"
|
||||
import Spinner from "@lib/components/Spinner.svelte"
|
||||
import ComposeMenu from "@app/components/ComposeMenu.svelte"
|
||||
import EditorEmojiButton from "@app/components/EditorEmojiButton.svelte"
|
||||
import DictationButton from "@app/components/DictationButton.svelte"
|
||||
import CommandArgBar from "@app/components/CommandArgBar.svelte"
|
||||
import EditorContent from "@app/editor/EditorContent.svelte"
|
||||
|
|
@ -187,6 +188,10 @@
|
|||
<Icon icon={WidgetAdd} />
|
||||
</Tippy>
|
||||
</Button>
|
||||
<EditorEmojiButton
|
||||
{editor}
|
||||
disabled={$uploading}
|
||||
class="join-item h-10 w-10 min-w-10 button button-neutral" />
|
||||
</div>
|
||||
<div class="chat-editor grow overflow-hidden">
|
||||
<EditorContent {autofocus} {editor} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import {tagSpec, tagValue} from "@welshman/util"
|
||||
import EmojiButton from "@lib/components/EmojiButton.svelte"
|
||||
|
|
@ -16,11 +15,11 @@
|
|||
|
||||
const shouldProtect = $relays.hasNip(url, 70)
|
||||
|
||||
const onEmoji = async (emoji: NativeEmoji) => {
|
||||
const onEmoji = async (emoji: string) => {
|
||||
const protect = await shouldProtect
|
||||
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)
|
||||
|
||||
if (room) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {TrustedEvent} from "@welshman/util"
|
||||
import * as nip19 from "nostr-tools/nip19"
|
||||
import {getIdOrAddress, tagSpec, tagValue, toNostrURI} from "@welshman/util"
|
||||
|
|
@ -54,11 +53,11 @@
|
|||
const isPinned = $derived($pinIds.includes(event.id))
|
||||
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()
|
||||
|
||||
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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import type {Snippet} from "svelte"
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import Bolt from "@assets/icons/bolt.svg?dataurl"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import Field from "@lib/components/Field.svelte"
|
||||
|
|
@ -22,8 +21,8 @@
|
|||
|
||||
let {amount = $bindable(), content = $bindable(), children}: Props = $props()
|
||||
|
||||
const onEmoji = (emoji: NativeEmoji) => {
|
||||
content = emoji.unicode
|
||||
const onEmoji = (emoji: string) => {
|
||||
content = emoji
|
||||
}
|
||||
|
||||
const selectAmount = (preset: number) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import type {NativeEmoji} from "emoji-picker-element/shared"
|
||||
import type {Snippet} from "svelte"
|
||||
import {between, throttle} from "@welshman/lib"
|
||||
import type {Maybe} from "@welshman/lib"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
|
|
@ -7,11 +7,27 @@
|
|||
import type {TippyController} from "@lib/components/Tippy.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 onClick = (emoji: NativeEmoji) => {
|
||||
const onClick = (emoji: string) => {
|
||||
onEmoji(emoji)
|
||||
tippy?.hide()
|
||||
}
|
||||
|
|
@ -31,14 +47,14 @@
|
|||
let tippy: Maybe<TippyController> = $state()
|
||||
</script>
|
||||
|
||||
<svelte:document onmousemove={tippy?.visible ? onMouseMove : undefined} />
|
||||
<svelte:document onmousemove={tippy?.visible && !allowKaomoji ? onMouseMove : undefined} />
|
||||
|
||||
<Button onclick={open} class={className} {...restProps}>
|
||||
<Tippy
|
||||
bind:controller={tippy}
|
||||
class="flex"
|
||||
component={EmojiPicker}
|
||||
props={{onClick}}
|
||||
props={{onClick, allowKaomoji}}
|
||||
params={{trigger: "manual", interactive: true, ...tippyParams}}>
|
||||
{@render children?.()}
|
||||
</Tippy>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<style>
|
||||
@media (max-width: 450px) {
|
||||
emoji-picker {
|
||||
max-width: 100%;
|
||||
--num-columns: 6;
|
||||
--category-emoji-size: 1.125rem;
|
||||
}
|
||||
|
|
@ -9,22 +8,98 @@
|
|||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import cx from "classnames"
|
||||
import "emoji-picker-element"
|
||||
import emojiDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url"
|
||||
import type {Emoji} from "emoji-picker-element/shared"
|
||||
import {onMount} from "svelte"
|
||||
import type {EmojiClickEvent} from "emoji-picker-element/shared"
|
||||
import {createSearch} from "@welshman/app"
|
||||
import Button from "@lib/components/Button.svelte"
|
||||
import {kaomoji} from "@lib/kaomoji"
|
||||
|
||||
interface Props {
|
||||
onClick: (emoji: Emoji) => void
|
||||
type Props = {
|
||||
onClick: (emoji: string) => void
|
||||
allowKaomoji?: boolean
|
||||
}
|
||||
|
||||
const {onClick}: Props = $props()
|
||||
const {onClick, allowKaomoji = false}: Props = $props()
|
||||
|
||||
let element: Element | undefined = $state()
|
||||
|
||||
onMount(() => {
|
||||
element?.addEventListener("emoji-click", (event: any) => onClick(event.detail as Emoji))
|
||||
const kaomojiSearch = createSearch(kaomoji, {
|
||||
getValue: entry => entry.value,
|
||||
fuseOptions: {
|
||||
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>
|
||||
|
||||
<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);
|
||||
}
|
||||
|
||||
/* emoji picker */
|
||||
/* emoji picker — the .emoji-picker frame around it supplies the border */
|
||||
emoji-picker {
|
||||
--background: var(--surface);
|
||||
--border-color: var(--surface-more);
|
||||
--border-radius: var(--radius-2xl);
|
||||
--border-size: 0;
|
||||
--button-active-background: var(--surface-more);
|
||||
--button-hover-background: var(--surface);
|
||||
--button-hover-background: var(--surface-more);
|
||||
--indicator-color: var(--primary);
|
||||
--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-placeholder-color: var(--content-subtle);
|
||||
--outline-color: var(--primary);
|
||||
width: min(352px, calc(100vw - 2rem));
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* In a popover the border is what separates the picker from the page, but in a dialog
|
||||
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) {
|
||||
[data-tippy-root]:has([data-component="EmojiPicker"]) {
|
||||
max-width: min(352px, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.tippy-box:has(emoji-picker) {
|
||||
.tippy-box:has([data-component="EmojiPicker"]) {
|
||||
overflow: visible;
|
||||
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 "./controls.css";
|
||||
@import "./divider.css";
|
||||
@import "./emoji-picker.css";
|
||||
@import "./input.css";
|
||||
@import "./join.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