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 { ReactNode, useCallback, useEffect, useMemo } from 'react' import { DisplayMention } from '../editor/user-mention/mention-extension' import { Linkify } from './linkify' import { linkClass } from './site-link' import Iframe from 'common/util/tiptap-iframe' import { debounce, noop } from 'lodash' import { FloatingFormatMenu } from '../editor/floating-format-menu' import { StickyFormatMenu } from '../editor/sticky-format-menu' import { Upload, useUploadMutation } from '../editor/upload-extension' import { generateReact } from '../editor/utils' import { EmojiExtension } from '../editor/emoji/emoji-extension' import { nodeViewMiddleware } from '../editor/nodeview-middleware' import { BasicImage, DisplayImage, MediumDisplayImage } from '../editor/image' import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state' import { richTextToString } from 'common/util/parse' import { safeLocalStorage } from 'web/lib/util/local' 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< JSONContent | undefined >(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(), 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' ? ( ) : ( ) }