v4: a comment and a chat message are edited in the same field as every text edited in place — each had an editor of its own: a comment's was its reply form, which showed a refused edit as a card and left the focus nowhere once closed, and a chat message's a box of its own with its own buttons and rules. InlineEdit gives its field, InlineEditField, to the components that open it with an action of their own, as these two do with their Edit and ✎: they keep those, a click on someone's message being no request to edit it. The field opens on the text with the caret at its end and as tall as the text, saves on Ctrl+Enter (and on Enter in the chat, where Shift+Enter breaks a line, as in its composer), leaves on Escape, sends nothing for a text left as it was, refuses an empty one, says why a save was refused in one line, and gives the focus back to the button that opened it, unless the caller has put it elsewhere, as the chat does to its composer. CommentForm only posts a comment or a reply now; ChatEditBox keeps only what is the chat's, scrolling itself into the list's view.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 3m20s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 44s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-20 18:16:43 +00:00
parent 3e03626cc1
commit eec73e9749
11 changed files with 186 additions and 204 deletions

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ msgstr ""
"Language-Team: \n" "Language-Team: \n"
"Plural-Forms: \n" "Plural-Forms: \n"
#: src/components/comment/CommentNode.tsx:140 #: src/components/comment/CommentNode.tsx:137
msgid "[deleted]" msgid "[deleted]"
msgstr "[deleted]" msgstr "[deleted]"
@@ -471,11 +471,10 @@ msgstr "Can't sign in there"
#: src/components/category/CategoryManager.tsx:241 #: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242 #: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92 #: src/components/comment/CommentForm.tsx:125
#: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:746 #: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42 #: src/components/form/FormActions.tsx:42
#: src/components/form/InlineEdit.tsx:277 #: src/components/form/InlineEdit.tsx:318
#: src/components/playlist/PlaylistEditForm.tsx:222 #: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/shared/ConfirmModal.tsx:63 #: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158 #: src/pages/Authorize.tsx:158
@@ -694,9 +693,9 @@ msgstr "Default tab"
#: src/components/category/CategoryManager.tsx:253 #: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118 #: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119 #: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220 #: src/components/chat/ChatMessageItem.tsx:217
#: src/components/comment/CommentNode.tsx:259
#: src/components/comment/CommentNode.tsx:265 #: src/components/comment/CommentNode.tsx:265
#: src/components/comment/CommentNode.tsx:271
#: src/components/form/DeleteButton.tsx:36 #: src/components/form/DeleteButton.tsx:36
#: src/components/shared/ConfirmModal.tsx:34 #: src/components/shared/ConfirmModal.tsx:34
msgid "Delete" msgid "Delete"
@@ -728,7 +727,7 @@ msgstr "Delete failed ({0})"
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Delete playlist" msgstr "Delete playlist"
#: src/components/comment/CommentNode.tsx:270 #: src/components/comment/CommentNode.tsx:264
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "Delete this comment?" msgstr "Delete this comment?"
@@ -736,7 +735,7 @@ msgstr "Delete this comment?"
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Delete this dump? This cannot be undone." msgstr "Delete this dump? This cannot be undone."
#: src/components/chat/ChatMessageItem.tsx:219 #: src/components/chat/ChatMessageItem.tsx:216
msgid "Delete this message?" msgid "Delete this message?"
msgstr "Delete this message?" msgstr "Delete this message?"
@@ -829,7 +828,7 @@ msgstr "Earlier"
#: src/components/chat/ChatMessageItem.tsx:107 #: src/components/chat/ChatMessageItem.tsx:107
#: src/components/chat/ChatMessageItem.tsx:108 #: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93 #: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256 #: src/components/comment/CommentNode.tsx:250
#: src/components/playlist/PlaylistHeader.tsx:104 #: src/components/playlist/PlaylistHeader.tsx:104
#: src/pages/Dump.tsx:556 #: src/pages/Dump.tsx:556
msgid "Edit" msgid "Edit"
@@ -867,7 +866,7 @@ msgstr "edited"
#. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132 #: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205 #: src/components/comment/CommentNode.tsx:202
#: src/components/playlist/PlaylistHeader.tsx:158 #: src/components/playlist/PlaylistHeader.tsx:158
#: src/pages/Dump.tsx:492 #: src/pages/Dump.tsx:492
msgid "edited {0}" msgid "edited {0}"
@@ -877,7 +876,7 @@ msgstr "edited {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128 #: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203 #: src/components/comment/CommentNode.tsx:200
#: src/components/playlist/PlaylistHeader.tsx:156 #: src/components/playlist/PlaylistHeader.tsx:156
#: src/pages/Dump.tsx:490 #: src/pages/Dump.tsx:490
msgid "Edited {0}" msgid "Edited {0}"
@@ -938,7 +937,7 @@ msgstr "Failed to post"
msgid "Failed to post comment" msgid "Failed to post comment"
msgstr "Failed to post comment" msgstr "Failed to post comment"
#: src/components/comment/CommentNode.tsx:291 #: src/components/comment/CommentNode.tsx:285
msgid "Failed to post reply" msgid "Failed to post reply"
msgstr "Failed to post reply" msgstr "Failed to post reply"
@@ -952,10 +951,6 @@ msgstr "Failed to save"
msgid "Failed to save category" msgid "Failed to save category"
msgstr "Failed to save category" msgstr "Failed to save category"
#: src/components/comment/CommentNode.tsx:218
msgid "Failed to save edit"
msgstr "Failed to save edit"
#: src/components/profile/ProfileHeader.tsx:174 #: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Failed to update avatar" msgstr "Failed to update avatar"
@@ -1538,11 +1533,11 @@ msgstr "Playlists"
msgid "Post comment" msgid "Post comment"
msgstr "Post comment" msgstr "Post comment"
#: src/components/comment/CommentNode.tsx:289 #: src/components/comment/CommentNode.tsx:283
msgid "Post reply" msgid "Post reply"
msgstr "Post reply" msgstr "Post reply"
#: src/components/comment/CommentNode.tsx:290 #: src/components/comment/CommentNode.tsx:284
#: src/components/comment/CommentThread.tsx:100 #: src/components/comment/CommentThread.tsx:100
#: src/components/dump/create/DumpCreateModal.tsx:752 #: src/components/dump/create/DumpCreateModal.tsx:752
msgid "Posting…" msgid "Posting…"
@@ -1640,7 +1635,7 @@ msgstr "Replace file"
#: src/components/chat/ChatMessageItem.tsx:97 #: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98 #: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244 #: src/components/comment/CommentNode.tsx:240
msgid "Reply" msgid "Reply"
msgstr "Reply" msgstr "Reply"
@@ -1697,16 +1692,13 @@ msgid "Role"
msgstr "Role" msgstr "Role"
#: src/components/category/CategoryManager.tsx:232 #: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85 #: src/components/form/InlineEdit.tsx:310
#: src/components/comment/CommentNode.tsx:216
#: src/components/form/InlineEdit.tsx:269
#: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/playlist/PlaylistEditForm.tsx:229
#: src/pages/DumpEdit.tsx:564 #: src/pages/DumpEdit.tsx:564
msgid "Save" msgid "Save"
msgstr "Save" msgstr "Save"
#: src/components/comment/CommentNode.tsx:217 #: src/components/form/InlineEdit.tsx:307
#: src/components/form/InlineEdit.tsx:266
#: src/components/playlist/PlaylistEditForm.tsx:226 #: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/ChangePasswordModal.tsx:119
#: src/pages/ResetPassword.tsx:191 #: src/pages/ResetPassword.tsx:191
@@ -1878,7 +1870,7 @@ msgstr "The month on {site}, {range}"
msgid "The new order couldn't be saved. Try again." msgid "The new order couldn't be saved. Try again."
msgstr "The new order couldn't be saved. Try again." msgstr "The new order couldn't be saved. Try again."
#: src/components/comment/CommentNode.tsx:282 #: src/components/comment/CommentNode.tsx:276
msgid "This comment couldn't be deleted. Try again." msgid "This comment couldn't be deleted. Try again."
msgstr "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again."
@@ -2163,7 +2155,7 @@ msgstr "Why & where"
msgid "Why?" msgid "Why?"
msgstr "Why?" msgstr "Why?"
#: src/components/comment/CommentNode.tsx:293 #: src/components/comment/CommentNode.tsx:287
msgid "Write a reply…" msgid "Write a reply…"
msgstr "Write a reply…" msgstr "Write a reply…"

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ msgstr ""
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
#: src/components/comment/CommentNode.tsx:140 #: src/components/comment/CommentNode.tsx:137
msgid "[deleted]" msgid "[deleted]"
msgstr "[supprimé]" msgstr "[supprimé]"
@@ -471,11 +471,10 @@ msgstr "Connexion impossible"
#: src/components/category/CategoryManager.tsx:241 #: src/components/category/CategoryManager.tsx:241
#: src/components/category/CategoryManager.tsx:242 #: src/components/category/CategoryManager.tsx:242
#: src/components/chat/ChatEditBox.tsx:92 #: src/components/comment/CommentForm.tsx:125
#: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:746 #: src/components/dump/create/DumpCreateModal.tsx:746
#: src/components/form/FormActions.tsx:42 #: src/components/form/FormActions.tsx:42
#: src/components/form/InlineEdit.tsx:277 #: src/components/form/InlineEdit.tsx:318
#: src/components/playlist/PlaylistEditForm.tsx:222 #: src/components/playlist/PlaylistEditForm.tsx:222
#: src/components/shared/ConfirmModal.tsx:63 #: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158 #: src/pages/Authorize.tsx:158
@@ -694,9 +693,9 @@ msgstr "Onglet par défaut"
#: src/components/category/CategoryManager.tsx:253 #: src/components/category/CategoryManager.tsx:253
#: src/components/chat/ChatMessageItem.tsx:118 #: src/components/chat/ChatMessageItem.tsx:118
#: src/components/chat/ChatMessageItem.tsx:119 #: src/components/chat/ChatMessageItem.tsx:119
#: src/components/chat/ChatMessageItem.tsx:220 #: src/components/chat/ChatMessageItem.tsx:217
#: src/components/comment/CommentNode.tsx:259
#: src/components/comment/CommentNode.tsx:265 #: src/components/comment/CommentNode.tsx:265
#: src/components/comment/CommentNode.tsx:271
#: src/components/form/DeleteButton.tsx:36 #: src/components/form/DeleteButton.tsx:36
#: src/components/shared/ConfirmModal.tsx:34 #: src/components/shared/ConfirmModal.tsx:34
msgid "Delete" msgid "Delete"
@@ -728,7 +727,7 @@ msgstr "Échec de la suppression ({0})"
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Supprimer la collection" msgstr "Supprimer la collection"
#: src/components/comment/CommentNode.tsx:270 #: src/components/comment/CommentNode.tsx:264
msgid "Delete this comment?" msgid "Delete this comment?"
msgstr "Supprimer ce commentaire ?" msgstr "Supprimer ce commentaire ?"
@@ -736,7 +735,7 @@ msgstr "Supprimer ce commentaire ?"
msgid "Delete this dump? This cannot be undone." msgid "Delete this dump? This cannot be undone."
msgstr "Supprimer cette reco ? Cette action est irréversible." msgstr "Supprimer cette reco ? Cette action est irréversible."
#: src/components/chat/ChatMessageItem.tsx:219 #: src/components/chat/ChatMessageItem.tsx:216
msgid "Delete this message?" msgid "Delete this message?"
msgstr "Supprimer ce message ?" msgstr "Supprimer ce message ?"
@@ -829,7 +828,7 @@ msgstr "Plus tôt"
#: src/components/chat/ChatMessageItem.tsx:107 #: src/components/chat/ChatMessageItem.tsx:107
#: src/components/chat/ChatMessageItem.tsx:108 #: src/components/chat/ChatMessageItem.tsx:108
#: src/components/chat/PendingItem.tsx:93 #: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256 #: src/components/comment/CommentNode.tsx:250
#: src/components/playlist/PlaylistHeader.tsx:104 #: src/components/playlist/PlaylistHeader.tsx:104
#: src/pages/Dump.tsx:556 #: src/pages/Dump.tsx:556
msgid "Edit" msgid "Edit"
@@ -867,7 +866,7 @@ msgstr "modifié"
#. placeholder {0}: relativeTime(dump.updatedAt) #. placeholder {0}: relativeTime(dump.updatedAt)
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:132 #: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205 #: src/components/comment/CommentNode.tsx:202
#: src/components/playlist/PlaylistHeader.tsx:158 #: src/components/playlist/PlaylistHeader.tsx:158
#: src/pages/Dump.tsx:492 #: src/pages/Dump.tsx:492
msgid "edited {0}" msgid "edited {0}"
@@ -877,7 +876,7 @@ msgstr "modifié {0}"
#. placeholder {0}: dump.updatedAt.toLocaleString() #. placeholder {0}: dump.updatedAt.toLocaleString()
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:128 #: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203 #: src/components/comment/CommentNode.tsx:200
#: src/components/playlist/PlaylistHeader.tsx:156 #: src/components/playlist/PlaylistHeader.tsx:156
#: src/pages/Dump.tsx:490 #: src/pages/Dump.tsx:490
msgid "Edited {0}" msgid "Edited {0}"
@@ -938,7 +937,7 @@ msgstr "Publication échouée"
msgid "Failed to post comment" msgid "Failed to post comment"
msgstr "Impossible de publier le commentaire" msgstr "Impossible de publier le commentaire"
#: src/components/comment/CommentNode.tsx:291 #: src/components/comment/CommentNode.tsx:285
msgid "Failed to post reply" msgid "Failed to post reply"
msgstr "Impossible de publier la réponse" msgstr "Impossible de publier la réponse"
@@ -952,10 +951,6 @@ msgstr "Enregistrement échoué"
msgid "Failed to save category" msgid "Failed to save category"
msgstr "Échec de l’enregistrement de la catégorie" msgstr "Échec de l’enregistrement de la catégorie"
#: src/components/comment/CommentNode.tsx:218
msgid "Failed to save edit"
msgstr "Impossible d'enregistrer la modification"
#: src/components/profile/ProfileHeader.tsx:174 #: src/components/profile/ProfileHeader.tsx:174
msgid "Failed to update avatar" msgid "Failed to update avatar"
msgstr "Impossible de mettre à jour l'avatar" msgstr "Impossible de mettre à jour l'avatar"
@@ -1538,11 +1533,11 @@ msgstr "Collections"
msgid "Post comment" msgid "Post comment"
msgstr "Publier le commentaire" msgstr "Publier le commentaire"
#: src/components/comment/CommentNode.tsx:289 #: src/components/comment/CommentNode.tsx:283
msgid "Post reply" msgid "Post reply"
msgstr "Publier la réponse" msgstr "Publier la réponse"
#: src/components/comment/CommentNode.tsx:290 #: src/components/comment/CommentNode.tsx:284
#: src/components/comment/CommentThread.tsx:100 #: src/components/comment/CommentThread.tsx:100
#: src/components/dump/create/DumpCreateModal.tsx:752 #: src/components/dump/create/DumpCreateModal.tsx:752
msgid "Posting…" msgid "Posting…"
@@ -1640,7 +1635,7 @@ msgstr "Remplacer le fichier"
#: src/components/chat/ChatMessageItem.tsx:97 #: src/components/chat/ChatMessageItem.tsx:97
#: src/components/chat/ChatMessageItem.tsx:98 #: src/components/chat/ChatMessageItem.tsx:98
#: src/components/comment/CommentNode.tsx:244 #: src/components/comment/CommentNode.tsx:240
msgid "Reply" msgid "Reply"
msgstr "Répondre" msgstr "Répondre"
@@ -1697,16 +1692,13 @@ msgid "Role"
msgstr "Rôle" msgstr "Rôle"
#: src/components/category/CategoryManager.tsx:232 #: src/components/category/CategoryManager.tsx:232
#: src/components/chat/ChatEditBox.tsx:85 #: src/components/form/InlineEdit.tsx:310
#: src/components/comment/CommentNode.tsx:216
#: src/components/form/InlineEdit.tsx:269
#: src/components/playlist/PlaylistEditForm.tsx:229 #: src/components/playlist/PlaylistEditForm.tsx:229
#: src/pages/DumpEdit.tsx:564 #: src/pages/DumpEdit.tsx:564
msgid "Save" msgid "Save"
msgstr "Enregistrer" msgstr "Enregistrer"
#: src/components/comment/CommentNode.tsx:217 #: src/components/form/InlineEdit.tsx:307
#: src/components/form/InlineEdit.tsx:266
#: src/components/playlist/PlaylistEditForm.tsx:226 #: src/components/playlist/PlaylistEditForm.tsx:226
#: src/components/profile/ChangePasswordModal.tsx:119 #: src/components/profile/ChangePasswordModal.tsx:119
#: src/pages/ResetPassword.tsx:191 #: src/pages/ResetPassword.tsx:191
@@ -1878,7 +1870,7 @@ msgstr "Le mois sur {site}, {range}"
msgid "The new order couldn't be saved. Try again." msgid "The new order couldn't be saved. Try again."
msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez." msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
#: src/components/comment/CommentNode.tsx:282 #: src/components/comment/CommentNode.tsx:276
msgid "This comment couldn't be deleted. Try again." msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
@@ -2163,7 +2155,7 @@ msgstr "Pourquoi & où"
msgid "Why?" msgid "Why?"
msgstr "Pourquoi ?" msgstr "Pourquoi ?"
#: src/components/comment/CommentNode.tsx:293 #: src/components/comment/CommentNode.tsx:287
msgid "Write a reply…" msgid "Write a reply…"
msgstr "Écrire une réponse…" msgstr "Écrire une réponse…"

