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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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
@@ -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…"
|
||||||
|
|
||||||
|
|||||||
12
src/App.css
12
src/App.css
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
|
|||||||
@@ -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: "" });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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" }),
|
||||||
|
|||||||
Reference in New Issue
Block a user