feat: update comments to use tiptap editor

This commit is contained in:
Henry
2026-02-08 22:35:42 +00:00
parent 1f0c780763
commit f797af64e7
4 changed files with 140 additions and 79 deletions

View File

@@ -387,46 +387,54 @@ export interface SlashNodeAttrs {
label?: string | null; label?: string | null;
} }
const CommandItems: SlashCommandItem[] = [ const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => {
{ const headingCommands: SlashCommandItem[] = disableHeadings
title: "Heading 1", ? []
icon: <HiH1 />, : [
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 1 }).run(), title: "Heading 1",
}, icon: <HiH1 />,
{ command: ({ editor }) =>
title: "Heading 2", editor.chain().focus().setHeading({ level: 1 }).run(),
icon: <HiH2 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 2 }).run(), title: "Heading 2",
}, icon: <HiH2 />,
{ command: ({ editor }) =>
title: "Heading 3", editor.chain().focus().setHeading({ level: 2 }).run(),
icon: <HiH3 />, },
command: ({ editor }) => {
editor.chain().focus().setHeading({ level: 3 }).run(), title: "Heading 3",
}, icon: <HiH3 />,
{ command: ({ editor }) =>
title: "Bullet List", editor.chain().focus().setHeading({ level: 3 }).run(),
icon: <HiOutlineListBullet />, },
command: ({ editor }) => editor.chain().focus().toggleBulletList().run(), ];
},
{ return [
title: "Ordered List", ...headingCommands,
icon: <HiOutlineNumberedList />, {
command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(), title: "Bullet List",
}, icon: <HiOutlineListBullet />,
{ command: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
title: "Blockquote", },
icon: <HiOutlineChatBubbleLeftEllipsis />, {
command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(), title: "Ordered List",
}, icon: <HiOutlineNumberedList />,
{ command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
title: "Code Block", },
icon: <HiOutlineCodeBracketSquare />, {
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(), title: "Blockquote",
}, icon: <HiOutlineChatBubbleLeftEllipsis />,
]; command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
},
{
title: "Code Block",
icon: <HiOutlineCodeBracketSquare />,
command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(),
},
];
};
export default function Editor({ export default function Editor({
content, content,
@@ -435,6 +443,8 @@ export default function Editor({
readOnly = false, readOnly = false,
workspaceMembers, workspaceMembers,
enableYouTubeEmbed = true, enableYouTubeEmbed = true,
placeholder,
disableHeadings = false,
}: { }: {
content: string | null; content: string | null;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@@ -442,18 +452,23 @@ export default function Editor({
readOnly?: boolean; readOnly?: boolean;
workspaceMembers: WorkspaceMember[]; workspaceMembers: WorkspaceMember[];
enableYouTubeEmbed?: boolean; enableYouTubeEmbed?: boolean;
placeholder?: string;
disableHeadings?: boolean;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const editor = useEditor( const editor = useEditor(
{ {
extensions: [ extensions: [
StarterKit, StarterKit.configure({
heading: disableHeadings ? false : undefined,
}),
Markdown, Markdown,
Placeholder.configure({ Placeholder.configure({
placeholder: readOnly placeholder: readOnly
? "" ? ""
: t`Add description... (type '/' to open commands or '@' to mention)`, : placeholder ??
t`Add description... (type '/' to open commands or '@' to mention)`,
}), }),
Link.configure({ Link.configure({
openOnClick: true, openOnClick: true,
@@ -466,10 +481,10 @@ export default function Editor({
autolink: true, autolink: true,
}), }),
SlashCommands.configure({ SlashCommands.configure({
commandItems: CommandItems, commandItems: getCommandItems(disableHeadings),
suggestion: { suggestion: {
items: ({ query }: { query: string }) => items: ({ query }: { query: string }) =>
filterSlashCommandItems(CommandItems, query), filterSlashCommandItems(getCommandItems(disableHeadings), query),
startOfLine: true, startOfLine: true,
char: "/", char: "/",
}, },

View File

@@ -1,12 +1,13 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { useState } from "react"; import { useState } from "react";
import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2"; import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Editor from "~/components/Editor";
import type { WorkspaceMember } from "~/components/Editor";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
@@ -29,7 +30,6 @@ const Comment = ({
createdAt, createdAt,
comment, comment,
isAuthor, isAuthor,
isAdmin,
isEdited = false, isEdited = false,
isViewOnly = false, isViewOnly = false,
}: { }: {
@@ -42,7 +42,6 @@ const Comment = ({
createdAt: string; createdAt: string;
comment: string | undefined; comment: string | undefined;
isAuthor: boolean; isAuthor: boolean;
isAdmin: boolean;
isEdited: boolean; isEdited: boolean;
isViewOnly: boolean; isViewOnly: boolean;
}) => { }) => {
@@ -57,6 +56,30 @@ const Comment = ({
}, },
}); });
const { data: cardData } = api.card.byId.useQuery(
{
cardPublicId,
},
{
enabled: !!cardPublicId && cardPublicId.length >= 12,
},
);
const workspaceMembers: WorkspaceMember[] =
cardData?.list.board.workspace.members
.filter((member) => member.email)
.map((member) => ({
publicId: member.publicId,
email: member.email,
user: member.user
? {
id: member.user.id,
name: member.user.name ?? null,
image: member.user.image ?? null,
}
: null,
})) ?? [];
if (!publicId) return null; if (!publicId) return null;
const updateCommentMutation = api.card.updateComment.useMutation({ const updateCommentMutation = api.card.updateComment.useMutation({
@@ -142,21 +165,28 @@ const Comment = ({
)} )}
</div> </div>
{!isEditing ? ( {!isEditing ? (
<ContentEditable <div className="mt-2">
html={comment ?? ""} <Editor
disabled={true} content={comment ?? null}
className="break-anywhere mt-2 text-sm" readOnly={true}
/> workspaceMembers={workspaceMembers}
enableYouTubeEmbed={false}
disableHeadings={true}
/>
</div>
) : ( ) : (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<ContentEditable <div className="mt-2">
placeholder={t`Add a comment...`} <Editor
html={watch("comment")} content={watch("comment")}
disabled={false} onChange={(value) => setValue("comment", value)}
onChange={(e) => setValue("comment", e.target.value)} workspaceMembers={workspaceMembers}
className="block w-full max-w-[800px] border-0 bg-transparent py-1.5 text-sm text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6" enableYouTubeEmbed={false}
/> placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
<div className="flex justify-end space-x-2"> disableHeadings={true}
/>
</div>
<div className="flex justify-end space-x-2 mt-2">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"

View File

@@ -1,8 +1,9 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import ContentEditable from "react-contenteditable";
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 LoadingSpinner from "~/components/LoadingSpinner"; import LoadingSpinner from "~/components/LoadingSpinner";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -13,7 +14,13 @@ interface FormValues {
comment: string; comment: string;
} }
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => { const NewCommentForm = ({
cardPublicId,
workspaceMembers,
}: {
cardPublicId: string;
workspaceMembers: WorkspaceMember[];
}) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { canCreateComment } = usePermissions(); const { canCreateComment } = usePermissions();
@@ -23,10 +30,6 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
}, },
}); });
const queryParams = {
cardPublicId,
};
const addCommentMutation = api.card.addComment.useMutation({ const addCommentMutation = api.card.addComment.useMutation({
onError: (_error, _newList) => { onError: (_error, _newList) => {
showPopup({ showPopup({
@@ -57,18 +60,13 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6" className="flex w-full max-w-[800px] flex-col rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
> >
<ContentEditable <Editor
placeholder={t`Add a comment...`} content={watch("comment")}
html={watch("comment")} onChange={(value) => setValue("comment", value)}
disabled={false} workspaceMembers={workspaceMembers}
onChange={(e) => setValue("comment", e.target.value)} enableYouTubeEmbed={false}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6" placeholder={t`Add comment... (type '/' to open commands or '@' to mention)`}
onKeyDown={async (e) => { disableHeadings={true}
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
await handleSubmit(onSubmit)();
}
}}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<button <button

View File

@@ -200,6 +200,21 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
const workspaceMembers = board?.workspace.members; const workspaceMembers = board?.workspace.members;
const boardId = board?.publicId; const boardId = board?.publicId;
const editorWorkspaceMembers =
workspaceMembers
?.filter((member) => member.email)
.map((member) => ({
publicId: member.publicId,
email: member.email,
user: member.user
? {
id: member.user.id,
name: member.user.name ?? null,
image: member.user.image ?? null,
}
: null,
})) ?? [];
const updateCard = api.card.update.useMutation({ const updateCard = api.card.update.useMutation({
onError: () => { onError: () => {
showPopup({ showPopup({
@@ -432,7 +447,10 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
</div> </div>
{!isTemplate && ( {!isTemplate && (
<div className="mt-6"> <div className="mt-6">
<NewCommentForm cardPublicId={cardId} /> <NewCommentForm
cardPublicId={cardId}
workspaceMembers={editorWorkspaceMembers}
/>
</div> </div>
)} )}
</div> </div>