From 07ce80b5fc7731e12d4b92fbd40188893f48f854 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 5 Feb 2026 15:38:35 +0200 Subject: [PATCH 1/4] feat(shared): replace MarkdownInput with RichTextInput Migrate from textarea-based MarkdownInput to TipTap-powered RichTextInput for a WYSIWYG editing experience while maintaining markdown compatibility. Changes: - Add RichTextInput component with TipTap editor - Add markdown<->HTML conversion utilities - Enable headings, code blocks, and images in rich text editor - Fix invalid typo-title4 CSS class (design system only has title1-3) - Simplify LinkModal form handling - Update comment, post, and freeform content editors to use RichTextInput Co-Authored-By: Claude Opus 4.5 --- packages/shared/package.json | 1 + .../MarkdownInput/CommentMarkdownInput.tsx | 26 +- .../fields/RichTextEditor/LinkModal.tsx | 3 +- .../fields/RichTextEditor/RichTextToolbar.tsx | 15 +- .../fields/RichTextEditor/index.tsx | 5 +- .../RichTextEditor/markdownInputRules.ts | 57 ++ .../fields/RichTextEditor/richtext.module.css | 40 + .../src/components/fields/RichTextInput.tsx | 938 ++++++++++++++++++ .../modals/post/CreateSharedPostModal.tsx | 13 +- .../freeform/write/WriteFreeformContent.tsx | 4 +- .../src/components/post/write/ShareLink.tsx | 5 +- packages/shared/src/lib/markdownConversion.ts | 246 +++++ pnpm-lock.yaml | 16 + 13 files changed, 1338 insertions(+), 31 deletions(-) create mode 100644 packages/shared/src/components/fields/RichTextEditor/markdownInputRules.ts create mode 100644 packages/shared/src/components/fields/RichTextInput.tsx create mode 100644 packages/shared/src/lib/markdownConversion.ts 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..d1d742519f 100644 --- a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx +++ b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx @@ -1,4 +1,4 @@ -import type { ReactElement, Ref } from 'react'; +import type { ReactElement, ReactNode, Ref } from 'react'; import React, { useState, useCallback, @@ -23,6 +23,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 +65,7 @@ const ToolbarButton = ({ }; function RichTextToolbarComponent( - { editor, onLinkAdd }: RichTextToolbarProps, + { editor, onLinkAdd, inlineActions, rightActions }: RichTextToolbarProps, ref: Ref, ): ReactElement { const [isLinkModalOpen, setIsLinkModalOpen] = useState(false); @@ -152,6 +154,12 @@ function RichTextToolbarComponent( isActive={editor.isActive('link')} onClick={openLinkModal} /> + {inlineActions && ( + <> +
+
{inlineActions}
+ + )} {(editor.can().undo() || editor.can().redo()) && (
)} @@ -169,6 +177,9 @@ function RichTextToolbarComponent( onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} /> + {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/RichTextInput.tsx b/packages/shared/src/components/fields/RichTextInput.tsx new file mode 100644 index 0000000000..8786ca0575 --- /dev/null +++ b/packages/shared/src/components/fields/RichTextInput.tsx @@ -0,0 +1,938 @@ +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 { useQuery } from '@tanstack/react-query'; +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 { search as emojiSearch } from 'node-emoji'; +import { ImageIcon, AtIcon } from '../icons'; +import { GifIcon } from '../icons/Gif'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '../buttons/Button'; +import { RecommendedMentionTooltip } from '../tooltips/RecommendedMentionTooltip'; +import type { UserShortProfile } from '../../lib/user'; +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 { useRequestProtocol } from '../../hooks/useRequestProtocol'; +import type { RecommendedMentionsData } from '../../graphql/comments'; +import { RECOMMEND_MENTIONS_QUERY } from '../../graphql/comments'; +import { handleRegex } from '../../graphql/users'; +import { isValidHttpUrl } from '../../lib'; +import { + UploadState, + useSyncUploader, +} from '../../hooks/input/useSyncUploader'; +import { + allowedContentImage, + allowedFileSize, + uploadNotAcceptedMessage, +} from '../../graphql/posts'; +import { useToastNotification } from '../../hooks/useToastNotification'; +import { generateStorageKey, StorageTopic } from '../../lib/storage'; +import { storageWrapper } from '../../lib/storageWrapper'; +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 styles from './RichTextEditor/richtext.module.css'; + +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; +} + +type EditorRange = { from: number; to: number } | null; + +export interface RichTextInputRef { + onMentionCommand?: () => void; + clearDraft: () => void; + setInput: (value: string) => void; + focus: () => void; +} + +const specialCharsRegex = new RegExp(/[^A-Za-z0-9_.]/); + +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 { displayToast } = useToastNotification(); + const { requestMethod } = useRequestProtocol(); + const toolbarRef = useRef(null); + const editorContainerRef = useRef(null); + const editorRef = useRef(null); + const uploadRef = useRef(null); + const dirtyRef = useRef(false); + const isSyncingRef = useRef(false); + const inputRef = useRef(''); + const saveTimeoutRef = useRef(); + const mentionRangeRef = useRef(null); + const emojiRangeRef = useRef(null); + const mentionsRef = useRef([]); + const emojiDataRef = useRef>([]); + const emojiQueryRef = useRef(undefined); + const queryRef = useRef(undefined); + const selectedRef = useRef(0); + const selectedEmojiRef = useRef(0); + 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 draftStorageKey = useMemo(() => { + if (!postId) { + return null; + } + const identifier = editCommentId || parentCommentId || postId; + return generateStorageKey(StorageTopic.Comment, 'draft', identifier); + }, [postId, editCommentId, parentCommentId]); + + const getInitialValue = useCallback(() => { + if (initialContent) { + return initialContent; + } + if (!draftStorageKey) { + return ''; + } + + return storageWrapper.getItem(draftStorageKey) || ''; + }, [initialContent, draftStorageKey]); + + const [input, setInput] = useState(getInitialValue); + const [query, setQuery] = useState(undefined); + const [emojiQuery, setEmojiQuery] = useState(undefined); + const [offset, setOffset] = useState([0, 0]); + const [selected, setSelected] = useState(0); + const [selectedEmoji, setSelectedEmoji] = useState(0); + + inputRef.current = input; + + const maxLength = maxInputLength ?? textareaProps.maxLength; + + const LinkShortcut = useMemo( + () => + Extension.create({ + name: 'linkShortcut', + addKeyboardShortcuts() { + return { + 'Mod-k': () => { + toolbarRef.current?.openLinkModal(); + return true; + }, + }; + }, + }), + [], + ); + + 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 emojiData = useMemo( + () => + emojiQuery ? emojiSearch(emojiQuery.toLowerCase()).slice(0, 20) : [], + [emojiQuery], + ); + + 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: !!user && typeof query !== 'undefined', + refetchOnWindowFocus: false, + refetchOnMount: false, + }); + + const mentions = data?.recommendedMentions; + + useEffect(() => { + queryRef.current = query; + }, [query]); + + useEffect(() => { + mentionsRef.current = mentions || []; + }, [mentions]); + + useEffect(() => { + emojiDataRef.current = emojiData; + }, [emojiData]); + + useEffect(() => { + emojiQueryRef.current = emojiQuery; + }, [emojiQuery]); + + useEffect(() => { + selectedRef.current = selected; + }, [selected]); + + useEffect(() => { + selectedEmojiRef.current = selectedEmoji; + }, [selectedEmoji]); + + 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 updateSuggestionsFromEditor = useCallback( + (currentEditor: Editor | null) => { + if (!currentEditor) { + return; + } + + if (!currentEditor.state.selection.empty) { + setQuery(undefined); + setEmojiQuery(undefined); + mentionRangeRef.current = null; + emojiRangeRef.current = null; + return; + } + + const { $from } = currentEditor.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 (isMentionEnabled && 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 && !currentEditor.isActive('link')) { + if (typeof queryRef.current === 'undefined') { + updateOffset(currentEditor); + setSelected(0); + } + + const from = currentEditor.state.selection.from - word.length; + mentionRangeRef.current = { + from, + to: currentEditor.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; + } + + if (isEmojiEnabled && word.startsWith(':')) { + const emojiValue = word.slice(1); + if (!specialCharsRegex.test(emojiValue)) { + if (typeof emojiQueryRef.current === 'undefined') { + updateOffset(currentEditor); + setSelectedEmoji(0); + } + + const from = currentEditor.state.selection.from - word.length; + emojiRangeRef.current = { + from, + to: currentEditor.state.selection.from, + }; + emojiQueryRef.current = emojiValue; + setEmojiQuery(emojiValue); + return; + } + } + + if (typeof emojiQueryRef.current !== 'undefined') { + emojiQueryRef.current = undefined; + setEmojiQuery(undefined); + emojiRangeRef.current = null; + } + }, + [isEmojiEnabled, isMentionEnabled, updateOffset], + ); + + const onApplyMention = useCallback((mention: UserShortProfile) => { + const currentEditor = editorRef.current; + if (!currentEditor || !mentionRangeRef.current) { + return; + } + + currentEditor + .chain() + .focus() + .insertContentAt(mentionRangeRef.current, `@${mention.username} `) + .run(); + + queryRef.current = undefined; + setQuery(undefined); + mentionRangeRef.current = null; + }, []); + + const onApplyEmoji = useCallback((emoji: string) => { + const currentEditor = editorRef.current; + if (!currentEditor || !emojiRangeRef.current) { + return; + } + + currentEditor + .chain() + .focus() + .insertContentAt(emojiRangeRef.current, `${emoji} `) + .run(); + + emojiQueryRef.current = undefined; + setEmojiQuery(undefined); + emojiRangeRef.current = null; + }, []); + + 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: { + handleKeyDown: (_view, event) => { + const isSpecialKey = event.ctrlKey || event.metaKey; + const hasMentions = + typeof queryRef.current !== 'undefined' && + (mentionsRef.current?.length ?? 0) > 0; + const hasEmojis = + typeof emojiQueryRef.current !== 'undefined' && + (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) { + setSelected((prev) => { + const total = mentionsRef.current?.length ?? 1; + if (isArrowUp) { + return (prev - 1 + total) % total; + } + return (prev + 1) % total; + }); + } else if (hasEmojis) { + setSelectedEmoji((prev) => { + const total = emojiDataRef.current?.length || 1; + if (isArrowUp) { + return (prev - 1 + total) % total; + } + return (prev + 1) % total; + }); + } + + return true; + } + + if (isEnter) { + if (hasMentions) { + const mention = mentionsRef.current?.[selectedRef.current]; + if (mention) { + onApplyMention(mention); + } + } else if (hasEmojis) { + const emoji = emojiDataRef.current?.[selectedEmojiRef.current]; + if (emoji) { + onApplyEmoji(emoji.emoji); + } + } + } + + return true; + }, + }, + immediatelyRender: false, + }); + + useEffect(() => { + editorRef.current = editor; + }, [editor]); + + useImperativeHandle(ref, () => ({ + onMentionCommand: () => { + if (!editor) { + return; + } + editor.chain().focus().insertContent('@').run(); + updateSuggestionsFromEditor(editor); + }, + clearDraft: () => { + if (draftStorageKey) { + storageWrapper.removeItem(draftStorageKey); + } + }, + setInput: (value: string) => { + updateInput(value, { notify: true, markDirty: true }); + if (!editor) { + return; + } + isSyncingRef.current = true; + editor.commands.setContent(markdownToHtmlBasic(value)); + }, + focus: () => { + 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]); + + useEffect(() => { + if (!draftStorageKey || !dirtyRef.current) { + return undefined; + } + + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + saveTimeoutRef.current = setTimeout(() => { + if (input && input.trim().length > 0) { + storageWrapper.setItem(draftStorageKey, input); + } else { + storageWrapper.removeItem(draftStorageKey); + } + }, 500); + + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + }; + }, [input, draftStorageKey]); + + const insertImage = useCallback((url: string, altText: string) => { + const currentEditor = editorRef.current; + if (!currentEditor) { + return; + } + + currentEditor.chain().focus().setImage({ src: url, alt: altText }).run(); + }, []); + + 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 = (event) => { + if (!isUploadEnabled) { + return; + } + + const files = (event.currentTarget as HTMLInputElement).files; + if (!files?.length) { + return; + } + + Array.from(files).forEach(verifyFile); + startUploading(); + }; + + const onGifCommand = async (gifUrl: string, altText: string) => { + insertImage(gifUrl, altText); + }; + + const handleDrop = async (event: React.DragEvent) => { + if (!isUploadEnabled) { + return; + } + + event.preventDefault(); + const files = event.dataTransfer.files; + if (!files?.length) { + return; + } + + Array.from(files).forEach(verifyFile); + startUploading(); + }; + + const handlePaste = (event: React.ClipboardEvent) => { + if (!isUploadEnabled) { + return; + } + + const files = event.clipboardData.files; + if (!files?.length) { + return; + } + + event.preventDefault(); + Array.from(files).forEach(verifyFile); + startUploading(); + }; + + const actionIcon = + queueCount === 0 ? ( + + ) : ( + + ); + + const remainingCharacters = + maxLength && editor?.storage.characterCount + ? maxLength - editor.storage.characterCount.characters() + : null; + + const hasToolbarActions = isUploadEnabled || isMentionEnabled || isGifEnabled; + const hasUploadHint = isUploadEnabled; + const toolbarActions = ( + <> + {isUploadEnabled && ( + + )} + + )} +
+ ); +} + +export default forwardRef(RichTextInput); diff --git a/packages/shared/src/components/modals/post/CreateSharedPostModal.tsx b/packages/shared/src/components/modals/post/CreateSharedPostModal.tsx index 676f635534..7e5c66403a 100644 --- a/packages/shared/src/components/modals/post/CreateSharedPostModal.tsx +++ b/packages/shared/src/components/modals/post/CreateSharedPostModal.tsx @@ -3,8 +3,8 @@ import React, { useRef, useState } from 'react'; import type { ModalProps } from '../common/Modal'; import { Modal } from '../common/Modal'; import type { ExternalLinkPreview } from '../../../graphql/posts'; -import type { MarkdownRef } from '../../fields/MarkdownInput'; -import MarkdownInput from '../../fields/MarkdownInput'; +import type { RichTextInputRef } from '../../fields/RichTextInput'; +import RichTextInput from '../../fields/RichTextInput'; import { WriteLinkPreview, WritePreviewSkeleton } from '../../post/write'; import { usePostToSquad } from '../../../hooks'; import { @@ -36,7 +36,7 @@ export function CreateSharedPostModal({ onRequestClose, ...props }: CreateSharedPostModalProps): ReactElement { - const markdownRef = useRef(); + const richTextRef = useRef(); const [link, setLink] = useState(preview?.permalink ?? preview?.url ?? ''); const { shouldShowCta, isEnabled, onToggle, onSubmitted } = useNotificationToggle(); @@ -81,7 +81,7 @@ export function CreateSharedPostModal({ icon={} size={ButtonSize.Small} variant={ButtonVariant.Tertiary} - onClick={markdownRef?.current?.onMentionCommand} + onClick={richTextRef?.current?.onMentionCommand} /> @@ -119,12 +119,11 @@ export function CreateSharedPostModal({ onSubmit={onFormSubmit} id="share_post" > - - )} - diff --git a/packages/shared/src/lib/markdownConversion.ts b/packages/shared/src/lib/markdownConversion.ts new file mode 100644 index 0000000000..a850350a07 --- /dev/null +++ b/packages/shared/src/lib/markdownConversion.ts @@ -0,0 +1,246 @@ +const escapeHtml = (value: string): string => + value + .replace(/&/g, '&') + .replace(//g, '>'); + +const escapeAttribute = (value: string): string => + value.replace(/"/g, '"'); + +const normalizeText = (value: string): string => value.replace(/\u00a0/g, ' '); + +const inlineMarkdownToHtml = (value: string): string => { + let result = escapeHtml(value); + + result = result.replace( + /!\[([^\]]*)\]\(([^)]+)\)/g, + (_, alt: string, url: string) => + `${alt}`, + ); + + result = result.replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, label: string, url: string) => + `${label}`, + ); + + result = result.replace(/\*\*(.+?)\*\*/g, '$1'); + result = result.replace(/\*(?!\*)([^*]+)\*(?!\*)/g, '$1'); + result = result.replace(/_(.+?)_/g, '$1'); + result = result.replace(/`([^`]+)`/g, '$1'); + + return result; +}; + +export const markdownToHtmlBasic = (markdown: string): string => { + if (!markdown) { + return ''; + } + + const lines = markdown.split(/\r?\n/); + const htmlParts: string[] = []; + let listType: 'ul' | 'ol' | null = null; + let listItems: string[] = []; + let isInCodeBlock = false; + let codeBlockLines: string[] = []; + + const flushList = () => { + if (!listType || listItems.length === 0) { + listType = null; + listItems = []; + return; + } + + htmlParts.push(`<${listType}>${listItems.join('')}`); + listType = null; + listItems = []; + }; + + lines.forEach((line) => { + const trimmed = line.trim(); + + if (trimmed.startsWith('```')) { + if (isInCodeBlock) { + const codeContent = escapeHtml(codeBlockLines.join('\n')); + htmlParts.push(`
${codeContent}
`); + codeBlockLines = []; + isInCodeBlock = false; + } else { + flushList(); + isInCodeBlock = true; + } + return; + } + + if (isInCodeBlock) { + codeBlockLines.push(line); + return; + } + + const unorderedMatch = /^[-*]\s+(.+)$/.exec(trimmed); + const orderedMatch = /^(\d+)\.\s+(.+)$/.exec(trimmed); + const headingMatch = /^(#{1,6})\s+(.+)$/.exec(trimmed); + + if (unorderedMatch) { + if (listType !== 'ul') { + flushList(); + listType = 'ul'; + } + listItems.push(`
  • ${inlineMarkdownToHtml(unorderedMatch[1])}
  • `); + return; + } + + if (orderedMatch) { + if (listType !== 'ol') { + flushList(); + listType = 'ol'; + } + listItems.push(`
  • ${inlineMarkdownToHtml(orderedMatch[2])}
  • `); + return; + } + + if (headingMatch) { + flushList(); + const level = headingMatch[1].length; + htmlParts.push( + `${inlineMarkdownToHtml(headingMatch[2])}`, + ); + return; + } + + if (!trimmed) { + flushList(); + return; + } + + flushList(); + htmlParts.push(`

    ${inlineMarkdownToHtml(line)}

    `); + }); + + if (isInCodeBlock) { + const codeContent = escapeHtml(codeBlockLines.join('\n')); + htmlParts.push(`
    ${codeContent}
    `); + codeBlockLines = []; + isInCodeBlock = false; + } + + flushList(); + + return htmlParts.join(''); +}; + +const serializeInline = (node: Node): string => { + if (node.nodeType === Node.TEXT_NODE) { + return normalizeText(node.textContent || ''); + } + + if (!(node instanceof Element)) { + return ''; + } + + const tagName = node.tagName.toLowerCase(); + + switch (tagName) { + case 'strong': + case 'b': + return `**${serializeChildren(node)}**`; + case 'em': + case 'i': + return `_${serializeChildren(node)}_`; + case 'a': { + const href = node.getAttribute('href') || ''; + const label = serializeChildren(node) || href; + return `[${label}](${href})`; + } + case 'code': + return `\`${serializeChildren(node)}\``; + case 'br': + return '\n'; + case 'img': { + const src = node.getAttribute('src') || ''; + const alt = node.getAttribute('alt') || ''; + return `![${alt}](${src})`; + } + case 'p': + return serializeChildren(node).trim(); + case 'li': + return serializeChildren(node).trim(); + default: + return serializeChildren(node); + } +}; + +const serializeChildren = (node: Element): string => + Array.from(node.childNodes) + .map((child) => serializeInline(child)) + .join(''); + +const serializeList = (node: Element, ordered: boolean): string => { + const items = Array.from(node.children).filter( + (child) => child.tagName.toLowerCase() === 'li', + ); + + return items + .map((item, index) => { + const prefix = ordered ? `${index + 1}.` : '-'; + const content = serializeInline(item).trim(); + return `${prefix} ${content}`.trim(); + }) + .join('\n'); +}; + +export const htmlToMarkdownBasic = (html: string): string => { + if (!html) { + return ''; + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + const blocks = Array.from(doc.body.childNodes) + .map((node) => { + if (node.nodeType === Node.TEXT_NODE) { + return normalizeText(node.textContent || '').trim(); + } + + if (!(node instanceof Element)) { + return ''; + } + + const tagName = node.tagName.toLowerCase(); + + switch (tagName) { + case 'p': + return serializeChildren(node).trim(); + case 'pre': { + const code = node.textContent ?? ''; + return `\`\`\`\n${normalizeText(code).trim()}\n\`\`\``; + } + case 'h1': + return `# ${serializeChildren(node).trim()}`; + case 'h2': + return `## ${serializeChildren(node).trim()}`; + case 'h3': + return `### ${serializeChildren(node).trim()}`; + case 'h4': + return `#### ${serializeChildren(node).trim()}`; + case 'h5': + return `##### ${serializeChildren(node).trim()}`; + case 'h6': + return `###### ${serializeChildren(node).trim()}`; + case 'ul': + return serializeList(node, false); + case 'ol': + return serializeList(node, true); + case 'img': + return serializeInline(node).trim(); + default: + return serializeChildren(node).trim(); + } + }) + .filter(Boolean); + + return blocks.join('\n\n').replace(/\n{3,}/g, '\n\n').trim(); +}; + +export default markdownToHtmlBasic; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18e2076602..2bfe11b945 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -453,6 +453,9 @@ importers: '@tiptap/extension-character-count': specifier: ^3.14.0 version: 3.14.0(@tiptap/extensions@3.14.0(@tiptap/core@3.15.0(@tiptap/pm@3.14.0))(@tiptap/pm@3.14.0)) + '@tiptap/extension-image': + specifier: ^3.14.0 + version: 3.19.0(@tiptap/core@3.15.0(@tiptap/pm@3.14.0)) '@tiptap/extension-link': specifier: ^3.14.0 version: 3.14.0(@tiptap/core@3.15.0(@tiptap/pm@3.14.0))(@tiptap/pm@3.14.0) @@ -3608,6 +3611,11 @@ packages: '@tiptap/core': ^3.14.0 '@tiptap/pm': ^3.14.0 + '@tiptap/extension-image@3.19.0': + resolution: {integrity: sha512-/rGl8nBziBPVJJ/9639eQWFDKcI3RQsDM3s+cqYQMFQfMqc7sQB9h4o4sHCBpmKxk3Y0FV/0NjnjLbBVm8OKdQ==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/extension-italic@3.14.0': resolution: {integrity: sha512-Arl5EaG4wdyipwvKjsI7Krlk3OkmqvLfF0YfGwsd5AVDxTiYuiDGgz7RF8J2kttbBeiUTqwME5xpkryQK3F+fg==} peerDependencies: @@ -12679,6 +12687,10 @@ snapshots: '@tiptap/core': 3.15.0(@tiptap/pm@3.14.0) '@tiptap/pm': 3.14.0 + '@tiptap/extension-image@3.19.0(@tiptap/core@3.15.0(@tiptap/pm@3.14.0))': + dependencies: + '@tiptap/core': 3.15.0(@tiptap/pm@3.14.0) + '@tiptap/extension-italic@3.14.0(@tiptap/core@3.15.0(@tiptap/pm@3.14.0))': dependencies: '@tiptap/core': 3.15.0(@tiptap/pm@3.14.0) @@ -16544,7 +16556,11 @@ snapshots: pretty-format: 26.6.2 throat: 5.0.0 transitivePeerDependencies: + - bufferutil + - canvas - supports-color + - ts-node + - utf-8-validate jest-junit@12.3.0: dependencies: From e98ee828d1f514b628231d1e002a05308f11b32b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:07:26 +0000 Subject: [PATCH 2/4] refactor(shared): extract RichTextInput logic into custom hooks - Extract mention autocomplete to useMentionAutocomplete hook - Extract emoji autocomplete to useEmojiAutocomplete hook - Extract image upload to useImageUpload hook - Extract draft storage to useDraftStorage hook - Reduce RichTextInput from 938 to 651 lines (31% reduction) - Fix ESLint errors in markdownConversion.ts for mutually recursive functions Co-authored-by: Chris Bongers --- .../fields/RichTextEditor/useDraftStorage.ts | 79 +++ .../RichTextEditor/useEmojiAutocomplete.ts | 130 +++++ .../fields/RichTextEditor/useImageUpload.ts | 125 +++++ .../RichTextEditor/useMentionAutocomplete.ts | 162 ++++++ .../src/components/fields/RichTextInput.tsx | 510 ++++-------------- packages/shared/src/lib/markdownConversion.ts | 29 +- 6 files changed, 624 insertions(+), 411 deletions(-) create mode 100644 packages/shared/src/components/fields/RichTextEditor/useDraftStorage.ts create mode 100644 packages/shared/src/components/fields/RichTextEditor/useEmojiAutocomplete.ts create mode 100644 packages/shared/src/components/fields/RichTextEditor/useImageUpload.ts create mode 100644 packages/shared/src/components/fields/RichTextEditor/useMentionAutocomplete.ts 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..ba60ca0c19 --- /dev/null +++ b/packages/shared/src/components/fields/RichTextEditor/useEmojiAutocomplete.ts @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { Editor } from '@tiptap/react'; +import { search as emojiSearch } from 'node-emoji'; + +type EditorRange = { from: number; to: number } | null; + +const specialCharsRegex = new RegExp(/[^A-Za-z0-9_.]/); + +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 index 8786ca0575..ebdbf600bc 100644 --- a/packages/shared/src/components/fields/RichTextInput.tsx +++ b/packages/shared/src/components/fields/RichTextInput.tsx @@ -16,7 +16,6 @@ import React, { } from 'react'; import classNames from 'classnames'; import dynamic from 'next/dynamic'; -import { useQuery } from '@tanstack/react-query'; import type { Editor } from '@tiptap/react'; import { useEditor, EditorContent } from '@tiptap/react'; import { Extension } from '@tiptap/core'; @@ -25,7 +24,6 @@ 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 { search as emojiSearch } from 'node-emoji'; import { ImageIcon, AtIcon } from '../icons'; import { GifIcon } from '../icons/Gif'; import { @@ -35,7 +33,6 @@ import { ButtonVariant, } from '../buttons/Button'; import { RecommendedMentionTooltip } from '../tooltips/RecommendedMentionTooltip'; -import type { UserShortProfile } from '../../lib/user'; import { SavingLabel } from './MarkdownInput/SavingLabel'; import { useAuthContext } from '../../contexts/AuthContext'; import { Loader } from '../Loader'; @@ -45,23 +42,7 @@ import ConditionalWrapper from '../ConditionalWrapper'; import { ProfileImageSize, ProfilePicture } from '../ProfilePicture'; import CloseButton from '../CloseButton'; import GifPopover from '../popover/GifPopover'; -import { useRequestProtocol } from '../../hooks/useRequestProtocol'; -import type { RecommendedMentionsData } from '../../graphql/comments'; -import { RECOMMEND_MENTIONS_QUERY } from '../../graphql/comments'; -import { handleRegex } from '../../graphql/users'; -import { isValidHttpUrl } from '../../lib'; -import { - UploadState, - useSyncUploader, -} from '../../hooks/input/useSyncUploader'; -import { - allowedContentImage, - allowedFileSize, - uploadNotAcceptedMessage, -} from '../../graphql/posts'; -import { useToastNotification } from '../../hooks/useToastNotification'; -import { generateStorageKey, StorageTopic } from '../../lib/storage'; -import { storageWrapper } from '../../lib/storageWrapper'; +import { allowedContentImage } from '../../graphql/posts'; import { htmlToMarkdownBasic, markdownToHtmlBasic, @@ -70,6 +51,10 @@ 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 RecommendedEmojiTooltip = dynamic( @@ -111,8 +96,6 @@ interface RichTextInputProps { parentCommentId?: string; } -type EditorRange = { from: number; to: number } | null; - export interface RichTextInputRef { onMentionCommand?: () => void; clearDraft: () => void; @@ -120,8 +103,6 @@ export interface RichTextInputRef { focus: () => void; } -const specialCharsRegex = new RegExp(/[^A-Za-z0-9_.]/); - function RichTextInput( { className = {}, @@ -149,77 +130,32 @@ function RichTextInput( const shouldShowSubmit = !!submitCopy; const { user } = useAuthContext(); const { parentSelector } = usePopupSelector(); - const { displayToast } = useToastNotification(); - const { requestMethod } = useRequestProtocol(); const toolbarRef = useRef(null); const editorContainerRef = useRef(null); const editorRef = useRef(null); - const uploadRef = useRef(null); const dirtyRef = useRef(false); const isSyncingRef = useRef(false); const inputRef = useRef(''); - const saveTimeoutRef = useRef(); - const mentionRangeRef = useRef(null); - const emojiRangeRef = useRef(null); - const mentionsRef = useRef([]); - const emojiDataRef = useRef>([]); - const emojiQueryRef = useRef(undefined); - const queryRef = useRef(undefined); - const selectedRef = useRef(0); - const selectedEmojiRef = useRef(0); + const [offset, setOffset] = useState([0, 0]); + 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 draftStorageKey = useMemo(() => { - if (!postId) { - return null; - } - const identifier = editCommentId || parentCommentId || postId; - return generateStorageKey(StorageTopic.Comment, 'draft', identifier); - }, [postId, editCommentId, parentCommentId]); - - const getInitialValue = useCallback(() => { - if (initialContent) { - return initialContent; - } - if (!draftStorageKey) { - return ''; - } - - return storageWrapper.getItem(draftStorageKey) || ''; - }, [initialContent, draftStorageKey]); - - const [input, setInput] = useState(getInitialValue); - const [query, setQuery] = useState(undefined); - const [emojiQuery, setEmojiQuery] = useState(undefined); - const [offset, setOffset] = useState([0, 0]); - const [selected, setSelected] = useState(0); - const [selectedEmoji, setSelectedEmoji] = useState(0); + const { getInitialValue, clearDraft } = useDraftStorage({ + postId, + editCommentId, + parentCommentId, + content: inputRef.current, + isDirty: dirtyRef.current, + }); + const [input, setInput] = useState(() => getInitialValue(initialContent)); inputRef.current = input; - const maxLength = maxInputLength ?? textareaProps.maxLength; - - const LinkShortcut = useMemo( - () => - Extension.create({ - name: 'linkShortcut', - addKeyboardShortcuts() { - return { - 'Mod-k': () => { - toolbarRef.current?.openLinkModal(); - return true; - }, - }; - }, - }), - [], - ); - const updateInput = useCallback( ( value: string, @@ -240,69 +176,32 @@ function RichTextInput( [onValueUpdate], ); - const emojiData = useMemo( - () => - emojiQuery ? emojiSearch(emojiQuery.toLowerCase()).slice(0, 20) : [], - [emojiQuery], - ); - - 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: !!user && typeof query !== 'undefined', - refetchOnWindowFocus: false, - refetchOnMount: false, - }); - - const mentions = data?.recommendedMentions; - - useEffect(() => { - queryRef.current = query; - }, [query]); - - useEffect(() => { - mentionsRef.current = mentions || []; - }, [mentions]); - - useEffect(() => { - emojiDataRef.current = emojiData; - }, [emojiData]); - - useEffect(() => { - emojiQueryRef.current = emojiQuery; - }, [emojiQuery]); - - useEffect(() => { - selectedRef.current = selected; - }, [selected]); + const updateOffset = useCallback((currentEditor: Editor | null) => { + if (!currentEditor?.view?.dom) { + return; + } - useEffect(() => { - selectedEmojiRef.current = selectedEmoji; - }, [selectedEmoji]); + 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 updateOffset = useCallback( - (currentEditor: Editor | null) => { - if (!currentEditor?.view?.dom) { - return; - } + const mention = useMentionAutocomplete({ + enabled: isMentionEnabled, + postId, + sourceId, + userId: user?.id, + onOffsetUpdate: updateOffset, + }); - 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 emoji = useEmojiAutocomplete({ + enabled: isEmojiEnabled, + onOffsetUpdate: updateOffset, + }); const updateSuggestionsFromEditor = useCallback( (currentEditor: Editor | null) => { @@ -311,118 +210,32 @@ function RichTextInput( } if (!currentEditor.state.selection.empty) { - setQuery(undefined); - setEmojiQuery(undefined); - mentionRangeRef.current = null; - emojiRangeRef.current = null; + mention.clearMention(); + emoji.clearEmoji(); return; } - const { $from } = currentEditor.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 (isMentionEnabled && 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 && !currentEditor.isActive('link')) { - if (typeof queryRef.current === 'undefined') { - updateOffset(currentEditor); - setSelected(0); - } - - const from = currentEditor.state.selection.from - word.length; - mentionRangeRef.current = { - from, - to: currentEditor.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; - } - - if (isEmojiEnabled && word.startsWith(':')) { - const emojiValue = word.slice(1); - if (!specialCharsRegex.test(emojiValue)) { - if (typeof emojiQueryRef.current === 'undefined') { - updateOffset(currentEditor); - setSelectedEmoji(0); - } - - const from = currentEditor.state.selection.from - word.length; - emojiRangeRef.current = { - from, - to: currentEditor.state.selection.from, - }; - emojiQueryRef.current = emojiValue; - setEmojiQuery(emojiValue); - return; - } - } - - if (typeof emojiQueryRef.current !== 'undefined') { - emojiQueryRef.current = undefined; - setEmojiQuery(undefined); - emojiRangeRef.current = null; - } + mention.updateFromEditor(currentEditor); + emoji.updateFromEditor(currentEditor); }, - [isEmojiEnabled, isMentionEnabled, updateOffset], + [mention, emoji], ); - const onApplyMention = useCallback((mention: UserShortProfile) => { - const currentEditor = editorRef.current; - if (!currentEditor || !mentionRangeRef.current) { - return; - } - - currentEditor - .chain() - .focus() - .insertContentAt(mentionRangeRef.current, `@${mention.username} `) - .run(); - - queryRef.current = undefined; - setQuery(undefined); - mentionRangeRef.current = null; - }, []); - - const onApplyEmoji = useCallback((emoji: string) => { - const currentEditor = editorRef.current; - if (!currentEditor || !emojiRangeRef.current) { - return; - } - - currentEditor - .chain() - .focus() - .insertContentAt(emojiRangeRef.current, `${emoji} `) - .run(); - - emojiQueryRef.current = undefined; - setEmojiQuery(undefined); - emojiRangeRef.current = null; - }, []); + const LinkShortcut = useMemo( + () => + Extension.create({ + name: 'linkShortcut', + addKeyboardShortcuts() { + return { + 'Mod-k': () => { + toolbarRef.current?.openLinkModal(); + return true; + }, + }; + }, + }), + [], + ); const editor = useEditor({ extensions: [ @@ -463,11 +276,11 @@ function RichTextInput( handleKeyDown: (_view, event) => { const isSpecialKey = event.ctrlKey || event.metaKey; const hasMentions = - typeof queryRef.current !== 'undefined' && - (mentionsRef.current?.length ?? 0) > 0; + typeof mention.queryRef.current !== 'undefined' && + (mention.mentionsRef.current?.length ?? 0) > 0; const hasEmojis = - typeof emojiQueryRef.current !== 'undefined' && - (emojiDataRef.current?.length ?? 0) > 0; + typeof emoji.emojiQueryRef.current !== 'undefined' && + (emoji.emojiDataRef.current?.length ?? 0) > 0; if (isSpecialKey && event.key === 'Enter' && inputRef.current?.length) { event.preventDefault(); @@ -493,16 +306,16 @@ function RichTextInput( if (isArrowUp || isArrowDown) { if (hasMentions) { - setSelected((prev) => { - const total = mentionsRef.current?.length ?? 1; + mention.setSelected((prev) => { + const total = mention.mentionsRef.current?.length ?? 1; if (isArrowUp) { return (prev - 1 + total) % total; } return (prev + 1) % total; }); } else if (hasEmojis) { - setSelectedEmoji((prev) => { - const total = emojiDataRef.current?.length || 1; + emoji.setSelectedEmoji((prev) => { + const total = emoji.emojiDataRef.current?.length || 1; if (isArrowUp) { return (prev - 1 + total) % total; } @@ -514,15 +327,17 @@ function RichTextInput( } if (isEnter) { - if (hasMentions) { - const mention = mentionsRef.current?.[selectedRef.current]; - if (mention) { - onApplyMention(mention); + if (hasMentions && editorRef.current) { + const selectedMention = + mention.mentionsRef.current?.[mention.selectedRef.current]; + if (selectedMention) { + mention.applyMention(editorRef.current, selectedMention); } - } else if (hasEmojis) { - const emoji = emojiDataRef.current?.[selectedEmojiRef.current]; - if (emoji) { - onApplyEmoji(emoji.emoji); + } else if (hasEmojis && editorRef.current) { + const selectedEmoji = + emoji.emojiDataRef.current?.[emoji.selectedEmojiRef.current]; + if (selectedEmoji) { + emoji.applyEmoji(editorRef.current, selectedEmoji.emoji); } } } @@ -537,6 +352,15 @@ function RichTextInput( editorRef.current = editor; }, [editor]); + const upload = useImageUpload({ + enabled: isUploadEnabled, + editorRef, + }); + + const onGifCommand = async (gifUrl: string, altText: string) => { + upload.insertImage(gifUrl, altText); + }; + useImperativeHandle(ref, () => ({ onMentionCommand: () => { if (!editor) { @@ -545,11 +369,7 @@ function RichTextInput( editor.chain().focus().insertContent('@').run(); updateSuggestionsFromEditor(editor); }, - clearDraft: () => { - if (draftStorageKey) { - storageWrapper.removeItem(draftStorageKey); - } - }, + clearDraft, setInput: (value: string) => { updateInput(value, { notify: true, markDirty: true }); if (!editor) { @@ -587,116 +407,8 @@ function RichTextInput( } }, [editor, initialContent, input, updateInput]); - useEffect(() => { - if (!draftStorageKey || !dirtyRef.current) { - return undefined; - } - - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } - - saveTimeoutRef.current = setTimeout(() => { - if (input && input.trim().length > 0) { - storageWrapper.setItem(draftStorageKey, input); - } else { - storageWrapper.removeItem(draftStorageKey); - } - }, 500); - - return () => { - if (saveTimeoutRef.current) { - clearTimeout(saveTimeoutRef.current); - } - }; - }, [input, draftStorageKey]); - - const insertImage = useCallback((url: string, altText: string) => { - const currentEditor = editorRef.current; - if (!currentEditor) { - return; - } - - currentEditor.chain().focus().setImage({ src: url, alt: altText }).run(); - }, []); - - 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 = (event) => { - if (!isUploadEnabled) { - return; - } - - const files = (event.currentTarget as HTMLInputElement).files; - if (!files?.length) { - return; - } - - Array.from(files).forEach(verifyFile); - startUploading(); - }; - - const onGifCommand = async (gifUrl: string, altText: string) => { - insertImage(gifUrl, altText); - }; - - const handleDrop = async (event: React.DragEvent) => { - if (!isUploadEnabled) { - return; - } - - event.preventDefault(); - const files = event.dataTransfer.files; - if (!files?.length) { - return; - } - - Array.from(files).forEach(verifyFile); - startUploading(); - }; - - const handlePaste = (event: React.ClipboardEvent) => { - if (!isUploadEnabled) { - return; - } - - const files = event.clipboardData.files; - if (!files?.length) { - return; - } - - event.preventDefault(); - Array.from(files).forEach(verifyFile); - startUploading(); - }; - const actionIcon = - queueCount === 0 ? ( + upload.queueCount === 0 ? ( ) : ( uploadRef?.current?.click()} + onClick={() => upload.uploadRef?.current?.click()} type="button" /> )} @@ -815,9 +527,9 @@ function RichTextInput( showUserAvatar && 'ml-3', )} ref={editorContainerRef} - onDrop={handleDrop} + onDrop={upload.handleDrop} onDragOver={(event) => event.preventDefault()} - onPaste={handlePaste} + onPaste={upload.handlePaste} > )} { - queryRef.current = undefined; - setQuery(undefined); - mentionRangeRef.current = null; + mentions={mention.mentions} + selected={mention.selected} + query={mention.query} + onMentionClick={(m) => { + if (editorRef.current) { + mention.applyMention(editorRef.current, m); + } }} + onClickOutside={mention.clearMention} appendTo={parentSelector} /> { - emojiQueryRef.current = undefined; - setEmojiQuery(undefined); - emojiRangeRef.current = null; + selected={emoji.selectedEmoji} + onSelect={(e) => { + if (editorRef.current) { + emoji.applyEmoji(editorRef.current, e); + } }} + onClickOutside={emoji.clearEmoji} /> {footer ?? ( {hasUploadHint && ( - + Drag and drop images to attach )} diff --git a/packages/shared/src/lib/markdownConversion.ts b/packages/shared/src/lib/markdownConversion.ts index a850350a07..99fc8732db 100644 --- a/packages/shared/src/lib/markdownConversion.ts +++ b/packages/shared/src/lib/markdownConversion.ts @@ -1,8 +1,5 @@ const escapeHtml = (value: string): string => - value - .replace(/&/g, '&') - .replace(//g, '>'); + value.replace(/&/g, '&').replace(//g, '>'); const escapeAttribute = (value: string): string => value.replace(/"/g, '"'); @@ -129,7 +126,17 @@ export const markdownToHtmlBasic = (markdown: string): string => { return htmlParts.join(''); }; -const serializeInline = (node: Node): string => { +// Mutually recursive functions for inline serialization +function serializeChildren(node: Element): string { + return ( + Array.from(node.childNodes) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .map((child) => serializeInline(child)) + .join('') + ); +} + +function serializeInline(node: Node): string { if (node.nodeType === Node.TEXT_NODE) { return normalizeText(node.textContent || ''); } @@ -168,12 +175,7 @@ const serializeInline = (node: Node): string => { default: return serializeChildren(node); } -}; - -const serializeChildren = (node: Element): string => - Array.from(node.childNodes) - .map((child) => serializeInline(child)) - .join(''); +} const serializeList = (node: Element, ordered: boolean): string => { const items = Array.from(node.children).filter( @@ -240,7 +242,10 @@ export const htmlToMarkdownBasic = (html: string): string => { }) .filter(Boolean); - return blocks.join('\n\n').replace(/\n{3,}/g, '\n\n').trim(); + return blocks + .join('\n\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); }; export default markdownToHtmlBasic; From 841a068788fea9ed0687572b6cea23a357873bbf Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Fri, 6 Feb 2026 14:24:23 +0200 Subject: [PATCH 3/4] fix: markdown switch mode --- .../fields/RichTextEditor/RichTextToolbar.tsx | 32 +- .../src/components/fields/RichTextInput.tsx | 292 +++++++++++++----- 2 files changed, 239 insertions(+), 85 deletions(-) diff --git a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx index d1d742519f..41cdf62c40 100644 --- a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx +++ b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx @@ -5,6 +5,7 @@ import React, { 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'; @@ -119,39 +120,52 @@ 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} /> {inlineActions && ( @@ -160,7 +174,7 @@ function RichTextToolbarComponent(
    {inlineActions}
    )} - {(editor.can().undo() || editor.can().redo()) && ( + {(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}
    diff --git a/packages/shared/src/components/fields/RichTextInput.tsx b/packages/shared/src/components/fields/RichTextInput.tsx index ebdbf600bc..655f5cd6d6 100644 --- a/packages/shared/src/components/fields/RichTextInput.tsx +++ b/packages/shared/src/components/fields/RichTextInput.tsx @@ -57,6 +57,12 @@ 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( @@ -133,10 +139,12 @@ function RichTextInput( 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]; @@ -273,6 +281,26 @@ function RichTextInput( 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 = @@ -361,6 +389,47 @@ function RichTextInput( 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) { @@ -379,6 +448,11 @@ function RichTextInput( editor.commands.setContent(markdownToHtmlBasic(value)); }, focus: () => { + if (isMarkdownMode) { + markdownTextareaRef.current?.focus(); + return; + } + editor?.commands.focus(); }, })); @@ -418,8 +492,11 @@ function RichTextInput( ); const remainingCharacters = - maxLength && editor?.storage.characterCount - ? maxLength - editor.storage.characterCount.characters() + maxLength && (isMarkdownMode || editor?.storage.characterCount) + ? maxLength - + (isMarkdownMode + ? input.length + : editor?.storage.characterCount.characters() ?? input.length) : null; const hasToolbarActions = isUploadEnabled || isMentionEnabled || isGifEnabled; @@ -527,90 +604,153 @@ function RichTextInput( showUserAvatar && 'ml-3', )} ref={editorContainerRef} - onDrop={upload.handleDrop} - onDragOver={(event) => event.preventDefault()} - onPaste={upload.handlePaste} + onDrop={isMarkdownMode ? undefined : upload.handleDrop} + onDragOver={ + isMarkdownMode ? undefined : (event) => event.preventDefault() + } + onPaste={isMarkdownMode ? undefined : upload.handlePaste} > - { - if (!editor) { - return; - } - if (!editor.state.selection.empty) { - editor.chain().focus().setLink({ href: url }).run(); - } else { - const linkText = label || url; - editor - .chain() - .focus() - .insertContent(`${linkText}`) - .run(); - } - }} - inlineActions={hasToolbarActions ? toolbarActions : null} - rightActions={ - onClose ? ( - - ) : null - } - /> - {isUploadEnabled && ( - + {isMarkdownMode ? ( + <> +
    + + Markdown editor + +
    + + {onClose && ( + + )} +
    +
    +