import CharacterCount from '@tiptap/extension-character-count' import {Link} from '@tiptap/extension-link' import Placeholder from '@tiptap/extension-placeholder' import type {Content, JSONContent} from '@tiptap/react' import {Editor, EditorContent, Extensions, mergeAttributes, useEditor} from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import clsx from 'clsx' import {richTextToString} from 'common/util/parse' import Iframe from 'common/util/tiptap-iframe' import {debounce, noop} from 'lodash' import {ReactNode, useCallback, useEffect, useMemo} from 'react' import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state' import {safeLocalStorage} from 'web/lib/util/local' import {EmojiExtension} from '../editor/emoji/emoji-extension' import {FloatingFormatMenu} from '../editor/floating-format-menu' import {BasicImage, DisplayImage, MediumDisplayImage} from '../editor/image' import {nodeViewMiddleware} from '../editor/nodeview-middleware' import {StickyFormatMenu} from '../editor/sticky-format-menu' import {Upload, useUploadMutation} from '../editor/upload-extension' import {DisplayMention} from '../editor/user-mention/mention-extension' import {generateReact} from '../editor/utils' import {Linkify} from './linkify' import {linkClass} from './site-link' const DisplayLink = Link.extend({ renderHTML({HTMLAttributes}) { HTMLAttributes.target = HTMLAttributes.href.includes('manifold.markets') ? '_self' : '_blank' delete HTMLAttributes.class // only use our classes (don't duplicate on paste) return ['a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] }, }).configure({ openOnClick: false, // stop link opening twice (browser still opens) HTMLAttributes: { rel: 'noopener ugc', class: linkClass, }, }) const editorExtensions = (simple = false): Extensions => nodeViewMiddleware([ StarterKit.configure({ heading: simple ? false : {levels: [1, 2, 3]}, horizontalRule: simple ? false : {}, }), simple ? DisplayImage : BasicImage, EmojiExtension, DisplayLink, DisplayMention, Iframe, Upload, ]) const proseClass = (size: 'sm' | 'md' | 'lg') => clsx( 'prose dark:prose-invert max-w-none leading-relaxed', 'prose-a:text-primary-700 prose-a:no-underline', size === 'sm' ? 'prose-sm' : 'text-md', size !== 'lg' && 'prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0', '[&>p]:prose-li:my-0', 'text-ink-900 prose-blockquote:text-teal-700', 'break-anywhere', ) export const getEditorLocalStorageKey = (key: string) => `text ${key}` export function useTextEditor(props: { placeholder?: string max?: number defaultValue?: Content size?: 'sm' | 'md' | 'lg' key?: string // unique key for autosave. If set, plz call `editor.commands.clearContent(true)` on submit to clear autosave extensions?: Extensions className?: string }) { const {placeholder, className, max, defaultValue, size = 'md', key} = props const simple = size === 'sm' const [content, setContent] = usePersistentLocalState( undefined, getEditorLocalStorageKey(key ?? ''), ) const save = useCallback( debounce((newContent: JSONContent) => { const oldText = richTextToString(content) const newText = richTextToString(newContent) if (oldText.length === 0 && newText.length === 0) { safeLocalStorage?.removeItem(getEditorLocalStorageKey(key ?? '')) } else { setContent(newContent) } }, 500), [], ) const getEditorProps = () => ({ attributes: { class: clsx( proseClass(size), 'outline-none py-[.5em] px-4', 'prose-img:select-auto', '[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-primary-300', // selected img, embeds 'dark:[&_.ProseMirror-gapcursor]:after:border-white', // gap cursor className, ), style: `min-height: ${1 + 1.625 * (simple ? 2 : 3)}em`, // 1em padding + 1.625 lines per row }, }) const editor = useEditor({ editorProps: getEditorProps(), immediatelyRender: false, onUpdate: !key ? noop : ({editor}) => { save(editor.getJSON()) }, extensions: [ ...editorExtensions(simple), Placeholder.configure({ placeholder, emptyEditorClass: 'before:content-[attr(data-placeholder)] before:text-ink-500 before:float-left before:h-0 cursor-text', }), CharacterCount.configure({limit: max}), ...(props.extensions ?? []), ], content: defaultValue ?? (key && content ? content : ''), }) useEffect(() => { // Using a dep array in the useEditor hook doesn't work, so we have to use a useEffect editor?.setOptions({ editorProps: getEditorProps(), }) }, [className]) const upload = useUploadMutation(editor) if (!editor) return null editor.storage.upload.mutation = upload editor.setOptions({ editorProps: { handlePaste(_view, event) { const imageFiles = getImages(event.clipboardData) if (imageFiles.length) { event.preventDefault() upload.mutate(imageFiles) return true // Prevent image in text/html from getting pasted again } // Otherwise, use default paste handler }, handleDrop(_view, event, _slice, moved) { // if dragged from outside if (!moved) { event.preventDefault() upload.mutate(getImages(event.dataTransfer)) } }, }, }) return editor } const getImages = (data: DataTransfer | null) => Array.from(data?.files ?? []).filter((file) => file.type.startsWith('image')) export function TextEditor(props: { editor: Editor | null simple?: boolean // show heading in toolbar hideEmbed?: boolean // hide toolbar children?: ReactNode // additional toolbar buttons className?: string onBlur?: () => void onChange?: () => void }) { const {editor, simple, hideEmbed, children, className, onBlur, onChange} = props return ( // matches input styling
{children}
) } function RichContent(props: {content: JSONContent; className?: string; size?: 'sm' | 'md' | 'lg'}) { const {className, content, size = 'md'} = props const jsxContent = useMemo(() => { try { return generateReact(content, [ StarterKit as any, size === 'sm' ? DisplayImage : size === 'md' ? MediumDisplayImage : BasicImage, DisplayLink, DisplayMention, Iframe, ]) } catch (e) { console.error('Error generating react', e, 'for content', content) return '' } }, [content, size]) if (!content) return null return (
{jsxContent}
) } // backwards compatibility: we used to store content as strings export function Content(props: { content: JSONContent | string /** font/spacing */ size?: 'sm' | 'md' | 'lg' className?: string }) { const {className, size = 'md', content} = props return typeof content === 'string' ? ( ) : ( ) }