import type { Range as TiptapRange } from "@tiptap/core"; import type { Editor as TiptapEditor } from "@tiptap/react"; import type { SuggestionKeyDownProps, SuggestionOptions, } from "@tiptap/suggestion"; import type { Instance as TippyInstance } from "tippy.js"; import { Button } from "@headlessui/react"; import { t } from "@lingui/core/macro"; import Link from "@tiptap/extension-link"; import Mention from "@tiptap/extension-mention"; import Placeholder from "@tiptap/extension-placeholder"; import Typography from "@tiptap/extension-typography"; import { BubbleMenu, EditorContent, Extension, ReactRenderer, useEditor, } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Suggestion from "@tiptap/suggestion"; import { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from "react"; import { HiH1, HiH2, HiH3, HiOutlineBold, HiOutlineChatBubbleLeftEllipsis, HiOutlineCodeBracket, HiOutlineCodeBracketSquare, HiOutlineItalic, HiOutlineListBullet, HiOutlineNumberedList, HiOutlineStrikethrough, } from "react-icons/hi2"; import { twMerge } from "tailwind-merge"; import tippy from "tippy.js"; import { Markdown } from "tiptap-markdown"; import { getAvatarUrl } from "~/utils/helpers"; import Avatar from "./Avatar"; import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode"; declare module "@tiptap/core" { interface Commands { slashSuggestion: { setSlashSuggestion: () => ReturnType; }; } } export interface SlashCommandItem { title: string; icon?: React.ReactNode; command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void; disabled?: boolean; } export interface SlashCommandsOptions { suggestion?: Partial; commandItems?: SlashCommandItem[]; options?: any; } function filterSlashCommandItems(items: SlashCommandItem[], query: string) { return items.filter((item) => item.title.toLowerCase().includes(query.toLowerCase()), ); } export interface RenderSuggestionsProps { editor: TiptapEditor; clientRect: () => DOMRect; items: SlashCommandItem[]; command: (item: SlashCommandItem) => void; } export interface WorkspaceMember { publicId: string; user: { id: string | null; name: string | null; image: string | null; } | null; email: string; } const CommandsList = forwardRef< { onKeyDown: (props: SuggestionKeyDownProps) => boolean }, { items: SlashCommandItem[]; command: (item: SlashCommandItem) => 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.map((item, index) => ( ))}
); }); CommandsList.displayName = "CommandsList"; const RenderSuggestions = () => { let reactRenderer: ReactRenderer; let popup: TippyInstance[]; return { onStart: (props: RenderSuggestionsProps) => { reactRenderer = new ReactRenderer(CommandsList, { 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: RenderSuggestionsProps) { reactRenderer.updateProps(props); if (!props.clientRect) return; popup[0]?.setProps({ getReferenceClientRect: props.clientRect, }); }, onKeyDown(props: SuggestionKeyDownProps): boolean { 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(); }, }; }; interface 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() { return { suggestion: { char: "/", command: ({ editor, range, props }) => { editor.chain().focus().deleteRange(range).run(); props.command({ editor, range }); }, items: ({ query }: { query: string }) => { return filterSlashCommandItems( this.parent().commandItems ?? [], query, ); }, render: () => { let component: ReturnType; return { onStart: (props: any) => { component = RenderSuggestions(); component.onStart(props); }, onUpdate(props: any) { component.onUpdate(props); }, onKeyDown(props: any) { if (props.event.key === "Escape") { return true; } return component.onKeyDown(props) ?? false; }, onExit: () => { component.onExit(); }, }; }, }, commandItems: [] as SlashCommandItem[], }; }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, ...this.options.suggestion, render: RenderSuggestions, } as SuggestionOptions), ]; }, }); export interface SlashNodeAttrs { id: string | null; label?: string | null; } const getCommandItems = (disableHeadings: boolean): SlashCommandItem[] => { const headingCommands: SlashCommandItem[] = disableHeadings ? [] : [ { title: "Heading 1", icon: , command: ({ editor }) => editor.chain().focus().setHeading({ level: 1 }).run(), }, { title: "Heading 2", icon: , command: ({ editor }) => editor.chain().focus().setHeading({ level: 2 }).run(), }, { title: "Heading 3", icon: , command: ({ editor }) => editor.chain().focus().setHeading({ level: 3 }).run(), }, ]; return [ ...headingCommands, { title: "Bullet List", icon: , command: ({ editor }) => editor.chain().focus().toggleBulletList().run(), }, { title: "Ordered List", icon: , command: ({ editor }) => editor.chain().focus().toggleOrderedList().run(), }, { title: "Blockquote", icon: , command: ({ editor }) => editor.chain().focus().toggleBlockquote().run(), }, { title: "Code Block", icon: , command: ({ editor }) => editor.chain().focus().toggleCodeBlock().run(), }, ]; }; export default function Editor({ content, onChange, onBlur, onSubmit, readOnly = false, workspaceMembers, enableYouTubeEmbed = true, placeholder, disableHeadings = false, }: { content: string | null; onChange?: (value: string) => void; onBlur?: () => void; onSubmit?: () => void; readOnly?: boolean; workspaceMembers: WorkspaceMember[]; enableYouTubeEmbed?: boolean; placeholder?: string; disableHeadings?: boolean; }) { const containerRef = useRef(null); // useEditor is created once (empty deps below), so keep the latest callbacks // in refs to avoid the editor capturing stale closures on re-render. const onChangeRef = useRef(onChange); const onBlurRef = useRef(onBlur); const onSubmitRef = useRef(onSubmit); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { onBlurRef.current = onBlur; }, [onBlur]); useEffect(() => { onSubmitRef.current = onSubmit; }, [onSubmit]); const editor = useEditor( { extensions: [ StarterKit.configure({ heading: disableHeadings ? false : undefined, }), Link.configure({ openOnClick: true, HTMLAttributes: { class: "text-blue-600 hover:text-blue-800 underline cursor-pointer", target: "_blank", rel: "noopener noreferrer", }, validate: (href) => /^https?:\/\//.test(href), autolink: true, linkOnPaste: true, }), Markdown.configure({ transformPastedText: true }), Placeholder.configure({ placeholder: readOnly ? "" : (placeholder ?? t`Add description... (type '/' to open commands or '@' to mention)`), }), SlashCommands.configure({ commandItems: getCommandItems(disableHeadings), suggestion: { items: ({ query }: { query: string }) => filterSlashCommandItems(getCommandItems(disableHeadings), query), startOfLine: true, char: "/", }, }), Mention.configure({ HTMLAttributes: { class: "mention", }, suggestion: { char: "@", items: ({ query }: { query: string }) => { const withEmail = workspaceMembers.filter( (member) => member.email, ); const mapped = withEmail.map((member: WorkspaceMember) => ({ id: member.publicId, label: member?.user?.name?.trim() || member.email || "", image: member?.user?.image ?? null, })); const all: MentionItem[] = mapped.filter( (item) => item.label && item.label.length > 0, ); const q = query.toLowerCase().trim(); if (q === "") { return all; } const filtered = all.filter((u) => u.label.toLowerCase().includes(q), ); return filtered; }, command: ({ editor, range, props }) => { const id = props.id ?? ""; const label = props.label ?? ""; const mentionHTML = `@${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}`; }, }), Typography.configure({ openDoubleQuote: false, closeDoubleQuote: false, openSingleQuote: false, closeSingleQuote: false, oneHalf: false, oneQuarter: false, threeQuarters: false, superscriptTwo: false, superscriptThree: false, }), ...(enableYouTubeEmbed ? [YouTubeNode] : []), ], content, onUpdate: ({ editor }) => onChangeRef.current?.(editor.getHTML()), onBlur: ({ event }) => { if ( document .querySelector(".tippy-box") ?.contains(event.relatedTarget as Node) ) return; // Only trigger onBlur if the click was outside both the editor and menu if (!containerRef.current?.contains(event.relatedTarget as Node)) { onBlurRef.current?.(); } }, editorProps: { attributes: { 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, injectCSS: false, }, [], // creating the editor only once ); // this will sync external content changes without re-creating the editor instance useEffect(() => { if (!editor) return; const currentHTML = editor.getHTML(); const safeContent = content ?? ""; if (safeContent !== currentHTML) { editor.commands.setContent(safeContent, false); } }, [content, editor]); // useEditor captures `readOnly` once at creation time (empty deps above), so // explicitly sync `editable` when the prop changes. Without this the editor // gets stuck read-only when `readOnly` flips from true to false after mount // (e.g. card permissions resolving slower than the card data on first load). useEffect(() => { if (!editor) return; if (editor.isEditable === !readOnly) return; editor.setEditable(!readOnly); }, [editor, readOnly]); return (
{!readOnly && editor && }
); } function EditorBubbleMenu({ editor }: { editor: TiptapEditor | null }) { const isMac = navigator.platform.includes("Mac"); const bubbleMenuItems = [ { title: "Bold", icon: , keys: ["meta", "b"], onClick: () => editor?.chain().focus().toggleBold().run(), active: editor?.isActive("bold"), }, { title: "Italic", icon: , keys: ["meta", "i"], onClick: () => editor?.chain().focus().toggleItalic().run(), active: editor?.isActive("italic"), }, { title: "Strikethrough", icon: , keys: ["meta", "shift", "s"], onClick: () => editor?.chain().focus().toggleStrike().run(), active: editor?.isActive("strike"), }, { title: "Code", icon: , keys: ["meta", "e"], onClick: () => editor?.chain().focus().toggleCode().run(), active: editor?.isActive("code"), }, ]; return (
{bubbleMenuItems.map((item) => ( ))}
); }