v3: the editors stop fighting the person typing — a review of every edit box turned up an Enter that sent a chat message mid-IME, an emoji picker that grabbed focus on ":D" and never went away, markdown that folded every line break, and limits that were counted but not kept. TextEditor: an Enter confirming an IME candidate no longer also sends, saves or picks. The : trigger wants two letters (Unicode ones too), not one, so emoticons, "note:" and "12:30" type through, and it keeps focus in the textarea — the picker's search follows what's typed, Enter or Tab takes the best match (a name equal to the query, then one starting with it, rather than frimousse's first in emoji order, which made ":rocket" an astronaut), the arrows step into the grid and typing there carries on in the textarea, and Escape closes just the picker, not the chat around it. It closes on a press outside, which it never did, and neither autocomplete fires inside code or a ``` fence. A ☺ button in each editor's corner opens the picker for mouse users, with a skin-tone selector that's remembered, and its search takes focus only once the picker is placed (a hidden field can't). Emoji, mentions and pasted images go in through execCommand, so Ctrl+Z takes back the insertion instead of wrecking the undo stack. Uploading an image made the textarea disabled, which dropped focus for good; it's read-only now, says "Uploading image…" under the box rather than only in an empty one's placeholder, reports a failed upload instead of swallowing it, and holds Enter until it's done. Any dropped file is caught — a non-image used to be opened by the browser in place of the page, draft and all — and a paste carrying text (a spreadsheet's cells come with a picture of themselves) pastes the text. The mention list follows the caret (a stale lookup can't close a newer one, lookups abort), wraps, scrolls its selection into view, closes on blur, and is a real listbox with aria-activedescendant. The counter shows from 80% of the limit by default rather than a standing "0 / 5000". Limits: chat Send and edit Save refused nothing over 2000 and let the server bounce it; they're disabled past it now. RichTextField's bare maxLength rule failed without a message, so an over-long "Why?" made Save do nothing and say nothing; it says so now. Inline editors answer Escape through a new onCancel (comment reply and edit, profile description, playlist description and title), the playlist editor takes Ctrl+Enter and can't be saved without a title, and saving a comment unchanged no longer stamps it edited. Markdown: a single newline is a line break, so a message written over three lines stops reading as one; links into the app route in place instead of opening a tab; headings are sized for prose instead of 2em page titles, and dropped from card teasers; GFM tables scroll in their own box and task lists lose the bullet; images load lazily. A chat message of only an emoji or three is shown large. Themes: brutalist and geocities give the new buttons no chrome, and geocities' ghost-button list, split in two by the placeholder-thumbnail rules in 1cb904d, is whole again, so its modal ✕, emoji cells and the picker's ✕ are flat as intended.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,81 +1,97 @@
|
||||
import { type RefObject, useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { insideCode } from "../utils/editorText.ts";
|
||||
|
||||
// Trigger: ':' not preceded by a word character, followed by a letter then
|
||||
// optional word chars / '+' / '-'. Requiring a letter first prevents ASCII
|
||||
// emoticons like ':-' or ':(' from opening the picker.
|
||||
const TRIGGER_RE = /(?<![A-Za-z0-9_]):([A-Za-z][A-Za-z0-9_+-]*)$/;
|
||||
// Trigger: ':' not preceded by a letter, digit or another ':', followed by a
|
||||
// letter then at least one more word char / '+' / '-'. Two characters, not
|
||||
// one, so emoticons like ':D' or ':P' — and "note:" or "12:30" — don't open
|
||||
// the picker; Unicode letters so French names ("coeur", "cœur") search too.
|
||||
const TRIGGER_RE = /(?<![\p{L}\p{N}_:]):(\p{L}[\p{L}\p{N}_+-]+)$/u;
|
||||
|
||||
interface EmojiTriggerState {
|
||||
open: boolean;
|
||||
/**
|
||||
* `inline`: opened by typing `:query` — focus stays in the textarea, the
|
||||
* picker's search follows what's typed, and a pick replaces the `:query`.
|
||||
* `browse`: opened from the button — focus goes to the picker's own search,
|
||||
* and a pick lands at the caret.
|
||||
*/
|
||||
export type EmojiMode = "closed" | "inline" | "browse";
|
||||
|
||||
interface EmojiState {
|
||||
mode: EmojiMode;
|
||||
query: string;
|
||||
/** The range of the textarea's value a pick replaces. */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const CLOSED: EmojiState = { mode: "closed", query: "", start: 0, end: 0 };
|
||||
|
||||
export function useEmojiTrigger(
|
||||
value: string,
|
||||
onChange: (value: string) => void,
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>,
|
||||
/** Replaces `[start, end)` of the textarea's value, with a space after
|
||||
* when `word` is set; see TextEditor. */
|
||||
replace: (start: number, end: number, text: string, word: boolean) => void,
|
||||
) {
|
||||
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);
|
||||
const [state, setStateRaw] = useState<EmojiState>(CLOSED);
|
||||
// Mirrored synchronously, so a change and a caret move in the same event
|
||||
// each see what the other just decided.
|
||||
const stateRef = useRef(state);
|
||||
const setState = useCallback((next: EmojiState) => {
|
||||
stateRef.current = next;
|
||||
setStateRaw(next);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Call this from the textarea's onChange (alongside the mention handler).
|
||||
* It receives the new value and cursor position already read from the event.
|
||||
* Call on every change (with `open`) and caret move (without): follows an
|
||||
* inline `:query` as it's typed, and closes once the caret leaves it.
|
||||
* Leaves a browse-mode picker alone.
|
||||
*/
|
||||
const detectEmojiTrigger = useCallback(
|
||||
(newValue: string, cursor: number) => {
|
||||
const textBefore = newValue.slice(0, cursor);
|
||||
const match = TRIGGER_RE.exec(textBefore);
|
||||
const syncEmojiTrigger = useCallback(
|
||||
(value: string, cursor: number, open: boolean) => {
|
||||
const s = stateRef.current;
|
||||
if (s.mode === "browse") return;
|
||||
if (!open && s.mode === "closed") return;
|
||||
const before = value.slice(0, cursor);
|
||||
const match = insideCode(before) ? null : TRIGGER_RE.exec(before);
|
||||
if (match) {
|
||||
triggerStartRef.current = match.index;
|
||||
triggerCursorRef.current = cursor;
|
||||
setState({ open: true, query: match[1] });
|
||||
} else {
|
||||
setState((s) => s.open ? { open: false, query: "" } : s);
|
||||
setState({
|
||||
mode: "inline",
|
||||
query: match[1],
|
||||
start: match.index,
|
||||
end: cursor,
|
||||
});
|
||||
} else if (s.mode !== "closed") {
|
||||
setState(CLOSED);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[setState],
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 openEmojiBrowser = useCallback((start: number, end: number) => {
|
||||
setState({ mode: "browse", query: "", start, end });
|
||||
}, [setState]);
|
||||
|
||||
const closeEmoji = useCallback(() => {
|
||||
setState({ open: false, query: "" });
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, [textareaRef]);
|
||||
const setEmojiQuery = useCallback((query: string) => {
|
||||
const s = stateRef.current;
|
||||
if (s.mode !== "closed") setState({ ...s, query });
|
||||
}, [setState]);
|
||||
|
||||
const selectEmoji = useCallback((native: string) => {
|
||||
const { mode, start, end } = stateRef.current;
|
||||
if (mode === "closed") return;
|
||||
setState(CLOSED);
|
||||
// An inline pick finishes a word, so it takes a space after it the way a
|
||||
// mention does; a browse pick goes in exactly where the caret was.
|
||||
replace(start, end, native, mode === "inline");
|
||||
}, [replace, setState]);
|
||||
|
||||
const closeEmoji = useCallback(() => setState(CLOSED), [setState]);
|
||||
|
||||
return {
|
||||
emojiOpen: state.open,
|
||||
emojiMode: state.mode,
|
||||
emojiQuery: state.query,
|
||||
detectEmojiTrigger,
|
||||
handleEmojiSelect,
|
||||
syncEmojiTrigger,
|
||||
openEmojiBrowser,
|
||||
setEmojiQuery,
|
||||
selectEmoji,
|
||||
closeEmoji,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { insideCode } from "../utils/editorText.ts";
|
||||
|
||||
export interface UserResult {
|
||||
id: string;
|
||||
@@ -16,7 +11,10 @@ export interface UserResult {
|
||||
interface MentionState {
|
||||
open: boolean;
|
||||
query: string;
|
||||
/** Where the `@` sits, and the caret after the query: the range a pick
|
||||
* replaces. */
|
||||
start: number;
|
||||
end: number;
|
||||
results: UserResult[];
|
||||
selectedIndex: number;
|
||||
}
|
||||
@@ -25,146 +23,170 @@ const CLOSED: MentionState = {
|
||||
open: false,
|
||||
query: "",
|
||||
start: 0,
|
||||
end: 0,
|
||||
results: [],
|
||||
selectedIndex: 0,
|
||||
};
|
||||
|
||||
// The server's mention rule (api/services/notification-service.ts): an `@` not
|
||||
// preceded by `[`, `(` or a word character, so what's offered is exactly what
|
||||
// would notify.
|
||||
const TRIGGER_RE = /(?<![[(\w])@(\w+)$/;
|
||||
|
||||
function getMentionQuery(
|
||||
value: string,
|
||||
pos: number,
|
||||
): { query: string; start: number } | null {
|
||||
const textBefore = value.slice(0, pos);
|
||||
// Match @word at end of text before cursor, not preceded by [ ( or word char
|
||||
const match = textBefore.match(/(?<![[(A-Za-z0-9_])@([A-Za-z0-9_]*)$/);
|
||||
if (!match || match[1].length === 0) return null;
|
||||
const start = pos - match[0].length;
|
||||
return { query: match[1], start };
|
||||
if (insideCode(textBefore)) return null;
|
||||
const match = TRIGGER_RE.exec(textBefore);
|
||||
if (!match) return null;
|
||||
return { query: match[1], start: match.index };
|
||||
}
|
||||
|
||||
export function useMentionAutocomplete(
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>,
|
||||
/** Replaces `[start, end)` of the textarea's value, with a space after
|
||||
* when `word` is set; see TextEditor. */
|
||||
replace: (start: number, end: number, text: string, word: boolean) => void,
|
||||
) {
|
||||
const [state, setState] = useState<MentionState>(CLOSED);
|
||||
const [state, setStateRaw] = useState<MentionState>(CLOSED);
|
||||
// Mirrored synchronously so the key handler and a fetch landing late both
|
||||
// read what the last keystroke decided, not the last render.
|
||||
const stateRef = useRef(state);
|
||||
const setState = useCallback((next: MentionState) => {
|
||||
stateRef.current = next;
|
||||
setStateRaw(next);
|
||||
}, []);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const cursorRef = useRef<number>(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleMentionChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const pos = e.target.selectionStart ?? newValue.length;
|
||||
cursorRef.current = pos;
|
||||
onChange(newValue);
|
||||
const cancelLookup = useCallback(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = null;
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
}, []);
|
||||
|
||||
const mention = getMentionQuery(newValue, pos);
|
||||
const close = useCallback(() => {
|
||||
cancelLookup();
|
||||
if (stateRef.current !== CLOSED) setState(CLOSED);
|
||||
}, [cancelLookup, setState]);
|
||||
|
||||
/**
|
||||
* Call on every change (with `lookup`) and caret move (without): tracks the
|
||||
* `@query` under the caret, searching for it after a short pause, and closes
|
||||
* once the caret leaves it.
|
||||
*/
|
||||
const syncMention = useCallback(
|
||||
(value: string, pos: number, lookup: boolean) => {
|
||||
const mention = getMentionQuery(value, pos);
|
||||
const s = stateRef.current;
|
||||
if (!mention) {
|
||||
setState(CLOSED);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (!lookup) {
|
||||
// A caret move within the same @query keeps the list; one that lands
|
||||
// on some other @word doesn't open anything.
|
||||
if (mention.start !== s.start || mention.query !== s.query) close();
|
||||
return;
|
||||
}
|
||||
if (mention.query === s.query && mention.start === s.start) return;
|
||||
|
||||
setState((s) => ({
|
||||
setState({
|
||||
...s,
|
||||
open: false,
|
||||
query: mention.query,
|
||||
start: mention.start,
|
||||
end: pos,
|
||||
selectedIndex: 0,
|
||||
}));
|
||||
});
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
cancelLookup();
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/users/search?q=${
|
||||
encodeURIComponent(mention.query)
|
||||
}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const body = await res.json();
|
||||
const current = stateRef.current;
|
||||
// Only the lookup for what's typed now may open or close the list.
|
||||
if (current.query !== mention.query) return;
|
||||
if (body.success && body.data.length > 0) {
|
||||
setState((s) =>
|
||||
s.query === mention.query
|
||||
? { ...s, open: true, results: body.data, selectedIndex: 0 }
|
||||
: s
|
||||
);
|
||||
setState({
|
||||
...current,
|
||||
open: true,
|
||||
results: body.data,
|
||||
selectedIndex: 0,
|
||||
});
|
||||
} else {
|
||||
setState(CLOSED);
|
||||
setState({ ...current, open: false, results: [] });
|
||||
}
|
||||
} catch {
|
||||
setState(CLOSED);
|
||||
// Aborted by a newer keystroke, or offline: nothing to offer.
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
[onChange],
|
||||
[cancelLookup, close, setState],
|
||||
);
|
||||
|
||||
const doSelect = useCallback(
|
||||
(username: string, start: number, cursorPos: number) => {
|
||||
const before = value.slice(0, start);
|
||||
const after = value.slice(cursorPos);
|
||||
onChange(`${before}@${username} ${after}`);
|
||||
setState(CLOSED);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
setTimeout(() => {
|
||||
const el = textareaRef.current;
|
||||
if (el) {
|
||||
const newPos = start + username.length + 2; // @ + username + space
|
||||
el.focus();
|
||||
el.setSelectionRange(newPos, newPos);
|
||||
}
|
||||
}, 0);
|
||||
const select = useCallback(
|
||||
(username: string) => {
|
||||
const { start, end } = stateRef.current;
|
||||
close();
|
||||
replace(start, end, `@${username}`, true);
|
||||
},
|
||||
[value, onChange, textareaRef],
|
||||
[close, replace],
|
||||
);
|
||||
|
||||
/** Returns whether it handled the key. */
|
||||
const handleMentionKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!state.open || state.results.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean => {
|
||||
const s = stateRef.current;
|
||||
if (!s.open || s.results.length === 0) return false;
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setState((s) => ({
|
||||
...s,
|
||||
selectedIndex: Math.min(s.selectedIndex + 1, s.results.length - 1),
|
||||
}));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setState((s) => ({
|
||||
...s,
|
||||
selectedIndex: Math.max(s.selectedIndex - 1, 0),
|
||||
}));
|
||||
} else if (
|
||||
(e.key === "Enter" || e.key === "Tab") && !e.ctrlKey && !e.metaKey
|
||||
const n = s.results.length;
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
// Wraps, like any listbox.
|
||||
setState({ ...s, selectedIndex: (s.selectedIndex + step + n) % n });
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(e.key === "Enter" || e.key === "Tab") &&
|
||||
!e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
const user = state.results[state.selectedIndex];
|
||||
if (user) doSelect(user.username, state.start, cursorRef.current);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setState(CLOSED);
|
||||
const user = s.results[s.selectedIndex];
|
||||
if (user) select(user.username);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
// Just the list: whatever Escape means to the editor's owner (cancel
|
||||
// an edit, close the chat) waits for a second press.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[state, doSelect],
|
||||
[close, select, setState],
|
||||
);
|
||||
|
||||
const handleMentionSelect = useCallback(
|
||||
(username: string) => {
|
||||
doSelect(username, state.start, cursorRef.current);
|
||||
},
|
||||
[doSelect, state.start],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => cancelLookup, [cancelLookup]);
|
||||
|
||||
return {
|
||||
mentionOpen: state.open,
|
||||
mentionOpen: state.open && state.results.length > 0,
|
||||
mentionResults: state.results,
|
||||
mentionSelectedIndex: state.selectedIndex,
|
||||
handleMentionChange,
|
||||
syncMention,
|
||||
closeMention: close,
|
||||
handleMentionKeyDown,
|
||||
handleMentionSelect,
|
||||
handleMentionSelect: select,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user