diff --git a/packages/shared/package.json b/packages/shared/package.json index ad844bcc7c..e113d2f036 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -119,6 +119,7 @@ "@tippyjs/react": "^4.2.6", "@tiptap/core": "^3.15.0", "@tiptap/extension-character-count": "^3.14.0", + "@tiptap/extension-image": "^3.14.0", "@tiptap/extension-link": "^3.14.0", "@tiptap/extension-placeholder": "^3.14.0", "@tiptap/react": "^3.14.0", diff --git a/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx index 7e56d724df..35dfb6caaa 100644 --- a/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx +++ b/packages/shared/src/components/fields/MarkdownInput/CommentMarkdownInput.tsx @@ -8,7 +8,8 @@ import type { import React, { forwardRef, useRef } from 'react'; import classNames from 'classnames'; import { defaultMarkdownCommands } from '../../../hooks/input'; -import MarkdownInput from './index'; +import type { RichTextInputRef } from '../RichTextInput'; +import RichTextInput from '../RichTextInput'; import type { Comment } from '../../../graphql/comments'; import { formToJson } from '../../../lib/form'; import type { Post } from '../../../graphql/posts'; @@ -64,7 +65,7 @@ export function CommentMarkdownInputComponent( const { mutateComment: { mutateComment, isLoading, isSuccess }, } = useWriteCommentContext(); - const markdownRef = useRef<{ clearDraft: () => void }>(null); + const richTextRef = useRef(null); const onSubmitForm: FormEventHandler = async (e) => { e.preventDefault(); @@ -78,8 +79,8 @@ export function CommentMarkdownInputComponent( const result = await mutateComment(content); // Clear draft after successful submission - if (result && markdownRef.current) { - markdownRef.current.clearDraft(); + if (result && richTextRef.current) { + richTextRef.current.clearDraft(); } return result; @@ -95,8 +96,8 @@ export function CommentMarkdownInputComponent( const result = await mutateComment(content); // Clear draft after successful submission - if (result && markdownRef.current) { - markdownRef.current.clearDraft(); + if (result && richTextRef.current) { + richTextRef.current.clearDraft(); } return result; @@ -111,21 +112,20 @@ export function CommentMarkdownInputComponent( style={style} ref={ref} > - { - if (markdownRefInstance) { - markdownRef.current = markdownRefInstance; + { + if (richTextRefInstance) { + richTextRef.current = richTextRefInstance; if (shouldFocus.current) { - markdownRefInstance.textareaRef.current.focus(); + richTextRefInstance.focus(); shouldFocus.current = false; } } }} className={{ - tab: classNames('!min-h-16', className?.tab), + container: classNames('!min-h-16', className?.markdownContainer), input: classNames(className?.input, replyTo && 'mt-0'), profile: replyTo && '!mt-0', - container: className?.markdownContainer, }} postId={postId} sourceId={sourceId} diff --git a/packages/shared/src/components/fields/RichTextEditor/LinkModal.tsx b/packages/shared/src/components/fields/RichTextEditor/LinkModal.tsx index b89caa2b7f..e733bd86b9 100644 --- a/packages/shared/src/components/fields/RichTextEditor/LinkModal.tsx +++ b/packages/shared/src/components/fields/RichTextEditor/LinkModal.tsx @@ -33,11 +33,12 @@ export const LinkModal = ({ const handleSubmit = useCallback( (e: FormEvent) => { e.preventDefault(); + e.stopPropagation(); + if (!url.trim()) { return; } - // Add https:// if no protocol specified let finalUrl = url.trim(); if (!/^https?:\/\//i.test(finalUrl)) { finalUrl = `https://${finalUrl}`; diff --git a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx index d1be7f9a6e..41cdf62c40 100644 --- a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx +++ b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx @@ -1,10 +1,11 @@ -import type { ReactElement, Ref } from 'react'; +import type { ReactElement, ReactNode, Ref } from 'react'; import React, { useState, useCallback, forwardRef, useImperativeHandle, } from 'react'; +import { useEditorState } from '@tiptap/react'; import type { Editor } from '@tiptap/react'; import { getMarkRange } from '@tiptap/core'; import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button'; @@ -23,6 +24,8 @@ import { LinkModal } from './LinkModal'; export interface RichTextToolbarProps { editor: Editor; onLinkAdd: (url: string, label?: string) => void; + inlineActions?: ReactNode; + rightActions?: ReactNode; } export interface RichTextToolbarRef { @@ -63,7 +66,7 @@ const ToolbarButton = ({ }; function RichTextToolbarComponent( - { editor, onLinkAdd }: RichTextToolbarProps, + { editor, onLinkAdd, inlineActions, rightActions }: RichTextToolbarProps, ref: Ref, ): ReactElement { const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); @@ -117,42 +120,61 @@ function RichTextToolbarComponent( setIsLinkModalOpen(false); }, []); + const editorState = useEditorState({ + editor, + selector: ({ editor: currentEditor }) => ({ + isBold: currentEditor.isActive('bold'), + isItalic: currentEditor.isActive('italic'), + isBulletList: currentEditor.isActive('bulletList'), + isOrderedList: currentEditor.isActive('orderedList'), + isLink: currentEditor.isActive('link'), + canUndo: currentEditor.can().undo(), + canRedo: currentEditor.can().redo(), + }), + }); + return ( <>
} - isActive={editor.isActive('bold')} + isActive={editorState.isBold} onClick={() => editor.chain().focus().toggleBold().run()} /> } - isActive={editor.isActive('italic')} + isActive={editorState.isItalic} onClick={() => editor.chain().focus().toggleItalic().run()} />
} - isActive={editor.isActive('bulletList')} + isActive={editorState.isBulletList} onClick={() => editor.chain().focus().toggleBulletList().run()} /> } - isActive={editor.isActive('orderedList')} + isActive={editorState.isOrderedList} onClick={() => editor.chain().focus().toggleOrderedList().run()} />
} - isActive={editor.isActive('link')} + isActive={editorState.isLink} onClick={openLinkModal} /> - {(editor.can().undo() || editor.can().redo()) && ( + {inlineActions && ( + <> +
+
{inlineActions}
+ + )} + {(editorState.canUndo || editorState.canRedo) && (
)} } isActive={false} onClick={() => editor.chain().focus().undo().run()} - disabled={!editor.can().undo()} + disabled={!editorState.canUndo} /> } isActive={false} onClick={() => editor.chain().focus().redo().run()} - disabled={!editor.can().redo()} + disabled={!editorState.canRedo} /> + {rightActions && ( +
{rightActions}
+ )}
{ + const match = value.match(/\(([^)]+)\)\s*$/); + return match?.[1]?.trim() ?? null; +}; + +const extractAlt = (value: string): string => + value.match(/^!\[([^\]]*)\]/)?.[1] ?? ''; + +export const MarkdownInputRules = Extension.create({ + name: 'markdownInputRules', + addInputRules() { + const rules = []; + const linkType = this.editor.schema.marks.link; + const imageType = this.editor.schema.nodes.image; + + if (linkType) { + rules.push( + markInputRule({ + find: linkRegex, + type: linkType, + getAttributes: (match) => { + const url = extractUrl(match[0]); + if (!url) { + return false; + } + return { href: url }; + }, + }), + ); + } + + if (imageType) { + rules.push( + nodeInputRule({ + find: imageRegex, + type: imageType, + getAttributes: (match) => { + const url = extractUrl(match[0]); + return { + src: url ?? '', + alt: extractAlt(match[0]), + }; + }, + }), + ); + } + + return rules; + }, +}); + +export default MarkdownInputRules; diff --git a/packages/shared/src/components/fields/RichTextEditor/richtext.module.css b/packages/shared/src/components/fields/RichTextEditor/richtext.module.css index fc760bf684..4283c5a0f2 100644 --- a/packages/shared/src/components/fields/RichTextEditor/richtext.module.css +++ b/packages/shared/src/components/fields/RichTextEditor/richtext.module.css @@ -29,10 +29,50 @@ @apply font-bold; } + & :where(h1) { + @apply my-3 text-text-primary typo-title1 font-bold; + } + + & :where(h2) { + @apply my-3 text-text-primary typo-title2 font-bold; + } + + & :where(h3) { + @apply my-3 text-text-primary typo-title3 font-bold; + } + + & :where(h4) { + @apply my-3 text-text-primary typo-body font-bold; + } + + & :where(h5) { + @apply my-2 text-text-primary typo-body font-bold; + } + + & :where(h6) { + @apply my-2 text-text-primary typo-callout font-bold; + } + & :where(em) { @apply italic; } + & :where(code) { + @apply rounded-6 bg-surface-float px-1 py-0.5 font-mono text-text-primary; + } + + & :where(pre) { + @apply my-3 overflow-x-auto rounded-12 bg-surface-float p-3; + } + + & :where(pre code) { + @apply bg-transparent p-0; + } + + & :where(img) { + @apply max-w-full rounded-8; + } + /* Placeholder styling */ & :global(.is-editor-empty:first-child::before) { @apply text-text-quaternary pointer-events-none float-left h-0; diff --git a/packages/shared/src/components/fields/RichTextEditor/useDraftStorage.ts b/packages/shared/src/components/fields/RichTextEditor/useDraftStorage.ts new file mode 100644 index 0000000000..bd15f3cc3c --- /dev/null +++ b/packages/shared/src/components/fields/RichTextEditor/useDraftStorage.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { generateStorageKey, StorageTopic } from '../../../lib/storage'; +import { storageWrapper } from '../../../lib/storageWrapper'; + +interface UseDraftStorageProps { + postId?: string; + editCommentId?: string; + parentCommentId?: string; + content: string; + isDirty: boolean; +} + +export function useDraftStorage({ + postId, + editCommentId, + parentCommentId, + content, + isDirty, +}: UseDraftStorageProps) { + const saveTimeoutRef = useRef(); + + const draftStorageKey = useMemo(() => { + if (!postId) { + return null; + } + const identifier = editCommentId || parentCommentId || postId; + return generateStorageKey(StorageTopic.Comment, 'draft', identifier); + }, [postId, editCommentId, parentCommentId]); + + const getInitialValue = useCallback( + (initialContent: string) => { + if (initialContent) { + return initialContent; + } + if (!draftStorageKey) { + return ''; + } + + return storageWrapper.getItem(draftStorageKey) || ''; + }, + [draftStorageKey], + ); + + const clearDraft = useCallback(() => { + if (draftStorageKey) { + storageWrapper.removeItem(draftStorageKey); + } + }, [draftStorageKey]); + + useEffect(() => { + if (!draftStorageKey || !isDirty) { + return undefined; + } + + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + saveTimeoutRef.current = setTimeout(() => { + if (content && content.trim().length > 0) { + storageWrapper.setItem(draftStorageKey, content); + } else { + storageWrapper.removeItem(draftStorageKey); + } + }, 500); + + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, [content, draftStorageKey, isDirty]); + + return { + draftStorageKey, + getInitialValue, + clearDraft, + }; +} diff --git a/packages/shared/src/components/fields/RichTextEditor/useEmojiAutocomplete.ts b/packages/shared/src/components/fields/RichTextEditor/useEmojiAutocomplete.ts new file mode 100644 index 0000000000..2cf6fc5be9 --- /dev/null +++ b/packages/shared/src/components/fields/RichTextEditor/useEmojiAutocomplete.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Editor } from '@tiptap/react'; +import { search as emojiSearch } from 'node-emoji'; +import { specialCharsRegex } from '../../../lib/strings'; + +type EditorRange = { from: number; to: number } | null; + +interface UseEmojiAutocompleteProps { + enabled: boolean; + onOffsetUpdate: (editor: Editor) => void; +} + +export function useEmojiAutocomplete({ + enabled, + onOffsetUpdate, +}: UseEmojiAutocompleteProps) { + const [emojiQuery, setEmojiQuery] = useState(undefined); + const [selectedEmoji, setSelectedEmoji] = useState(0); + const emojiRangeRef = useRef(null); + const emojiQueryRef = useRef(undefined); + const selectedEmojiRef = useRef(0); + const emojiDataRef = useRef>([]); + + const emojiData = useMemo( + () => + emojiQuery ? emojiSearch(emojiQuery.toLowerCase()).slice(0, 20) : [], + [emojiQuery], + ); + + useEffect(() => { + emojiDataRef.current = emojiData; + }, [emojiData]); + + useEffect(() => { + emojiQueryRef.current = emojiQuery; + }, [emojiQuery]); + + useEffect(() => { + selectedEmojiRef.current = selectedEmoji; + }, [selectedEmoji]); + + const updateFromEditor = useCallback( + (editor: Editor | null) => { + if (!editor || !enabled) { + return; + } + + if (!editor.state.selection.empty) { + setEmojiQuery(undefined); + emojiRangeRef.current = null; + return; + } + + const { $from } = editor.state.selection; + const parentText = $from.parent.textBetween( + 0, + $from.parent.content.size, + '\n', + '\n', + ); + const cursorOffset = $from.parentOffset; + const textBefore = parentText.slice(0, cursorOffset); + const wordMatch = /(?:^|\s)(\S+)$/.exec(textBefore); + const word = wordMatch?.[1] || ''; + + if (word.startsWith(':')) { + const emojiValue = word.slice(1); + if (!specialCharsRegex.test(emojiValue)) { + if (typeof emojiQueryRef.current === 'undefined') { + onOffsetUpdate(editor); + setSelectedEmoji(0); + } + + const from = editor.state.selection.from - word.length; + emojiRangeRef.current = { + from, + to: editor.state.selection.from, + }; + emojiQueryRef.current = emojiValue; + setEmojiQuery(emojiValue); + return; + } + } + + if (typeof emojiQueryRef.current !== 'undefined') { + emojiQueryRef.current = undefined; + setEmojiQuery(undefined); + emojiRangeRef.current = null; + } + }, + [enabled, onOffsetUpdate], + ); + + const applyEmoji = useCallback((editor: Editor, emoji: string) => { + if (!emojiRangeRef.current) { + return; + } + + editor + .chain() + .focus() + .insertContentAt(emojiRangeRef.current, `${emoji} `) + .run(); + + emojiQueryRef.current = undefined; + setEmojiQuery(undefined); + emojiRangeRef.current = null; + }, []); + + const clearEmoji = useCallback(() => { + emojiQueryRef.current = undefined; + setEmojiQuery(undefined); + emojiRangeRef.current = null; + }, []); + + return { + emojiQuery, + emojiData, + selectedEmoji, + setSelectedEmoji, + emojiRangeRef, + emojiQueryRef, + emojiDataRef, + selectedEmojiRef, + updateFromEditor, + applyEmoji, + clearEmoji, + }; +} diff --git a/packages/shared/src/components/fields/RichTextEditor/useImageUpload.ts b/packages/shared/src/components/fields/RichTextEditor/useImageUpload.ts new file mode 100644 index 0000000000..64a15e2fb4 --- /dev/null +++ b/packages/shared/src/components/fields/RichTextEditor/useImageUpload.ts @@ -0,0 +1,125 @@ +import type { FormEventHandler } from 'react'; +import { useCallback, useRef } from 'react'; +import type { Editor } from '@tiptap/react'; +import { + allowedContentImage, + allowedFileSize, + uploadNotAcceptedMessage, +} from '../../../graphql/posts'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import { + UploadState, + useSyncUploader, +} from '../../../hooks/input/useSyncUploader'; + +interface UseImageUploadProps { + enabled: boolean; + editorRef: React.MutableRefObject; +} + +export function useImageUpload({ enabled, editorRef }: UseImageUploadProps) { + const { displayToast } = useToastNotification(); + const uploadRef = useRef(null); + + const insertImage = useCallback( + (url: string, altText: string) => { + const editor = editorRef.current; + if (!editor) { + return; + } + + editor.chain().focus().setImage({ src: url, alt: altText }).run(); + }, + [editorRef], + ); + + const { uploadedCount, queueCount, pushUpload, startUploading } = + useSyncUploader({ + onStarted: () => {}, + onFinish: (status, file, url) => { + if (status === UploadState.Failed || !url) { + displayToast(uploadNotAcceptedMessage); + return; + } + + insertImage(url, file.name); + }, + }); + + const verifyFile = useCallback( + (file: File) => { + const isValidType = allowedContentImage.includes(file.type); + + if (file.size > allowedFileSize || !isValidType) { + displayToast(uploadNotAcceptedMessage); + return; + } + + pushUpload(file); + }, + [displayToast, pushUpload], + ); + + const onUpload: FormEventHandler = useCallback( + (event) => { + if (!enabled) { + return; + } + + const { files } = event.currentTarget as HTMLInputElement; + if (!files?.length) { + return; + } + + Array.from(files).forEach(verifyFile); + startUploading(); + }, + [enabled, verifyFile, startUploading], + ); + + const handleDrop = useCallback( + async (event: React.DragEvent) => { + if (!enabled) { + return; + } + + event.preventDefault(); + const { files } = event.dataTransfer; + if (!files?.length) { + return; + } + + Array.from(files).forEach(verifyFile); + startUploading(); + }, + [enabled, verifyFile, startUploading], + ); + + const handlePaste = useCallback( + (event: React.ClipboardEvent) => { + if (!enabled) { + return; + } + + const { files } = event.clipboardData; + if (!files?.length) { + return; + } + + event.preventDefault(); + Array.from(files).forEach(verifyFile); + startUploading(); + }, + [enabled, verifyFile, startUploading], + ); + + return { + uploadRef, + uploadedCount, + queueCount, + onUpload, + handleDrop, + handlePaste, + insertImage, + }; +} diff --git a/packages/shared/src/components/fields/RichTextEditor/useMentionAutocomplete.ts b/packages/shared/src/components/fields/RichTextEditor/useMentionAutocomplete.ts new file mode 100644 index 0000000000..cebdddca82 --- /dev/null +++ b/packages/shared/src/components/fields/RichTextEditor/useMentionAutocomplete.ts @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { Editor } from '@tiptap/react'; +import type { UserShortProfile } from '../../../lib/user'; +import type { RecommendedMentionsData } from '../../../graphql/comments'; +import { RECOMMEND_MENTIONS_QUERY } from '../../../graphql/comments'; +import { handleRegex } from '../../../graphql/users'; +import { isValidHttpUrl } from '../../../lib'; +import { useRequestProtocol } from '../../../hooks/useRequestProtocol'; + +type EditorRange = { from: number; to: number } | null; + +interface UseMentionAutocompleteProps { + enabled: boolean; + postId?: string; + sourceId?: string; + userId?: string; + onOffsetUpdate: (editor: Editor) => void; +} + +export function useMentionAutocomplete({ + enabled, + postId, + sourceId, + userId, + onOffsetUpdate, +}: UseMentionAutocompleteProps) { + const { requestMethod } = useRequestProtocol(); + const [query, setQuery] = useState(undefined); + const [selected, setSelected] = useState(0); + const mentionRangeRef = useRef(null); + const queryRef = useRef(undefined); + const selectedRef = useRef(0); + const mentionsRef = useRef([]); + + const key = ['user', query, postId, sourceId]; + const { data = { recommendedMentions: [] } } = + useQuery({ + queryKey: key, + queryFn: () => + requestMethod( + RECOMMEND_MENTIONS_QUERY, + { postId, query, sourceId }, + { requestKey: JSON.stringify(key) }, + ), + enabled: !!userId && typeof query !== 'undefined', + refetchOnWindowFocus: false, + refetchOnMount: false, + }); + + const mentions = data?.recommendedMentions; + + useEffect(() => { + queryRef.current = query; + }, [query]); + + useEffect(() => { + mentionsRef.current = mentions || []; + }, [mentions]); + + useEffect(() => { + selectedRef.current = selected; + }, [selected]); + + const updateFromEditor = useCallback( + (editor: Editor | null) => { + if (!editor || !enabled) { + return; + } + + if (!editor.state.selection.empty) { + setQuery(undefined); + mentionRangeRef.current = null; + return; + } + + const { $from } = editor.state.selection; + const parentText = $from.parent.textBetween( + 0, + $from.parent.content.size, + '\n', + '\n', + ); + const cursorOffset = $from.parentOffset; + const textBefore = parentText.slice(0, cursorOffset); + const wordMatch = /(?:^|\s)(\S+)$/.exec(textBefore); + const word = wordMatch?.[1] || ''; + + if (word.startsWith('@')) { + const mention = word.slice(1); + const isValid = mention.length === 0 || handleRegex.test(mention); + const looksLikeUrl = + isValidHttpUrl(word) || + word.startsWith('www.') || + /^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+/i.test(word); + + if (isValid && !looksLikeUrl && !editor.isActive('link')) { + if (typeof queryRef.current === 'undefined') { + onOffsetUpdate(editor); + setSelected(0); + } + + const from = editor.state.selection.from - word.length; + mentionRangeRef.current = { + from, + to: editor.state.selection.from, + }; + queryRef.current = mention; + setQuery(mention); + } else if (typeof queryRef.current !== 'undefined') { + queryRef.current = undefined; + setQuery(undefined); + mentionRangeRef.current = null; + } + } else if (typeof queryRef.current !== 'undefined') { + queryRef.current = undefined; + setQuery(undefined); + mentionRangeRef.current = null; + } + }, + [enabled, onOffsetUpdate], + ); + + const applyMention = useCallback( + (editor: Editor, mention: UserShortProfile) => { + if (!mentionRangeRef.current) { + return; + } + + editor + .chain() + .focus() + .insertContentAt(mentionRangeRef.current, `@${mention.username} `) + .run(); + + queryRef.current = undefined; + setQuery(undefined); + mentionRangeRef.current = null; + }, + [], + ); + + const clearMention = useCallback(() => { + queryRef.current = undefined; + setQuery(undefined); + mentionRangeRef.current = null; + }, []); + + return { + query, + mentions, + selected, + setSelected, + mentionRangeRef, + queryRef, + mentionsRef, + selectedRef, + updateFromEditor, + applyMention, + clearMention, + }; +} diff --git a/packages/shared/src/components/fields/RichTextInput.tsx b/packages/shared/src/components/fields/RichTextInput.tsx new file mode 100644 index 0000000000..b70b13990b --- /dev/null +++ b/packages/shared/src/components/fields/RichTextInput.tsx @@ -0,0 +1,788 @@ +import type { + FormEventHandler, + MutableRefObject, + ReactElement, + ReactNode, + TextareaHTMLAttributes, +} from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import classNames from 'classnames'; +import dynamic from 'next/dynamic'; +import type { Editor } from '@tiptap/react'; +import { useEditor, EditorContent } from '@tiptap/react'; +import { Extension } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import Link from '@tiptap/extension-link'; +import Placeholder from '@tiptap/extension-placeholder'; +import CharacterCount from '@tiptap/extension-character-count'; +import Image from '@tiptap/extension-image'; +import { ImageIcon, AtIcon } from '../icons'; +import { GifIcon } from '../icons/Gif'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; +import { RecommendedMentionTooltip } from '../tooltips/RecommendedMentionTooltip'; +import { SavingLabel } from './MarkdownInput/SavingLabel'; +import { useAuthContext } from '../../contexts/AuthContext'; +import { Loader } from '../Loader'; +import { Divider } from '../utilities'; +import { usePopupSelector } from '../../hooks/usePopupSelector'; +import ConditionalWrapper from '../ConditionalWrapper'; +import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; +import CloseButton from '../CloseButton'; +import GifPopover from '../popover/GifPopover'; +import { allowedContentImage } from '../../graphql/posts'; +import { + htmlToMarkdownBasic, + markdownToHtmlBasic, +} from '../../lib/markdownConversion'; +import { MarkdownCommand } from '../../hooks/input/useMarkdownInput'; +import type { RichTextToolbarRef } from './RichTextEditor/RichTextToolbar'; +import { RichTextToolbar } from './RichTextEditor/RichTextToolbar'; +import { MarkdownInputRules } from './RichTextEditor/markdownInputRules'; +import { useMentionAutocomplete } from './RichTextEditor/useMentionAutocomplete'; +import { useEmojiAutocomplete } from './RichTextEditor/useEmojiAutocomplete'; +import { useImageUpload } from './RichTextEditor/useImageUpload'; +import { useDraftStorage } from './RichTextEditor/useDraftStorage'; +import styles from './RichTextEditor/richtext.module.css'; + +const markdownPasteRegex = + /(^|\n)\s{0,3}(#{1,6}\s|[-*]\s|\d+\.\s|```)|\[[^\]]+\]\([^)]+\)|!\[[^\]]*]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*/; + +const isLikelyMarkdown = (value: string): boolean => + markdownPasteRegex.test(value); + +const RecommendedEmojiTooltip = dynamic( + () => + import( + /* webpackChunkName: "lazyRecommendedEmojiTooltip" */ '../tooltips/RecommendedEmojiTooltip' + ), + { ssr: false }, +); + +interface ClassName { + container?: string; + input?: string; + profile?: string; +} + +interface RichTextInputProps { + className?: ClassName; + footer?: ReactNode; + textareaProps?: Omit< + TextareaHTMLAttributes, + 'className' + >; + submitCopy?: string; + showUserAvatar?: boolean; + isUpdatingDraft?: boolean; + timeline?: ReactNode; + isLoading?: boolean; + disabledSubmit?: boolean; + maxInputLength?: number; + onClose?: () => void; + postId?: string; + sourceId?: string; + onSubmit?: FormEventHandler; + onValueUpdate?: (value: string) => void; + initialContent?: string; + enabledCommand?: Partial>; + editCommentId?: string; + parentCommentId?: string; +} + +export interface RichTextInputRef { + onMentionCommand?: () => void; + clearDraft: () => void; + setInput: (value: string) => void; + focus: () => void; +} + +function RichTextInput( + { + className = {}, + footer, + textareaProps = {}, + submitCopy, + showUserAvatar, + isUpdatingDraft, + timeline, + isLoading, + disabledSubmit, + maxInputLength, + onClose, + postId, + sourceId, + onSubmit, + onValueUpdate, + initialContent = '', + enabledCommand = {}, + editCommentId, + parentCommentId, + }: RichTextInputProps, + ref: MutableRefObject, +): ReactElement { + const shouldShowSubmit = !!submitCopy; + const { user } = useAuthContext(); + const { parentSelector } = usePopupSelector(); + const toolbarRef = useRef(null); + const editorContainerRef = useRef(null); + const editorRef = useRef(null); + const markdownTextareaRef = useRef(null); + const dirtyRef = useRef(false); + const isSyncingRef = useRef(false); + const inputRef = useRef(''); + const [offset, setOffset] = useState([0, 0]); + const [isMarkdownMode, setIsMarkdownMode] = useState(false); + + const isUploadEnabled = enabledCommand[MarkdownCommand.Upload]; + const isMentionEnabled = enabledCommand[MarkdownCommand.Mention]; + const isEmojiEnabled = enabledCommand[MarkdownCommand.Emoji]; + const isGifEnabled = enabledCommand[MarkdownCommand.Gif]; + const headerActionSize = ButtonSize.XSmall; + const maxLength = maxInputLength ?? textareaProps.maxLength; + + const { getInitialValue, clearDraft } = useDraftStorage({ + postId, + editCommentId, + parentCommentId, + content: inputRef.current, + isDirty: dirtyRef.current, + }); + + const [input, setInput] = useState(() => getInitialValue(initialContent)); + inputRef.current = input; + + const updateInput = useCallback( + ( + value: string, + options: { notify?: boolean; markDirty?: boolean } = {}, + ) => { + const { notify = true, markDirty = true } = options; + if (markDirty && !dirtyRef.current) { + dirtyRef.current = true; + } + + setInput(value); + inputRef.current = value; + + if (notify) { + onValueUpdate?.(value); + } + }, + [onValueUpdate], + ); + + const updateOffset = useCallback((currentEditor: Editor | null) => { + if (!currentEditor?.view?.dom) { + return; + } + + const coords = currentEditor.view.coordsAtPos( + currentEditor.state.selection.from, + ); + const rect = + editorContainerRef.current?.getBoundingClientRect() || + currentEditor.view.dom.getBoundingClientRect(); + setOffset([coords.left - rect.left, coords.top - rect.top]); + }, []); + + const mention = useMentionAutocomplete({ + enabled: isMentionEnabled, + postId, + sourceId, + userId: user?.id, + onOffsetUpdate: updateOffset, + }); + + const emoji = useEmojiAutocomplete({ + enabled: isEmojiEnabled, + onOffsetUpdate: updateOffset, + }); + + const updateSuggestionsFromEditor = useCallback( + (currentEditor: Editor | null) => { + if (!currentEditor) { + return; + } + + if (!currentEditor.state.selection.empty) { + mention.clearMention(); + emoji.clearEmoji(); + return; + } + + mention.updateFromEditor(currentEditor); + emoji.updateFromEditor(currentEditor); + }, + [mention, emoji], + ); + + const LinkShortcut = useMemo( + () => + Extension.create({ + name: 'linkShortcut', + addKeyboardShortcuts() { + return { + 'Mod-k': () => { + toolbarRef.current?.openLinkModal(); + return true; + }, + }; + }, + }), + [], + ); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + blockquote: false, + horizontalRule: false, + }), + Link.configure({ + openOnClick: false, + HTMLAttributes: { + target: '_blank', + rel: 'noopener nofollow', + }, + }), + Placeholder.configure({ + placeholder: textareaProps.placeholder || 'Share your thoughts', + }), + Image, + MarkdownInputRules, + ...(maxLength ? [CharacterCount.configure({ limit: maxLength })] : []), + LinkShortcut, + ], + content: markdownToHtmlBasic(input), + onUpdate: ({ editor: updatedEditor }) => { + if (isSyncingRef.current) { + isSyncingRef.current = false; + return; + } + + const markdown = htmlToMarkdownBasic(updatedEditor.getHTML()); + updateInput(markdown); + updateSuggestionsFromEditor(updatedEditor); + }, + onSelectionUpdate: ({ editor: updatedEditor }) => { + updateSuggestionsFromEditor(updatedEditor); + }, + editorProps: { + handlePaste: (_view, event) => { + const hasFiles = (event.clipboardData?.files?.length ?? 0) > 0; + if (hasFiles) { + return false; + } + + const text = event.clipboardData?.getData('text/plain')?.trim(); + if (!text || !isLikelyMarkdown(text)) { + return false; + } + + const convertedHtml = markdownToHtmlBasic(text); + if (!convertedHtml) { + return false; + } + + event.preventDefault(); + editorRef.current?.chain().focus().insertContent(convertedHtml).run(); + return true; + }, + handleKeyDown: (_view, event) => { + const isSpecialKey = event.ctrlKey || event.metaKey; + const hasMentions = + typeof mention.queryRef.current !== 'undefined' && + (mention.mentionsRef.current?.length ?? 0) > 0; + const hasEmojis = + typeof emoji.emojiQueryRef.current !== 'undefined' && + (emoji.emojiDataRef.current?.length ?? 0) > 0; + + if (isSpecialKey && event.key === 'Enter' && inputRef.current?.length) { + event.preventDefault(); + onSubmit?.({ + currentTarget: { value: inputRef.current }, + } as React.FormEvent); + return true; + } + + if (!hasMentions && !hasEmojis) { + return false; + } + + const isArrowUp = event.key === 'ArrowUp'; + const isArrowDown = event.key === 'ArrowDown'; + const isEnter = event.key === 'Enter'; + + if (!isArrowUp && !isArrowDown && !isEnter) { + return false; + } + + event.preventDefault(); + + if (isArrowUp || isArrowDown) { + if (hasMentions) { + mention.setSelected((prev) => { + const total = mention.mentionsRef.current?.length ?? 1; + if (isArrowUp) { + return (prev - 1 + total) % total; + } + return (prev + 1) % total; + }); + } else if (hasEmojis) { + emoji.setSelectedEmoji((prev) => { + const total = emoji.emojiDataRef.current?.length || 1; + if (isArrowUp) { + return (prev - 1 + total) % total; + } + return (prev + 1) % total; + }); + } + + return true; + } + + if (isEnter) { + if (hasMentions && editorRef.current) { + const selectedMention = + mention.mentionsRef.current?.[mention.selectedRef.current]; + if (selectedMention) { + mention.applyMention(editorRef.current, selectedMention); + } + } else if (hasEmojis && editorRef.current) { + const selectedEmoji = + emoji.emojiDataRef.current?.[emoji.selectedEmojiRef.current]; + if (selectedEmoji) { + emoji.applyEmoji(editorRef.current, selectedEmoji.emoji); + } + } + } + + return true; + }, + }, + immediatelyRender: false, + }); + + useEffect(() => { + editorRef.current = editor; + }, [editor]); + + const upload = useImageUpload({ + enabled: isUploadEnabled, + editorRef, + }); + + const onGifCommand = async (gifUrl: string, altText: string) => { + upload.insertImage(gifUrl, altText); + }; + + const switchToMarkdownMode = useCallback(() => { + if (editorRef.current) { + const markdown = htmlToMarkdownBasic(editorRef.current.getHTML()); + updateInput(markdown); + } + setIsMarkdownMode(true); + }, [updateInput]); + + const switchToRichMode = useCallback(() => { + if (editorRef.current) { + isSyncingRef.current = true; + editorRef.current.commands.setContent( + markdownToHtmlBasic(inputRef.current), + ); + } + setIsMarkdownMode(false); + }, []); + + const onMarkdownInput = useCallback( + (event: React.FormEvent) => { + const { value } = event.currentTarget; + updateInput(value); + }, + [updateInput], + ); + + const onMarkdownKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const isSpecialKey = event.ctrlKey || event.metaKey; + if (!isSpecialKey || event.key !== 'Enter' || !inputRef.current?.length) { + return; + } + + event.preventDefault(); + onSubmit?.({ + currentTarget: { value: inputRef.current }, + } as React.FormEvent); + }, + [onSubmit], + ); + + useImperativeHandle(ref, () => ({ + onMentionCommand: () => { + if (!editor) { + return; + } + editor.chain().focus().insertContent('@').run(); + updateSuggestionsFromEditor(editor); + }, + clearDraft, + setInput: (value: string) => { + updateInput(value, { notify: true, markDirty: true }); + if (!editor) { + return; + } + isSyncingRef.current = true; + editor.commands.setContent(markdownToHtmlBasic(value)); + }, + focus: () => { + if (isMarkdownMode) { + markdownTextareaRef.current?.focus(); + return; + } + + editor?.commands.focus(); + }, + })); + + useEffect(() => { + const content = inputRef.current; + if (!content || !editor) { + return; + } + + editor.commands.focus('end'); + }, [editor]); + + useEffect(() => { + if (dirtyRef.current) { + return; + } + + if (input?.length === 0 && initialContent?.length > 0) { + updateInput(initialContent, { notify: false, markDirty: false }); + + if (editor) { + isSyncingRef.current = true; + editor.commands.setContent(markdownToHtmlBasic(initialContent)); + } + } + }, [editor, initialContent, input, updateInput]); + + const actionIcon = + upload.queueCount === 0 ? ( + + ) : ( + + ); + + const remainingCharacters = + maxLength && (isMarkdownMode || editor?.storage.characterCount) + ? maxLength - + (isMarkdownMode + ? input.length + : editor?.storage.characterCount.characters() ?? input.length) + : null; + + const hasToolbarActions = isUploadEnabled || isMentionEnabled || isGifEnabled; + const hasUploadHint = isUploadEnabled; + const toolbarActions = ( + <> + {isUploadEnabled && ( + + {onClose && ( + + )} +
+
+