v3: added emoji picker, various bug and layout fixes

This commit is contained in:
khannurien
2026-03-22 20:24:29 +00:00
parent a104113e05
commit c5051e3485
24 changed files with 384 additions and 177 deletions

View File

@@ -6,18 +6,22 @@ interface AvatarProps {
username: string;
hasAvatar: boolean;
size?: number;
version?: number;
}
export function Avatar(
{ userId, username, hasAvatar, size = 36 }: AvatarProps,
{ userId, username, hasAvatar, size = 36, version }: AvatarProps,
) {
const [imgFailed, setImgFailed] = useState(false);
const sizeStyle = { width: size, height: size };
if (hasAvatar && !imgFailed) {
const src = version
? `${API_URL}/api/avatars/${userId}?v=${version}`
: `${API_URL}/api/avatars/${userId}`;
return (
<img
src={`${API_URL}/api/avatars/${userId}`}
src={src}
alt={username}
title={username}
style={sizeStyle}

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import React, { useRef, useState } from "react";
import { Link } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Comment, RawComment, User } from "../model.ts";
@@ -31,8 +31,6 @@ function buildTree(comments: Comment[]): Map<string, Comment[]> {
return map;
}
const MAX_INDENT_DEPTH = 6;
interface CommentNodeProps {
comment: Comment;
tree: Map<string, Comment[]>;
@@ -161,9 +159,7 @@ function CommentNode({
{children.length > 0 && (
<ul
className="comment-replies"
style={depth >= MAX_INDENT_DEPTH
? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" }
: undefined}
style={{ "--depth": depth } as React.CSSProperties}
>
{children.map((child) => (
<CommentNode
@@ -357,9 +353,7 @@ function CommentNode({
{children.length > 0 && (
<ul
className="comment-replies"
style={depth >= MAX_INDENT_DEPTH
? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" }
: undefined}
style={{ "--depth": depth } as React.CSSProperties}
>
{children.map((child) => (
<CommentNode

View File

@@ -21,8 +21,7 @@ import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
import { ErrorCard } from "./ErrorCard.tsx";
import { FileDropZone } from "./FileDropZone.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
const MAX_FILE_SIZE = 50 * 1024 * 1024;
import { MAX_FILE_SIZE } from "../config/upload.ts";
type Mode = "url" | "file";
type Phase = "create" | "playlist";

View File

@@ -1,6 +1,8 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { EmojiPicker } from "frimousse";
import { MentionDropdown } from "./MentionDropdown.tsx";
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts";
export interface TextEditorHandle {
focus(): void;
@@ -34,6 +36,8 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
ref,
) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const emojiViewportRef = useRef<HTMLDivElement>(null);
const emojiSearchRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => textareaRef.current?.focus(),
@@ -48,6 +52,20 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
handleMentionSelect,
} = useMentionAutocomplete(value, onChange, textareaRef);
const {
emojiOpen,
emojiQuery,
detectEmojiTrigger,
handleEmojiSelect,
closeEmoji,
} = useEmojiTrigger(value, onChange, textareaRef);
useEffect(() => {
if (emojiOpen) {
emojiViewportRef.current?.focus({ preventScroll: true });
}
}, [emojiOpen]);
useEffect(() => {
if (!autoResize) return;
const el = textareaRef.current;
@@ -61,7 +79,10 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
<textarea
ref={textareaRef}
value={value}
onChange={handleMentionChange}
onChange={(e) => {
handleMentionChange(e);
detectEmojiTrigger(e.target.value, e.target.selectionStart ?? 0);
}}
onKeyDown={(e) => {
handleMentionKeyDown(e);
if (!e.defaultPrevented) onKeyDown?.(e);
@@ -79,6 +100,61 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
onSelect={handleMentionSelect}
/>
)}
{emojiOpen && (
<div
className="emoji-picker-float"
onKeyDownCapture={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
closeEmoji();
}
}}
onKeyDown={(e) => {
// Redirect printable characters to the search input when it
// doesn't already have focus (e.g. while navigating with arrows)
if (
e.key.length === 1 &&
!e.ctrlKey && !e.metaKey && !e.altKey &&
document.activeElement !== emojiSearchRef.current
) {
const search = emojiSearchRef.current;
if (search) {
e.preventDefault();
search.focus({ preventScroll: true });
// Inject the character via the native setter so React's
// synthetic onChange fires and frimousse updates its search state
const setter = Object.getOwnPropertyDescriptor(
globalThis.HTMLInputElement.prototype,
"value",
)?.set;
setter?.call(search, search.value + e.key);
search.dispatchEvent(new Event("input", { bubbles: true }));
}
}
}}
>
<EmojiPicker.Root
onEmojiSelect={(e) => handleEmojiSelect(e.emoji)}
>
<EmojiPicker.Search
ref={emojiSearchRef}
defaultValue={emojiQuery}
placeholder="Search emoji…"
/>
<EmojiPicker.Viewport
ref={emojiViewportRef}
// tabIndex={-1} makes the div programmatically focusable so
// frimousse's onFocusCapture can detect it and arm arrow-key nav
tabIndex={-1}
>
<EmojiPicker.Loading>Loading</EmojiPicker.Loading>
<EmojiPicker.Empty>No emoji found.</EmojiPicker.Empty>
<EmojiPicker.List />
</EmojiPicker.Viewport>
</EmojiPicker.Root>
</div>
)}
</div>
);
},