diff --git a/apps/web/package.json b/apps/web/package.json index 4f16c908..df9d7353 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "catalog:", "@tiptap/extension-link": "^2.22.2", + "@tiptap/extension-mention": "^3.0.9", "@tiptap/extension-placeholder": "^2.14.0", "@tiptap/pm": "^2.14.0", "@tiptap/react": "^2.14.0", diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index e4abc1c3..a494c3fd 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -34,6 +34,9 @@ import { } from "react-icons/hi2"; import { twMerge } from "tailwind-merge"; import tippy from "tippy.js"; +import Mention from "@tiptap/extension-mention"; +import Avatar from "./Avatar"; +import { getAvatarUrl } from "~/utils/helpers"; declare module "@tiptap/core" { interface Commands { @@ -69,6 +72,16 @@ export interface RenderSuggestionsProps { command: (item: SlashCommandItem) => void; } +export type WorkspaceMember = { + publicId: string; + user: { + id: string; + name: string | null; + image: string | null; + } | null; + email: string; +}; + const CommandsList = forwardRef< { onKeyDown: (props: SuggestionKeyDownProps) => boolean }, { @@ -182,6 +195,125 @@ const RenderSuggestions = () => { }; }; +type MentionItem = { id: string; label: string; image: string | null }; + +const MentionList = forwardRef< + { onKeyDown: (props: SuggestionKeyDownProps) => boolean }, + { + items: MentionItem[]; + command: (item: MentionItem) => void; + } +>(({ items, command }, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: SuggestionKeyDownProps) => { + if (event.key === "ArrowUp") { + setSelectedIndex((selectedIndex + items.length - 1) % items.length); + return true; + } + + if (event.key === "ArrowDown") { + setSelectedIndex((selectedIndex + 1) % items.length); + return true; + } + + if (event.key === "Enter") { + const item = items[selectedIndex]; + if (item) { + command(item); + } + return true; + } + + return false; + }, + })); + + return ( +
+
+ {items.length > 0 ? items.map((item, index) => ( + + )) : ( +
+ No results +
+ )} +
+
+ ); +}); + +MentionList.displayName = "MentionList"; + +const renderMentionSuggestions = () => { + let reactRenderer: ReactRenderer; + let popup: TippyInstance[]; + + return { + onStart: (props: any) => { + reactRenderer = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) return; + + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: reactRenderer.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate(props: any) { + reactRenderer.updateProps(props); + if (!props.clientRect) return; + popup[0]?.setProps({ getReferenceClientRect: props.clientRect }); + }, + onKeyDown(props: SuggestionKeyDownProps) { + if (props.event.key === "Escape") { + popup[0]?.hide(); + return true; + } + return ( + ( + reactRenderer.ref as { + onKeyDown?: (props: SuggestionKeyDownProps) => boolean; + } + ).onKeyDown?.(props) ?? false + ); + }, + onExit() { + popup[0]?.destroy(); + reactRenderer.destroy(); + }, + }; +}; + const SlashCommands = Extension.create({ name: "slash-commands", addOptions() { @@ -285,11 +417,13 @@ export default function Editor({ onChange, onBlur, readOnly = false, + workspaceMembers, }: { content: string | null; onChange?: (value: string) => void; onBlur?: () => void; readOnly?: boolean; + workspaceMembers: WorkspaceMember[]; }) { const containerRef = useRef(null); @@ -300,7 +434,7 @@ export default function Editor({ Placeholder.configure({ placeholder: readOnly ? "" - : t`Add description... (type '/' to open commands)`, + : t`Add description... (type '/' to open commands or '@' to mention)`, }), Link.configure({ openOnClick: true, @@ -321,6 +455,38 @@ export default function Editor({ char: "/", }, }), + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: { + char: "@", + items: ({ query }: { query: string }) => { + const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({ + id: member.publicId, + label: member?.user?.name ?? member.email, + image: member?.user?.image ?? null, + })); + const q = query.toLowerCase(); + return all.filter((u) => u.label.toLowerCase().includes(q)); + }, + command: ({ editor, range, props }: any) => { + const mentionHTML = `@${props.label} `; + + editor + .chain() + .focus() + .deleteRange(range) + .insertContent(mentionHTML) + .focus() + .run(); + }, + render: renderMentionSuggestions, + }, + renderText({ options, node }) { + return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; + }, + }), ], content, onUpdate: ({ editor }) => onChange?.(editor.getHTML()), @@ -369,6 +535,14 @@ export default function Editor({ .tiptap p { margin: 0 0 1rem 0 !important; } + .tiptap .mention { + background-color: rgba(59, 130, 246, 0.1); + border-radius: 0.25rem; + padding: 0.125rem 0.25rem; + color: rgb(59, 130, 246); + text-decoration: none; + font-weight: 500; + } `} {!readOnly && editor && } ({ + publicId: member.publicId, + email: member.email, + user: member.user ? { + id: member.publicId, + name: member.user.name, + image: member.user.image ?? null, + } : null, + })) ?? [] + } /> diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx index 06024832..2d64db69 100644 --- a/apps/web/src/views/card/index.tsx +++ b/apps/web/src/views/card/index.tsx @@ -247,6 +247,7 @@ export default function CardPage() { content={card.description} onChange={(e) => setValue("description", e)} onBlur={() => handleSubmit(onSubmit)()} + workspaceMembers={board?.workspace?.members ?? []} /> diff --git a/apps/web/src/views/public/board/CardModal.tsx b/apps/web/src/views/public/board/CardModal.tsx index 4b9baed3..ff132cee 100644 --- a/apps/web/src/views/public/board/CardModal.tsx +++ b/apps/web/src/views/public/board/CardModal.tsx @@ -119,7 +119,11 @@ export function CardModal({ {data?.description && (
- +
)} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e65d041..12806bad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: '@tiptap/extension-link': specifier: ^2.22.2 version: 2.22.2(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0) + '@tiptap/extension-mention': + specifier: ^3.0.9 + version: 3.0.9(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(@tiptap/suggestion@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)) '@tiptap/extension-placeholder': specifier: ^2.14.0 version: 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0) @@ -2776,6 +2779,13 @@ packages: peerDependencies: '@tiptap/core': ^2.7.0 + '@tiptap/extension-mention@3.0.9': + resolution: {integrity: sha512-DTQNAQkHZ+7Enlt3KvjqN6eECINlqPpET4Drzwj8Mmz9kMILc87cz3G2cwEKRrS9A1Xn3H3VpWvElWE2Wq9JHw==} + peerDependencies: + '@tiptap/core': ^3.0.9 + '@tiptap/pm': ^3.0.9 + '@tiptap/suggestion': ^3.0.9 + '@tiptap/extension-ordered-list@2.14.0': resolution: {integrity: sha512-QUZcyuW9AKvSfpFHcGmbyRCqxcpY0VNf0xipEtogxbA+JDDw3ZSPqU1dUgz9wk00RahPTwNDdY5aVjdQ5N4N9Q==} peerDependencies: @@ -9621,6 +9631,12 @@ snapshots: dependencies: '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) + '@tiptap/extension-mention@3.0.9(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0)(@tiptap/suggestion@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0))': + dependencies: + '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0) + '@tiptap/pm': 2.14.0 + '@tiptap/suggestion': 2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))(@tiptap/pm@2.14.0) + '@tiptap/extension-ordered-list@2.14.0(@tiptap/core@2.14.0(@tiptap/pm@2.14.0))': dependencies: '@tiptap/core': 2.14.0(@tiptap/pm@2.14.0)