Compare commits

...

1 Commits

Author SHA1 Message Date
Tom Moor 8e4b7a5a61 fix: Remove empty comment drafts to prevent sidebar auto-opening
When a user creates a comment mark but navigates away without typing,
the draft mark persists in the document. On return, this caused the
comment sidebar to auto-open and focus the empty draft.

Move onCreateCommentMark to the command level (called before dispatch)
so the Comment model exists in the store before updateComments runs.
This lets updateComments reliably identify stale empty drafts arriving
via sync and clean them up without affecting freshly created comments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 21:57:41 -04:00
3 changed files with 57 additions and 27 deletions
+30 -19
View File
@@ -10,6 +10,7 @@ import insertFiles from "@shared/editor/commands/insertFiles";
import EditorContainer from "@shared/editor/components/Styles";
import { AttachmentPreset } from "@shared/types";
import { ProsemirrorHelper } from "@shared/utils/ProsemirrorHelper";
import Storage from "@shared/utils/Storage";
import { getDataTransferFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import ClickablePadding from "~/components/ClickablePadding";
@@ -45,7 +46,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
const {
id,
onChange,
onCreateCommentMark,
onDeleteCommentMark,
onFileUploadStart,
onFileUploadStop,
@@ -207,23 +207,34 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
);
const updateComments = React.useCallback(() => {
if (onCreateCommentMark && onDeleteCommentMark && localRef.current) {
const commentMarks = localRef.current.getComments();
const commentIds = comments.orderedData.map((c) => c.id);
const commentMarkIds = commentMarks?.map((c) => c.id);
const newCommentIds = difference(
commentMarkIds,
previousCommentIds.current ?? [],
commentIds
);
if (!localRef.current) {
return;
}
newCommentIds.forEach((commentId) => {
const mark = commentMarks.find((c) => c.id === commentId);
if (mark) {
onCreateCommentMark(mark.id, mark.userId);
}
});
const commentMarks = localRef.current.getComments();
const commentIds = comments.orderedData.map((c) => c.id);
const commentMarkIds = commentMarks?.map((c) => c.id);
const newCommentIds = difference(
commentMarkIds,
previousCommentIds.current ?? [],
commentIds
);
// Clean up empty draft comment marks that arrived via sync from a
// previous session.
for (const commentId of newCommentIds) {
const mark = commentMarks.find((c) => c.id === commentId);
if (
mark &&
(mark as { draft?: boolean }).draft &&
mark.userId === props.userId &&
!Storage.get(`draft-${id}-${mark.id}`)
) {
setTimeout(() => localRef.current?.removeComment(mark.id), 0);
}
}
if (onDeleteCommentMark) {
const removedCommentIds = difference(
previousCommentIds.current ?? [],
commentMarkIds ?? []
@@ -232,10 +243,10 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
removedCommentIds.forEach((commentId) => {
onDeleteCommentMark(commentId);
});
previousCommentIds.current = commentMarkIds;
}
}, [onCreateCommentMark, onDeleteCommentMark, comments.orderedData]);
previousCommentIds.current = commentMarkIds;
}, [onDeleteCommentMark, comments.orderedData, id, props.userId]);
const handleChange = React.useCallback(
(event) => {
+17 -6
View File
@@ -1,4 +1,3 @@
import type { Attrs } from "prosemirror-model";
import type { Command } from "prosemirror-state";
import { NodeSelection, TextSelection } from "prosemirror-state";
import { v4 as uuidv4 } from "uuid";
@@ -8,21 +7,27 @@ import { addMark } from "./addMark";
import { collapseSelection } from "./collapseSelection";
import { chainCommands } from "prosemirror-commands";
export const addComment = (attrs: Attrs): Command =>
interface CommentAttrs {
userId: string;
onCreateCommentMark?: (commentId: string, userId: string) => void;
}
export const addComment = (attrs: CommentAttrs): Command =>
chainCommands(addCommentTextSelection(attrs), addCommentNodeSelection(attrs));
const addCommentNodeSelection =
(attrs: Attrs): Command =>
(attrs: CommentAttrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const { selection } = state;
const existingMarks = selection.node.attrs.marks ?? [];
const id = uuidv4();
const newMark = {
type: "comment",
attrs: {
id: uuidv4(),
id,
userId: attrs.userId,
draft: true,
},
@@ -31,12 +36,14 @@ const addCommentNodeSelection =
...selection.node.attrs,
marks: [...existingMarks, newMark],
};
attrs.onCreateCommentMark?.(id, attrs.userId);
dispatch?.(state.tr.setNodeMarkup(selection.from, undefined, newAttrs));
return true;
};
const addCommentTextSelection =
(attrs: Attrs): Command =>
(attrs: CommentAttrs): Command =>
(state, dispatch) => {
if (!(state.selection instanceof TextSelection)) {
return false;
@@ -57,9 +64,13 @@ const addCommentTextSelection =
return false;
}
const id = uuidv4();
attrs.onCreateCommentMark?.(id, attrs.userId);
chainTransactions(
addMark(state.schema.marks.comment, {
id: uuidv4(),
id,
userId: attrs.userId,
draft: true,
}),
+10 -2
View File
@@ -89,9 +89,13 @@ export default class Comment extends Mark {
return false;
}
const id = uuidv4();
this.options.onCreateCommentMark?.(id, this.options.userId);
chainTransactions(
toggleMark(type, {
id: uuidv4(),
id,
userId: this.options.userId,
draft: true,
}),
@@ -105,7 +109,11 @@ export default class Comment extends Mark {
commands() {
return this.options.onCreateCommentMark
? (): Command => addComment({ userId: this.options.userId })
? (): Command =>
addComment({
userId: this.options.userId,
onCreateCommentMark: this.options.onCreateCommentMark,
})
: undefined;
}