View File

@@ -5109,18 +5109,6 @@ body.has-player .chat-fab {
color: var(--color-danger); color: var(--color-danger);
} }
.chat-edit {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.chat-edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.4rem;
}
/* The input rests on one line, as tall as the Send button beside it: both /* The input rests on one line, as tall as the Send button beside it: both
take --chat-composer-height, which any style's line of text fits in. */ take --chat-composer-height, which any style's line of text fits in. */
.chat-composer { .chat-composer {

View File

@@ -5,39 +5,33 @@
* @module * @module
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef } from "react";
import { Trans } from "@lingui/react/macro"; import { InlineEditField } from "../form/InlineEdit.tsx";
import { TextEditor, type TextEditorHandle } from "../editor/TextEditor.tsx"; import { COUNT_FROM } from "../../utils/chat/message.ts";
import { COUNT_FROM, sendable } from "../../utils/chat/message.ts";
import { VALIDATION } from "../../../shared/validation.ts"; import { VALIDATION } from "../../../shared/validation.ts";
/** Passed to {@link ChatEditBox} by `ChatMessageItem` while it is editing. */ /** Passed to {@link ChatEditBox} by `ChatMessageItem` while it is editing. */
export interface ChatEditBoxProps { export interface ChatEditBoxProps {
initial: string; initial: string;
/** /** Receives the trimmed text, only when it changed and can be sent. */
* Receives the trimmed text, only when it can be sent. It may be unchanged:
* the caller decides whether to send an edit.
*/
onSave: (body: string) => void; onSave: (body: string) => void;
/** Also called on Escape. */ /** Called once the text is saved, or left, Escape included. */
onCancel: () => void; onClose: () => void;
} }
/** /**
* Renders an editor holding `initial`, with Save and Cancel. Enter saves and * Renders the field every text edited in place gets, `InlineEditField`, with
* Shift+Enter starts a new line, as in the composer. * Enter saving and Shift+Enter starting a new line, as in the composer. It
* scrolls itself into the list's view.
*/ */
export function ChatEditBox( export function ChatEditBox(
{ initial, onSave, onCancel }: ChatEditBoxProps, { initial, onSave, onClose }: ChatEditBoxProps,
): React.JSX.Element { ): React.JSX.Element {
const [draft, setDraft] = useState(initial);
const editorRef = useRef<TextEditorHandle>(null);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
// The message is still easing to its new height, so a scroll on focus would // The message is still easing to its new height, so a scroll now would land
// land short. Scroll once the transition is over, Save button included. // short. Scroll once the transition is over, Save button included.
useEffect(() => { useEffect(() => {
editorRef.current?.focus({ atEnd: true, preventScroll: true });
const box = boxRef.current; const box = boxRef.current;
if (!box) return; if (!box) return;
const settle = parseFloat( const settle = parseFloat(
@@ -50,48 +44,19 @@ export function ChatEditBox(
return () => clearTimeout(id); return () => clearTimeout(id);
}, []); }, []);
const save = () => {
if (!sendable(draft)) return;
onSave(draft.trim());
};
return ( return (
<div className="chat-edit animate-in" ref={boxRef}> <div className="chat-edit" ref={boxRef}>
<TextEditor <InlineEditField
ref={editorRef} value={initial}
value={draft} onSave={onSave}
onChange={setDraft} onClose={onClose}
rows={2} kind="markdown"
autoResize required
maxLength={VALIDATION.CHAT_MESSAGE_MAX} maxLength={VALIDATION.CHAT_MESSAGE_MAX}
countFrom={COUNT_FROM} countFrom={COUNT_FROM}
className="comment-reply-textarea chat-input" fieldClassName="chat-input"
onSubmit={save} enterSaves
onCancel={onCancel}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
}}
/> />
<div className="chat-edit-actions">
<button
type="button"
className="btn-primary btn-sm"
onClick={save}
disabled={!sendable(draft)}
>
<Trans>Save</Trans>
</button>
<button
type="button"
className="btn-secondary btn-sm"
onClick={onCancel}
>
<Trans>Cancel</Trans>
</button>
</div>
</div> </div>
); );
} }

View File

@@ -194,11 +194,8 @@ export function ChatMessageItem(
? ( ? (
<ChatEditBox <ChatEditBox
initial={message.body} initial={message.body}
onSave={(body) => { onSave={(body) => onEdit(message.id, body)}
if (body !== message.body) onEdit(message.id, body); onClose={onEndEdit}
onEndEdit();
}}
onCancel={onEndEdit}
/> />
) )
: ( : (

View File

@@ -1,6 +1,6 @@
/** /**
* Renders the form of a dump's comment thread that posts a comment, a reply, * Renders the form of a dump's comment thread that posts a comment or a
* or an edit. * reply. An edit opens `InlineEditField` in the comment's place.
* *
* @module * @module
*/ */
@@ -15,7 +15,7 @@ import { useApiForm } from "../../hooks/form/useApiForm.ts";
/** /**
* Passed to {@link CommentForm} by `CommentThread`, for a new comment, and by * Passed to {@link CommentForm} by `CommentThread`, for a new comment, and by
* `CommentNode`, for a reply or an edit. * `CommentNode`, for a reply.
*/ */
export interface CommentFormProps { export interface CommentFormProps {
/** /**
@@ -28,7 +28,6 @@ export interface CommentFormProps {
pendingLabel: React.ReactNode; pendingLabel: React.ReactNode;
errorTitle: string; errorTitle: string;
placeholder?: string; placeholder?: string;
defaultValue?: string;
/** Without it, the form has no Cancel button. */ /** Without it, the form has no Cancel button. */
onCancel?: () => void; onCancel?: () => void;
/** /**
@@ -39,7 +38,7 @@ export interface CommentFormProps {
editorRef?: React.Ref<TextEditorHandle>; editorRef?: React.Ref<TextEditorHandle>;
/** /**
* `top` puts `leading` beside the editor, for the new-comment form. `inline` * `top` puts `leading` beside the editor, for the new-comment form. `inline`
* is for replies and edits, inside a comment. * is for replies, inside a comment.
*/ */
layout?: "inline" | "top"; layout?: "inline" | "top";
leading?: React.ReactNode; leading?: React.ReactNode;
@@ -57,7 +56,6 @@ export function CommentForm({
pendingLabel, pendingLabel,
errorTitle, errorTitle,
placeholder, placeholder,
defaultValue = "",
onCancel, onCancel,
cancelWhenEmpty, cancelWhenEmpty,
editorRef, editorRef,
@@ -66,7 +64,7 @@ export function CommentForm({
className, className,
}: CommentFormProps): React.JSX.Element { }: CommentFormProps): React.JSX.Element {
const form = useApiForm<{ body: string }>({ const form = useApiForm<{ body: string }>({
defaultValues: { body: defaultValue }, defaultValues: { body: "" },
}); });
const body = form.watch("body"); const body = form.watch("body");
const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX; const disabled = !body.trim() || body.length > VALIDATION.COMMENT_BODY_MAX;
@@ -91,8 +89,8 @@ export function CommentForm({
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
onSubmit={() => void submit()} onSubmit={() => void submit()}
// Escape closes a reply or an edit. The new-comment form has // Escape closes a reply. The new-comment form has nothing to
// nothing to close. // close.
onCancel={onCancel && !cancelWhenEmpty onCancel={onCancel && !cancelWhenEmpty
? () => { ? () => {
form.reset({ body: "" }); form.reset({ body: "" });

View File

@@ -30,6 +30,8 @@ import { useWS } from "../../hooks/ws/useWS.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { expectOk } from "../../utils/shared/api.ts"; import { expectOk } from "../../utils/shared/api.ts";
import { CommentForm } from "./CommentForm.tsx"; import { CommentForm } from "./CommentForm.tsx";
import { InlineEditField } from "../form/InlineEdit.tsx";
import { VALIDATION } from "../../../shared/validation.ts";
import { userPath } from "../../../shared/paths.ts"; import { userPath } from "../../../shared/paths.ts";
/** /**
@@ -75,7 +77,7 @@ export function CommentNode({
); );
const replyEditorRef = useRef<TextEditorHandle>(null); const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null); const editButtonRef = useRef<HTMLButtonElement>(null);
const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS(); const { likeCounts, myLikes, castCommentLike, removeCommentLike } = useWS();
const { authFetch } = useAuth(); const { authFetch } = useAuth();
@@ -105,18 +107,13 @@ export function CommentNode({
} }
} }
// Not called with the text unchanged, which would only mark it "edited".
async function handleEditSave(body: string) { async function handleEditSave(body: string) {
// Saving it unchanged would only mark it "edited".
if (body === comment.body) {
setEditOpen(false);
return;
}
const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, { const res = await authFetch(`${API_URL}/api/comments/${comment.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body } satisfies UpdateCommentRequest), body: JSON.stringify({ body } satisfies UpdateCommentRequest),
}); });
onCommentUpdated(deserializeComment(await expectOk<Wire<Comment>>(res))); onCommentUpdated(deserializeComment(await expectOk<Wire<Comment>>(res)));
setEditOpen(false);
} }
const canModerate = can(currentUser, "comment:moderate"); const canModerate = can(currentUser, "comment:moderate");
@@ -209,15 +206,14 @@ export function CommentNode({
</div> </div>
{editOpen {editOpen
? ( ? (
<CommentForm <InlineEditField
editorRef={editEditorRef} value={comment.body}
defaultValue={comment.body} onSave={handleEditSave}
onSubmit={handleEditSave} onClose={() => setEditOpen(false)}
submitLabel={<Trans>Save</Trans>} returnFocusTo={editButtonRef}
pendingLabel={<Trans>Saving…</Trans>} kind="markdown"
errorTitle={t`Failed to save edit`} required
className="animate-in" maxLength={VALIDATION.COMMENT_BODY_MAX}
onCancel={() => setEditOpen(false)}
/> />
) )
: <Markdown className="comment-body">{comment.body}</Markdown>} : <Markdown className="comment-body">{comment.body}</Markdown>}
@@ -246,12 +242,10 @@ export function CommentNode({
)} )}
{canEdit && !editOpen && ( {canEdit && !editOpen && (
<button <button
ref={editButtonRef}
type="button" type="button"
className="comment-action-btn" className="comment-action-btn"
onClick={() => { onClick={() => setEditOpen(true)}
setEditOpen(true);
setTimeout(() => editEditorRef.current?.focus(), 0);
}}
> >
<Trans>Edit</Trans> <Trans>Edit</Trans>
</button> </button>

View File

@@ -1,19 +1,14 @@
/** /**
* Renders a piece of text that whoever may change it edits where it stands, * Renders the editing of a text in place, with no page or form of its own.
* with no page or form of its own: a dump's title and note on its page, a * {@link InlineEdit} shows the text and opens the field over it: a dump's
* playlist's title and description on its page, a profile's description and * title and note, a playlist's title and description, a profile's description
* email on one's own profile. * and email. {@link InlineEditField} is that field alone, for a text that
* opens it with an action of its own: a comment's Edit, a chat message's ✎.
* *
* @module * @module
*/ */
import { import { type ReactNode, useEffect, useRef, useState } from "react";
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { Controller, FormProvider, useFormState } from "react-hook-form"; import { Controller, FormProvider, useFormState } from "react-hook-form";
import { useApiForm } from "../../hooks/form/useApiForm.ts"; import { useApiForm } from "../../hooks/form/useApiForm.ts";
@@ -49,10 +44,7 @@ export interface InlineEditProps {
placeholder?: string; placeholder?: string;
/** Added to the wrapper, which the text and the field both sit in. */ /** Added to the wrapper, which the text and the field both sit in. */
className?: string; className?: string;
/** /** Added to the field: see {@link InlineEditFieldProps}. */
* Added to a one-line field: the class of the text it edits, such as
* `dump-title`, so that the field takes on its type in every style.
*/
fieldClassName?: string; fieldClassName?: string;
children: ReactNode; children: ReactNode;
} }
@@ -80,33 +72,14 @@ export function InlineEdit(
}: InlineEditProps, }: InlineEditProps,
): React.JSX.Element { ): React.JSX.Element {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const heightRef = useHeightTransition<HTMLDivElement>(editing); const rootRef = useHeightTransition<HTMLDivElement>(editing);
const rootRef = useRef<HTMLDivElement | null>(null);
const setRoot = useCallback((el: HTMLDivElement | null) => {
rootRef.current = el;
return heightRef(el);
}, [heightRef]);
const markRef = useRef<HTMLButtonElement>(null); const markRef = useRef<HTMLButtonElement>(null);
const refocusRef = useRef(false);
useEffect(() => {
if (editing || !refocusRef.current) return;
refocusRef.current = false;
markRef.current?.focus({ preventScroll: true });
}, [editing]);
if (!editable) return <>{children}</>; if (!editable) return <>{children}</>;
const close = () => {
// A field disabled while it saved has dropped the focus to the body.
refocusRef.current = !!rootRef.current?.contains(document.activeElement) ||
document.activeElement === document.body;
setEditing(false);
};
return ( return (
<div <div
ref={setRoot} ref={rootRef}
className={`inline-edit inline-edit--${ className={`inline-edit inline-edit--${
kind === "markdown" ? "block" : "line" kind === "markdown" ? "block" : "line"
}${editing ? " inline-edit--editing" : ""}${ }${editing ? " inline-edit--editing" : ""}${
@@ -115,10 +88,11 @@ export function InlineEdit(
> >
{editing {editing
? ( ? (
<InlineEditForm <InlineEditField
value={value} value={value}
onSave={onSave} onSave={onSave}
onClose={close} onClose={() => setEditing(false)}
returnFocusTo={markRef}
kind={kind} kind={kind}
required={required} required={required}
maxLength={maxLength} maxLength={maxLength}
@@ -160,12 +134,52 @@ export function InlineEdit(
); );
} }
type InlineEditFormProps = /**
& Omit<InlineEditProps, "editable" | "className" | "children"> * Passed to {@link InlineEditField} by {@link InlineEdit}, and by the
& { onClose: () => void }; * components that open it with an action of their own, `CommentNode` and
* `ChatMessageItem`, in place of the text while it is edited.
*/
export interface InlineEditFieldProps extends
Pick<
InlineEditProps,
"value" | "kind" | "required" | "maxLength" | "placeholder"
> {
/**
* Saves the trimmed text. The field closes once it is done, and stays open
* under the error it throws. A text left as it was isn't passed to it.
*/
onSave: (value: string) => Promise<void> | void;
/** Called once the text is saved, or left: the field is to unmount. */
onClose: () => void;
/** Names a one-line field, such as "Edit title". */
label?: string;
/**
* Added to the field: for a one-line one, the class of the text it edits,
* such as `dump-title`, so that it takes on its type in every style.
*/
fieldClassName?: string;
/**
* In the Markdown editor, saves on Enter, a line breaking on Shift+Enter, as
* the chat's composer does. Ctrl/Cmd+Enter saves in either case.
*/
enterSaves?: boolean;
/** Where the Markdown editor's counter starts, near `maxLength`. */
countFrom?: number;
/**
* Takes the focus once the field is gone, if it had it and no one has
* taken it since: the button that opened it, back in its place.
*/
returnFocusTo?: React.RefObject<HTMLElement | null>;
}
/** The field, mounted afresh on each opening so it starts from `value`. */ /**
function InlineEditForm( * Renders the field holding `value`, focused with the caret at its end, and
* Save and Cancel under it. Enter saves a line, Ctrl/Cmd+Enter a Markdown
* text, Escape leaves. Saving is refused while the text is empty and
* `required`, or longer than `maxLength`. It is mounted afresh on each
* opening, so that it starts from `value`.
*/
export function InlineEditField(
{ {
value, value,
onSave, onSave,
@@ -176,7 +190,10 @@ function InlineEditForm(
placeholder, placeholder,
fieldClassName, fieldClassName,
label, label,
}: InlineEditFormProps, enterSaves,
countFrom,
returnFocusTo,
}: InlineEditFieldProps,
): React.JSX.Element { ): React.JSX.Element {
const form = useApiForm<{ text: string }>({ const form = useApiForm<{ text: string }>({
defaultValues: { text: value }, defaultValues: { text: value },
@@ -186,6 +203,16 @@ function InlineEditForm(
const editorRef = useRef<TextEditorHandle>(null); const editorRef = useRef<TextEditorHandle>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
// Runs once the field is gone and the button that opened it is back. The
// focus fell to the page with the field that had it, unless the caller has
// put it somewhere already.
useEffect(() => () => {
const active = document.activeElement;
if (!active || active === document.body) {
returnFocusTo?.current?.focus({ preventScroll: true });
}
}, [returnFocusTo]);
// The caret goes after the text, where an edit most often begins. // The caret goes after the text, where an edit most often begins.
useEffect(() => { useEffect(() => {
editorRef.current?.focus({ atEnd: true, preventScroll: true }); editorRef.current?.focus({ atEnd: true, preventScroll: true });
@@ -206,6 +233,9 @@ function InlineEditForm(
if (trimmed !== value.trim()) await onSave(trimmed); if (trimmed !== value.trim()) await onSave(trimmed);
onClose(); onClose();
}); });
const save = () => {
if (!tooLong && !blank) void onSubmit();
};
const cancel = () => { const cancel = () => {
if (!submitting) onClose(); if (!submitting) onClose();
}; };
@@ -222,16 +252,27 @@ function InlineEditForm(
render={({ field }) => ( render={({ field }) => (
<TextEditor <TextEditor
ref={editorRef} ref={editorRef}
className="comment-reply-textarea" className={`comment-reply-textarea${
fieldClassName ? ` ${fieldClassName}` : ""
}`}
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
onSubmit={() => { onSubmit={save}
if (!tooLong && !blank) void onSubmit();
}}
onCancel={cancel} onCancel={cancel}
onKeyDown={enterSaves
? (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
}
: undefined}
placeholder={placeholder} placeholder={placeholder}
// As tall as the text it opens on, growing as it is typed.
rows={1}
autoResize autoResize
maxLength={maxLength} maxLength={maxLength}
countFrom={countFrom}
disabled={submitting} disabled={submitting}
/> />
)} )}

View File

@@ -191,6 +191,21 @@ describe("CommentThread", () => {
expect(calls("PATCH /api/comments/:id")).toHaveLength(0); expect(calls("PATCH /api/comments/:id")).toHaveLength(0);
}); });
test("a refused edit stays open and says why; Escape goes back to Edit", async () => {
route("PATCH /api/comments/:id", () => fail("Too spicy", 400));
const user = setup([makeComment({ id: "a", userId: "me", body: "old" })]);
await user.click(screen.getByRole("button", { name: "Edit" }));
await user.type(
screen.getByDisplayValue("old"),
"er{Control>}{Enter}{/Control}",
);
expect(await screen.findByRole("alert")).toHaveTextContent("Too spicy");
expect(screen.getByDisplayValue("older")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.getByText("old")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Edit" })).toHaveFocus();
});
test("only the author or a moderator can edit and delete", () => { test("only the author or a moderator can edit and delete", () => {
const comments = [ const comments = [
makeComment({ id: "mine", userId: "me", body: "mine" }), makeComment({ id: "mine", userId: "me", body: "mine" }),