v3: added emoji picker, various bug and layout fixes
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user