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

@@ -2722,8 +2722,8 @@ body.has-player .fab-new {
}
.comment-replies {
padding-left: 1.25rem;
margin-left: 1.1rem;
padding-left: max(0.4rem, calc(1.25rem - var(--depth, 0) * 0.1rem));
margin-left: max(0.25rem, calc(1.1rem - var(--depth, 0) * 0.09rem));
margin-top: 0.35rem;
border-left: 2px solid
color-mix(in srgb, var(--color-accent) 30%, transparent);
@@ -3193,6 +3193,74 @@ body.has-player .fab-new {
font-size: 0.9rem;
color: var(--color-text);
}
/* ── Emoji picker (frimousse) ── */
.emoji-picker-float {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
z-index: 201;
width: 320px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.emoji-picker-float input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border: none;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
outline: none;
}
.emoji-picker-float input::placeholder {
color: var(--color-text-muted, #888);
}
/* frimousse uses bare attributes (no data- prefix) */
.emoji-picker-float [frimousse-viewport] {
max-height: 220px;
/* frimousse already sets overflow-y: auto inline */
}
.emoji-picker-float [frimousse-category-header] {
padding: 4px 8px 2px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted, #888);
background: var(--color-surface);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.emoji-picker-float [frimousse-emoji] {
flex: 0 0 calc(100% / var(--frimousse-list-columns, 9));
background: none;
border: none;
padding: 4px 0;
font-size: 1.35em;
line-height: 1;
cursor: pointer;
border-radius: 4px;
transition: background 0.1s;
}
.emoji-picker-float [frimousse-emoji]:hover,
.emoji-picker-float [frimousse-emoji][data-active] {
background: var(--color-bg);
}
.emoji-picker-float [frimousse-loading],
.emoji-picker-float [frimousse-empty] {
display: block;
padding: 16px;
text-align: center;
font-size: 0.85rem;
color: var(--color-text-muted, #888);
}
.notif-icon--mention {
font-weight: 700;
font-family: monospace;

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>
);
},

1
src/config/upload.ts Normal file
View File

@@ -0,0 +1 @@
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB

View File

@@ -0,0 +1,79 @@
import { type RefObject, useCallback, useRef, useState } from "react";
// Trigger: ':' not preceded by a word character, followed by 1+ word chars
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z0-9_+\-]{1,})$/;
interface EmojiTriggerState {
open: boolean;
query: string;
}
export function useEmojiTrigger(
value: string,
onChange: (value: string) => void,
textareaRef: RefObject<HTMLTextAreaElement | null>,
) {
const [state, setState] = useState<EmojiTriggerState>({
open: false,
query: "",
});
// Saved positions at the moment the trigger was detected
const triggerStartRef = useRef(0);
const triggerCursorRef = useRef(0);
/**
* Call this from the textarea's onChange (alongside the mention handler).
* It receives the new value and cursor position already read from the event.
*/
const detectEmojiTrigger = useCallback(
(newValue: string, cursor: number) => {
const textBefore = newValue.slice(0, cursor);
const match = TRIGGER_RE.exec(textBefore);
if (match) {
triggerStartRef.current = match.index;
triggerCursorRef.current = cursor;
setState({ open: true, query: match[1] });
} else {
setState((s) => s.open ? { open: false, query: "" } : s);
}
},
[],
);
/**
* Called when frimousse fires onEmojiSelect.
* Replaces the ':query' text with the selected emoji.
*/
const handleEmojiSelect = useCallback(
(native: string) => {
const start = triggerStartRef.current;
const cursor = triggerCursorRef.current;
const newValue = value.slice(0, start) + native + " " +
value.slice(cursor);
onChange(newValue);
setState({ open: false, query: "" });
const newPos = start + native.length + 1; // +1 for trailing space
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.setSelectionRange(newPos, newPos);
}
});
},
[value, onChange, textareaRef],
);
const closeEmoji = useCallback(() => {
setState({ open: false, query: "" });
requestAnimationFrame(() => textareaRef.current?.focus());
}, [textareaRef]);
return {
emojiOpen: state.open,
emojiQuery: state.query,
detectEmojiTrigger,
handleEmojiSelect,
closeEmoji,
};
}

View File

@@ -314,6 +314,7 @@ export interface OnlineUser {
userId: string;
username: string;
hasAvatar: boolean;
avatarVersion?: number;
}
export interface WelcomeMessage {

View File

@@ -13,8 +13,7 @@ import { TextEditor } from "../components/TextEditor.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { FileDropZone } from "../components/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 DumpCreateState =

View File

@@ -555,6 +555,7 @@ export function Index() {
username={u.username}
hasAvatar={u.hasAvatar}
size={32}
version={u.avatarVersion}
/>
</Link>
))}

View File

@@ -503,11 +503,8 @@ export function UserPublicProfile() {
}
setState((prev) =>
prev.status === "loaded"
? {
...prev,
user: { ...prev.user, avatarMime: body.data?.avatarMime },
}
prev.status === "loaded" && body.data
? { ...prev, user: deserializeUser(body.data) }
: prev
);
} catch {
@@ -561,6 +558,7 @@ export function UserPublicProfile() {
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={72}
version={profileUser.updatedAt?.getTime()}
/>
{isOwnProfile && (
<label className="avatar-change-overlay" title="Change avatar">