mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
feat: Up arrow in comment input should jump to editing previous comment (#9727)
closes #9700
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user