Compare commits

...

1 Commits

Author SHA1 Message Date
Henry
aff1d76010 feat: allow submitting comments via keyboard shortcut 2026-07-07 22:24:32 +01:00
2 changed files with 64 additions and 30 deletions

View File

@@ -10,6 +10,7 @@ import { t } from "@lingui/core/macro";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
import Mention from "@tiptap/extension-mention"; import Mention from "@tiptap/extension-mention";
import Placeholder from "@tiptap/extension-placeholder"; import Placeholder from "@tiptap/extension-placeholder";
import Typography from "@tiptap/extension-typography";
import { import {
BubbleMenu, BubbleMenu,
EditorContent, EditorContent,
@@ -17,7 +18,6 @@ import {
ReactRenderer, ReactRenderer,
useEditor, useEditor,
} from "@tiptap/react"; } from "@tiptap/react";
import Typography from "@tiptap/extension-typography";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion"; import Suggestion from "@tiptap/suggestion";
import { import {
@@ -440,6 +440,7 @@ export default function Editor({
content, content,
onChange, onChange,
onBlur, onBlur,
onSubmit,
readOnly = false, readOnly = false,
workspaceMembers, workspaceMembers,
enableYouTubeEmbed = true, enableYouTubeEmbed = true,
@@ -449,6 +450,7 @@ export default function Editor({
content: string | null; content: string | null;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onBlur?: () => void; onBlur?: () => void;
onSubmit?: () => void;
readOnly?: boolean; readOnly?: boolean;
workspaceMembers: WorkspaceMember[]; workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean; enableYouTubeEmbed?: boolean;
@@ -461,12 +463,16 @@ export default function Editor({
// in refs to avoid the editor capturing stale closures on re-render. // in refs to avoid the editor capturing stale closures on re-render.
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
const onBlurRef = useRef(onBlur); const onBlurRef = useRef(onBlur);
const onSubmitRef = useRef(onSubmit);
useEffect(() => { useEffect(() => {
onChangeRef.current = onChange; onChangeRef.current = onChange;
}, [onChange]); }, [onChange]);
useEffect(() => { useEffect(() => {
onBlurRef.current = onBlur; onBlurRef.current = onBlur;
}, [onBlur]); }, [onBlur]);
useEffect(() => {
onSubmitRef.current = onSubmit;
}, [onSubmit]);
const editor = useEditor( const editor = useEditor(
{ {
@@ -489,8 +495,8 @@ export default function Editor({
Placeholder.configure({ Placeholder.configure({
placeholder: readOnly placeholder: readOnly
? "" ? ""
: placeholder ?? : (placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`, t`Add description... (type '/' to open commands or '@' to mention)`),
}), }),
SlashCommands.configure({ SlashCommands.configure({
commandItems: getCommandItems(disableHeadings), commandItems: getCommandItems(disableHeadings),
@@ -508,24 +514,26 @@ export default function Editor({
suggestion: { suggestion: {
char: "@", char: "@",
items: ({ query }: { query: string }) => { items: ({ query }: { query: string }) => {
const withEmail = workspaceMembers.filter((member) => member.email); const withEmail = workspaceMembers.filter(
(member) => member.email,
);
const mapped = withEmail.map((member: WorkspaceMember) => ({ const mapped = withEmail.map((member: WorkspaceMember) => ({
id: member.publicId, id: member.publicId,
label: member?.user?.name?.trim() || member.email || "", label: member?.user?.name?.trim() || member.email || "",
image: member?.user?.image ?? null, image: member?.user?.image ?? null,
})); }));
const all: MentionItem[] = mapped.filter( const all: MentionItem[] = mapped.filter(
(item) => item.label && item.label.length > 0, (item) => item.label && item.label.length > 0,
); );
const q = query.toLowerCase().trim(); const q = query.toLowerCase().trim();
if (q === "") { if (q === "") {
return all; return all;
} }
const filtered = all.filter((u) => const filtered = all.filter((u) =>
u.label.toLowerCase().includes(q), u.label.toLowerCase().includes(q),
); );
@@ -551,15 +559,15 @@ export default function Editor({
}, },
}), }),
Typography.configure({ Typography.configure({
openDoubleQuote: false, openDoubleQuote: false,
closeDoubleQuote: false, closeDoubleQuote: false,
openSingleQuote: false, openSingleQuote: false,
closeSingleQuote: false, closeSingleQuote: false,
oneHalf: false, oneHalf: false,
oneQuarter: false, oneQuarter: false,
threeQuarters: false, threeQuarters: false,
superscriptTwo: false, superscriptTwo: false,
superscriptThree: false, superscriptThree: false,
}), }),
...(enableYouTubeEmbed ? [YouTubeNode] : []), ...(enableYouTubeEmbed ? [YouTubeNode] : []),
], ],
@@ -581,6 +589,13 @@ export default function Editor({
attributes: { attributes: {
class: "outline-none focus:outline-none focus-visible:ring-0", class: "outline-none focus:outline-none focus-visible:ring-0",
}, },
handleKeyDown: (_view, event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
onSubmitRef.current?.();
return true;
}
return false;
},
}, },
editable: !readOnly, editable: !readOnly,
injectCSS: false, injectCSS: false,

View File

@@ -2,9 +2,10 @@ import { t } from "@lingui/core/macro";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiOutlineArrowUp } from "react-icons/hi2"; import { HiOutlineArrowUp } from "react-icons/hi2";
import Editor from "~/components/Editor";
import type { WorkspaceMember } from "~/components/Editor"; import type { WorkspaceMember } from "~/components/Editor";
import Editor from "~/components/Editor";
import LoadingSpinner from "~/components/LoadingSpinner"; import LoadingSpinner from "~/components/LoadingSpinner";
import { Tooltip } from "~/components/Tooltip";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -51,6 +52,21 @@ const NewCommentForm = ({
}); });
}; };
const isMac =
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
const submitTooltip = (
<div className="flex flex-row items-center gap-2 text-[11px]">
{t`Submit`}
<span className="inline-flex items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950">
{isMac ? "⌘" : "Ctrl"}
</span>
<span className="inline-flex items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950">
Enter
</span>
</div>
);
if (!canCreateComment) { if (!canCreateComment) {
return null; return null;
} }
@@ -63,23 +79,26 @@ const NewCommentForm = ({
<Editor <Editor
content={watch("comment")} content={watch("comment")}
onChange={(value) => setValue("comment", value)} onChange={(value) => setValue("comment", value)}
onSubmit={handleSubmit(onSubmit)}
workspaceMembers={workspaceMembers} workspaceMembers={workspaceMembers}
enableYouTubeEmbed={false} enableYouTubeEmbed={false}
placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`} placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
disableHeadings={true} disableHeadings={true}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<button <Tooltip content={submitTooltip} placement="top">
type="submit" <button
disabled={addCommentMutation.isPending} type="submit"
className="flex h-8 w-8 items-center justify-center rounded-full border border-light-600 bg-light-300 hover:bg-light-400 disabled:opacity-50 dark:border-dark-400 dark:bg-dark-200 dark:hover:bg-dark-400" disabled={addCommentMutation.isPending}
> className="flex h-8 w-8 items-center justify-center rounded-full border border-light-600 bg-light-300 hover:bg-light-400 disabled:opacity-50 dark:border-dark-400 dark:bg-dark-200 dark:hover:bg-dark-400"
{addCommentMutation.isPending ? ( >
<LoadingSpinner size="sm" /> {addCommentMutation.isPending ? (
) : ( <LoadingSpinner size="sm" />
<HiOutlineArrowUp /> ) : (
)} <HiOutlineArrowUp />
</button> )}
</button>
</Tooltip>
</div> </div>
</form> </form>
); );