Files
outline/app/scenes/Document/components/Comments/CommentForm.tsx
T
Tom Moor 9811ab6aea feat: Emoji reaction shorthand (#12650)
* Add "+:emoji:" reaction shorthand to comment form

Typing a comment that consists solely of a leading "+" followed by a
single emoji now adds that emoji as a reaction to the comment above,
instead of posting a new reply — mirroring the Slack shorthand.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Move parseReactionShorthand into editor/lib/emoji

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Open emoji menu when colon is preceded by a plus

The suggestion menu's trigger boundary excluded "+", so typing "+:" never
opened the emoji menu — preventing the "+:emoji:" reaction shorthand from
being typed. Add a configurable `precededBy` option to the Suggestion
extension and set it to "+" for the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

* Always allow "+" before suggestion trigger

Simplify by adding "+" to the trigger boundary for all suggestion menus
rather than making it a per-menu option. This lets the "+:emoji:" reaction
shorthand open the emoji menu.

https://claude.ai/code/session_01RSiUiEFLBaRF6YBfPNPiX6

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-11 21:51:11 -04:00

398 lines
11 KiB
TypeScript

import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { v4 as uuidv4 } from "uuid";
import { m } from "framer-motion";
import { action } from "mobx";
import { observer } from "mobx-react";
import { ImageIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useTheme } from "styled-components";
import { parseReactionShorthand } from "@shared/editor/lib/emoji";
import type { ProsemirrorData } from "@shared/types";
import { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation, CommentValidation } from "@shared/validations";
import Comment from "~/models/Comment";
import { Avatar } from "~/components/Avatar";
import ButtonSmall from "~/components/ButtonSmall";
import { useDocumentContext } from "~/components/DocumentContext";
import Flex from "~/components/Flex";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import type { Editor as SharedEditor } from "~/editor";
import useCurrentUser from "~/hooks/useCurrentUser";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useStores from "~/hooks/useStores";
import { Bubble } from "./CommentThreadItem";
import { HighlightedText } from "./HighlightText";
import lazyWithRetry from "~/utils/lazyWithRetry";
import { mergeRefs } from "react-merge-refs";
import { HStack } from "~/components/primitives/HStack";
const CommentEditor = lazyWithRetry(() => import("./CommentEditor"));
type Props = {
/** Callback when the form is submitted. */
onSubmit?: () => void;
/** Callback when the draft should be saved. */
onSaveDraft: (data: ProsemirrorData | undefined) => void;
/** A draft comment for this thread. */
draft?: ProsemirrorData;
/** The document that the comment will be associated with */
documentId: string;
/** The comment thread that the comment will be associated with */
thread?: Comment;
/** Placeholder text to display in the editor */
placeholder?: string;
/** Whether to focus the editor on mount */
autoFocus?: boolean;
/** Whether to render the comment form as standalone, rather than as a reply */
standalone?: boolean;
/** Whether to animate the comment form in and out */
animatePresence?: boolean;
/** Text to highlight at the top of the comment */
highlightedText?: string;
/** Callback when the editor is focused */
onFocus?: () => void;
/** Callback when the editor is blurred */
onBlur?: () => void;
/** Callback when user presses up arrow at the start of the editor */
onUpArrowAtStart?: () => void;
/**
* Callback invoked when a new top-level comment is about to be created,
* just before it is added to the store. Receives the generated comment id.
*/
onBeforeCreate?: (commentId: string) => void;
};
function CommentForm({
documentId,
thread,
draft,
onSubmit,
onSaveDraft,
onFocus,
onBlur,
onUpArrowAtStart,
onBeforeCreate,
autoFocus,
standalone,
placeholder,
animatePresence,
highlightedText,
...rest
}: Props) {
const { editor } = useDocumentContext();
const formRef = React.useRef<HTMLFormElement>(null);
const editorRef = React.useRef<SharedEditor>(null);
const [forceRender, setForceRender] = React.useState(0);
const [inputFocused, setInputFocused] = React.useState(autoFocus);
const file = React.useRef<HTMLInputElement>(null);
const hasFocusedOnMount = React.useRef(false);
const theme = useTheme();
const { t } = useTranslation();
const { comments } = useStores();
const user = useCurrentUser();
const reset = React.useCallback(async () => {
const isEmpty = editorRef.current?.isEmpty() ?? true;
if (isEmpty && thread?.isNew) {
if (thread.id) {
editor?.removeComment(thread.id);
}
await thread.delete();
}
}, [editor, thread]);
useOnClickOutside(formRef, reset);
React.useEffect(() => {
window.addEventListener("beforeunload", reset);
return () => window.removeEventListener("beforeunload", reset);
}, [reset]);
const handleCreateComment = action(async (event: React.FormEvent) => {
event.preventDefault();
onSaveDraft(undefined);
setForceRender((s) => ++s);
setInputFocused(false);
const commentDraft = draft;
const comment =
thread ??
new Comment(
{
createdAt: new Date().toISOString(),
documentId,
data: draft,
reactions: [],
},
comments
);
comment
.save({
documentId,
data: draft,
})
.then(() => onSubmit?.())
.catch(() => {
onSaveDraft(commentDraft);
setForceRender((s) => ++s);
comment.isNew = true;
toast.error(t("Error creating comment"));
});
// optimistically update the comment model
comment.isNew = false;
comment.createdById = user.id;
comment.createdBy = user;
});
const handleCreateReply = action(async (event: React.FormEvent) => {
event.preventDefault();
if (!draft) {
return;
}
// "+:emoji:" shorthand: react to the comment above instead of replying.
if (thread && !thread.isNew) {
const emoji = parseReactionShorthand(draft);
if (emoji) {
const target = comments
.inThread(thread.id)
.filter((comment) => !comment.isNew)
.pop();
if (target) {
onSaveDraft(undefined);
setForceRender((s) => ++s);
void target.addReaction({ emoji, user });
onSubmit?.();
// re-focus the comment editor
setTimeout(() => {
editorRef.current?.focusAtStart();
}, 0);
return;
}
}
}
const commentDraft = draft;
onSaveDraft(undefined);
setForceRender((s) => ++s);
const comment = new Comment(
{
createdAt: new Date().toISOString(),
parentCommentId: thread?.id,
documentId,
data: draft,
reactions: [],
},
comments
);
comment.id = uuidv4();
if (!thread) {
onBeforeCreate?.(comment.id);
}
comments.add(comment);
comment
.save()
.then(() => onSubmit?.())
.catch(() => {
onSaveDraft(commentDraft);
setForceRender((s) => ++s);
comments.remove(comment.id);
comment.isNew = true;
toast.error(t("Error creating comment"));
});
// optimistically update the comment model
comment.isNew = false;
comment.createdById = user.id;
comment.createdBy = user;
// re-focus the comment editor
setTimeout(() => {
editorRef.current?.focusAtStart();
}, 0);
});
const handleChange = (
value: (asString: boolean, trim: boolean) => ProsemirrorData
) => {
const text = value(true, true);
onSaveDraft(text ? value(false, true) : undefined);
};
const handleSave = () => {
formRef.current?.dispatchEvent(
new Event("submit", { cancelable: true, bubbles: true })
);
};
const handleClickPadding = () => {
if (editorRef.current?.isBlurred) {
editorRef.current?.focusAtStart();
}
};
const handleCancel = async () => {
onSaveDraft(undefined);
setForceRender((s) => ++s);
setInputFocused(false);
await reset();
};
const handleFocus = () => {
onFocus?.();
setInputFocused(true);
};
const handleBlur = () => {
onBlur?.();
};
const handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
event.stopPropagation();
event.preventDefault();
const files = getEventFiles(event);
if (!files.length) {
return;
}
return editorRef.current?.insertFiles(event, files);
};
const handleImageUpload = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
file.current?.click();
};
const handleUpArrowAtStart = () => {
if (onUpArrowAtStart) {
onUpArrowAtStart();
setInputFocused(false);
}
};
// Focus the editor when it's a new comment just mounted
const handleMounted = React.useCallback(
(ref) => {
if (autoFocus && ref && !hasFocusedOnMount.current) {
if (!draft) {
ref.focusAtStart();
}
hasFocusedOnMount.current = true;
}
},
[autoFocus, draft]
);
const presence = animatePresence
? {
initial: {
opacity: 0,
marginBottom: -100,
},
animate: {
opacity: 1,
marginBottom: 0,
transition: {
type: "spring",
bounce: 0.1,
},
},
exit: {
opacity: 0,
marginBottom: -100,
scale: 0.98,
},
}
: {};
return (
<m.form
ref={formRef}
onSubmit={thread?.isNew ? handleCreateComment : handleCreateReply}
{...presence}
{...rest}
>
<VisuallyHidden.Root>
<input
ref={file}
type="file"
onChange={handleFilePicked}
accept={AttachmentValidation.imageContentTypes.join(", ")}
tabIndex={-1}
/>
</VisuallyHidden.Root>
<Flex gap={8} align="flex-start">
<Avatar model={user} size={24} style={{ marginTop: 8 }} />
<Bubble
gap={10}
onClick={handleClickPadding}
$lastOfThread
$firstOfAuthor
$firstOfThread={standalone}
column
>
{highlightedText && (
<HighlightedText>{highlightedText}</HighlightedText>
)}
<React.Suspense fallback={<div style={{ height: 24 }} />}>
<CommentEditor
key={`${forceRender}`}
ref={mergeRefs([editorRef, handleMounted])}
defaultValue={draft}
onChange={handleChange}
onSave={handleSave}
onFocus={handleFocus}
onBlur={handleBlur}
onUpArrowAtStart={handleUpArrowAtStart}
maxLength={CommentValidation.maxLength}
placeholder={
placeholder ||
// isNew is only the case for comments that exist in draft state,
// they are marks in the document, but not yet saved to the db.
(thread?.isNew
? `${t("Add a comment")}`
: `${t("Add a reply")}`)
}
/>
</React.Suspense>
{(inputFocused || draft) && (
<Flex justify="space-between" gap={8}>
<HStack>
<ButtonSmall type="submit" borderOnHover>
{thread && !thread.isNew ? t("Reply") : t("Post")}
</ButtonSmall>
<ButtonSmall onClick={handleCancel} neutral borderOnHover>
{t("Cancel")}
</ButtonSmall>
</HStack>
<Tooltip content={t("Upload image")} placement="top">
<NudeButton onClick={handleImageUpload}>
<ImageIcon color={theme.textTertiary} />
</NudeButton>
</Tooltip>
</Flex>
)}
</Bubble>
</Flex>
</m.form>
);
}
export default observer(CommentForm);