feat: Up arrow in comment input should jump to editing previous comment (#9727)

closes #9700
This commit is contained in:
Tom Moor
2025-07-25 08:20:47 -04:00
committed by GitHub
parent e1fe955a76
commit 980220d2ac
5 changed files with 131 additions and 4 deletions
+47
View File
@@ -0,0 +1,47 @@
import { Plugin } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import Extension from "@shared/editor/lib/Extension";
export default class UpArrowAtStart extends Extension {
get name() {
return "upArrowAtStart";
}
get plugins() {
return [
new Plugin({
props: {
handleKeyDown: (view: EditorView, event: KeyboardEvent) => {
// Only handle up arrow key
if (event.key !== "ArrowUp") {
return false;
}
const { state } = view;
const { selection } = state;
// Check if cursor is at the very beginning of the document
// and it's an empty selection (cursor, not text selection)
if (selection.empty && selection.from <= 1) {
// Also check if we're at the start of the first text node
const $pos = state.doc.resolve(selection.from);
const isAtDocStart = $pos.parentOffset === 0 && $pos.depth <= 1;
if (isAtDocStart) {
// Call the onUpArrowAtStart callback if it exists
// Cast to any to access the custom prop since it's not in the base Props type
const props = this.editor.props as any;
if (props.onUpArrowAtStart) {
props.onUpArrowAtStart();
return true;
}
}
}
return false;
},
},
}),
];
}
}
@@ -11,6 +11,7 @@ import MentionMenuExtension from "~/editor/extensions/MentionMenu";
import PasteHandler from "~/editor/extensions/PasteHandler";
import PreventTab from "~/editor/extensions/PreventTab";
import SmartText from "~/editor/extensions/SmartText";
import UpArrowAtStart from "~/editor/extensions/UpArrowAtStart";
import useCurrentUser from "~/hooks/useCurrentUser";
const extensions = [
@@ -21,13 +22,19 @@ const extensions = [
ClipboardTextSerializer,
EmojiMenuExtension,
MentionMenuExtension,
UpArrowAtStart,
// Order these default key handlers last
PreventTab,
Keys,
];
type CommentEditorProps = EditorProps & {
/** Callback when user presses up arrow at the start of the editor */
onUpArrowAtStart?: () => void;
};
const CommentEditor = (
props: EditorProps,
props: CommentEditorProps,
ref: React.RefObject<SharedEditor>
) => {
const user = useCurrentUser({ rejectOnEmpty: false });
@@ -53,6 +53,8 @@ type Props = {
onFocus?: () => void;
/** Callback when the editor is blurred */
onBlur?: () => void;
/** Callback when user presses up arrow at the start of the editor */
onUpArrowAtStart?: () => void;
};
function CommentForm({
@@ -63,6 +65,7 @@ function CommentForm({
onSaveDraft,
onFocus,
onBlur,
onUpArrowAtStart,
autoFocus,
standalone,
placeholder,
@@ -227,6 +230,13 @@ function CommentForm({
file.current?.click();
};
const handleUpArrowAtStart = () => {
if (onUpArrowAtStart) {
onUpArrowAtStart();
setInputFocused(false);
}
};
// Focus the editor when it's a new comment just mounted, after a delay as the
// editor is mounted within a fade transition.
React.useEffect(() => {
@@ -296,6 +306,7 @@ function CommentForm({
onSave={handleSave}
onFocus={handleFocus}
onBlur={handleBlur}
onUpArrowAtStart={handleUpArrowAtStart}
maxLength={CommentValidation.maxLength}
placeholder={
placeholder ||
@@ -22,6 +22,7 @@ import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useCurrentUser from "~/hooks/useCurrentUser";
import { sidebarAppearDuration } from "~/styles/animations";
import CommentForm from "./CommentForm";
import CommentThreadItem from "./CommentThreadItem";
@@ -59,6 +60,7 @@ function CommentThread({
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const [autoFocus, setAutoFocusOn, setAutoFocusOff] = useBoolean(thread.isNew);
const user = useCurrentUser();
const can = usePolicy(document);
@@ -67,6 +69,11 @@ function CommentThread({
undefined
);
// Track edit states for all comments in the thread
const [editingCommentIds, setEditingCommentIds] = React.useState<Set<string>>(
new Set()
);
const canReply = can.comment && !thread.isResolved;
const highlightedText = ProsemirrorHelper.getAnchorTextForComment(
@@ -127,6 +134,30 @@ function CommentThread({
setCollapse(null);
};
const handleUpArrowAtStart = React.useCallback(() => {
// Find the previous comment by the current user in reverse order
const userComments = commentsInThread
.filter((comment) => comment.createdById === user.id)
.reverse(); // Start from most recent
if (userComments.length > 0) {
const previousComment = userComments[0];
setEditingCommentIds((prev) => new Set(prev).add(previousComment.id));
}
}, [commentsInThread, user.id]);
const handleCommentEditStart = React.useCallback((commentId: string) => {
setEditingCommentIds((prev) => new Set(prev).add(commentId));
}, []);
const handleCommentEditEnd = React.useCallback((commentId: string) => {
setEditingCommentIds((prev) => {
const newSet = new Set(prev);
newSet.delete(commentId);
return newSet;
});
}, []);
const renderShowMore = (collapse: { begin: number; final: number }) => {
const count = collapse.final - collapse.begin + 1;
const createdBy = commentsInThread
@@ -242,6 +273,9 @@ function CommentThread({
lastOfAuthor={lastOfAuthor}
previousCommentCreatedAt={commentsInThread[index - 1]?.createdAt}
dir={document.dir}
forceEdit={editingCommentIds.has(comment.id)}
onEditStart={() => handleCommentEditStart(comment.id)}
onEditEnd={() => handleCommentEditEnd(comment.id)}
/>
);
})}
@@ -261,6 +295,7 @@ function CommentThread({
highlightedText={
commentsInThread.length === 0 ? highlightedText : undefined
}
onUpArrowAtStart={handleUpArrowAtStart}
/>
</Fade>
)}
@@ -88,6 +88,12 @@ type Props = {
onUpdate?: (id: string, attrs: { resolved: boolean }) => void;
/** Text to highlight at the top of the comment */
highlightedText?: string;
/** Whether to force the comment into edit mode */
forceEdit?: boolean;
/** Callback when edit mode starts */
onEditStart?: () => void;
/** Callback when edit mode ends */
onEditEnd?: () => void;
};
function CommentThreadItem({
@@ -101,6 +107,9 @@ function CommentThreadItem({
onDelete,
onUpdate,
highlightedText,
forceEdit,
onEditStart,
onEditEnd,
}: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
@@ -112,6 +121,20 @@ function CommentThreadItem({
comment.updatedAt !== comment.createdAt &&
!comment.isResolved;
const [isEditing, setEditing, setReadOnly] = useBoolean();
// Handle forced edit mode
React.useEffect(() => {
if (forceEdit && !isEditing) {
setEditing();
onEditStart?.();
}
}, [forceEdit, isEditing, setEditing, onEditStart]);
// Override setReadOnly to call onEditEnd
const handleSetReadOnly = React.useCallback(() => {
setReadOnly();
onEditEnd?.();
}, [setReadOnly, onEditEnd]);
const formRef = React.useRef<HTMLFormElement>(null);
const handleAddReaction = React.useCallback(
@@ -156,7 +179,7 @@ function CommentThreadItem({
event.preventDefault();
try {
setReadOnly();
handleSetReadOnly();
comment.data = data;
await comment.save();
} catch (_err) {
@@ -167,7 +190,7 @@ function CommentThreadItem({
const handleCancel = () => {
setData(comment.data);
setReadOnly();
handleSetReadOnly();
};
return (
@@ -211,6 +234,7 @@ function CommentThreadItem({
defaultValue={data}
onChange={handleChange}
onSave={handleSave}
onCancel={handleCancel}
autoFocus
/>
{isEditing && (
@@ -261,7 +285,10 @@ function CommentThreadItem({
<Action
as={CommentMenu}
comment={comment}
onEdit={setEditing}
onEdit={() => {
setEditing();
onEditStart?.();
}}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>