import CharacterCount from '@tiptap/extension-character-count' import Placeholder from '@tiptap/extension-placeholder' import { useEditor, BubbleMenu, EditorContent, JSONContent, Content, Editor, mergeAttributes, Extensions, } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' import clsx from 'clsx' import { useCallback, useEffect, useState } from 'react' import { Linkify } from './linkify' import { uploadImage } from 'web/lib/firebase/storage' import { useMutation } from 'react-query' import { linkClass } from './site-link' import { DisplayMention } from './editor/mention' import { DisplayContractMention } from './editor/contract-mention' import GridComponent from './editor/tiptap-grid-cards' import Iframe from 'common/util/tiptap-iframe' import TiptapTweet from './editor/tiptap-tweet' import { EmbedModal } from './editor/embed-modal' import { CheckIcon, CodeIcon, EyeOffIcon, PhotographIcon, PresentationChartLineIcon, TrashIcon, } from '@heroicons/react/solid' import { MarketModal } from './editor/market-modal' import { insertContent } from './editor/utils' import { Tooltip } from './tooltip' import BoldIcon from 'web/lib/icons/bold-icon' import ItalicIcon from 'web/lib/icons/italic-icon' import LinkIcon from 'web/lib/icons/link-icon' import { getUrl } from 'common/util/parse' import { TiptapSpoiler } from 'common/util/tiptap-spoiler' import { ImageModal } from './editor/image-modal' import { storageStore, usePersistentState, } from 'web/hooks/use-persistent-state' import { safeLocalStorage } from 'web/lib/util/local' import { debounce } from 'lodash' const DisplayImage = Image.configure({ HTMLAttributes: { class: 'max-h-60', }, }) const DisplayLink = Link.extend({ renderHTML({ HTMLAttributes }) { delete HTMLAttributes.class // only use our classes (don't duplicate on paste) return [ 'a', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0, ] }, }).configure({ HTMLAttributes: { class: clsx('no-underline !text-indigo-700', linkClass), }, }) export const editorExtensions = (simple = false): Extensions => [ StarterKit.configure({ heading: simple ? false : { levels: [1, 2, 3] }, horizontalRule: simple ? false : {}, }), simple ? DisplayImage : Image, DisplayLink, DisplayMention, DisplayContractMention, GridComponent, Iframe, TiptapTweet, TiptapSpoiler.configure({ spoilerOpenClass: 'rounded-sm bg-greyscale-2', }), ] const proseClass = clsx( 'prose prose-p:my-0 prose-ul:my-0 prose-ol:my-0 prose-li:my-0 prose-blockquote:not-italic max-w-none prose-quoteless leading-relaxed', 'font-light prose-a:font-light prose-blockquote:font-light prose-sm' ) export function useTextEditor(props: { placeholder?: string max?: number defaultValue?: Content simple?: boolean key?: string // unique key for autosave. If set, plz call `clearContent(true)` on submit to clear autosave }) { const { placeholder, max, defaultValue, simple, key } = props const [content, saveContent] = usePersistentState( undefined, { key: `text ${key}`, store: storageStore(safeLocalStorage()), } ) // eslint-disable-next-line react-hooks/exhaustive-deps const save = useCallback(debounce(saveContent, 500), []) const editorClass = clsx( proseClass, !simple && 'min-h-[6em]', 'outline-none pt-2 px-4', 'prose-img:select-auto', '[&_.ProseMirror-selectednode]:outline-dotted [&_*]:outline-indigo-300' // selected img, embeds ) const editor = useEditor({ editorProps: { attributes: { class: editorClass, spellcheck: simple ? 'true' : 'false' }, }, onUpdate: key ? ({ editor }) => save(editor.getJSON()) : undefined, extensions: [ ...editorExtensions(simple), Placeholder.configure({ placeholder, emptyEditorClass: 'before:content-[attr(data-placeholder)] before:text-slate-500 before:float-left before:h-0 cursor-text', }), CharacterCount.configure({ limit: max }), ], content: defaultValue ?? (key && content ? content : ''), }) const upload = useUploadMutation(editor) editor?.setOptions({ editorProps: { handlePaste(view, event) { const imageFiles = getImages(event.clipboardData) if (imageFiles.length) { event.preventDefault() upload.mutate(imageFiles) } // If the pasted content is iframe code, directly inject it const text = event.clipboardData?.getData('text/plain').trim() ?? '' if (isValidIframe(text)) { insertContent(editor, text) return true // Prevent the code from getting pasted as text } return // 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, upload } } const getImages = (data: DataTransfer | null) => Array.from(data?.files ?? []).filter((file) => file.type.startsWith('image')) function isValidIframe(text: string) { return /^$/.test(text) } function FloatingMenu(props: { editor: Editor | null }) { const { editor } = props const [url, setUrl] = useState(null) if (!editor) return null // current selection const isBold = editor.isActive('bold') const isItalic = editor.isActive('italic') const isLink = editor.isActive('link') const isSpoiler = editor.isActive('spoiler') const setLink = () => { const href = url && getUrl(url) if (href) { editor.chain().focus().extendMarkRange('link').setLink({ href }).run() } } const unsetLink = () => editor.chain().focus().unsetLink().run() return ( {url === null ? ( <> ) : ( <> setUrl(e.target.value)} /> )} ) } export function TextEditor(props: { editor: Editor | null upload: ReturnType children?: React.ReactNode // additional toolbar buttons }) { const { editor, upload, children } = props const [imageOpen, setImageOpen] = useState(false) const [iframeOpen, setIframeOpen] = useState(false) const [marketOpen, setMarketOpen] = useState(false) return ( <> {/* hide placeholder when focused */}
{/* Toolbar, with buttons for images and embeds */}
{/* Spacer that also focuses editor on click */}
editor?.chain().focus('end').createParagraphNear().run() } aria-hidden /> {children}
{upload.isLoading && Uploading image...} {upload.isError && ( Error uploading image :( )} ) } const useUploadMutation = (editor: Editor | null) => useMutation( (files: File[]) => // TODO: Images should be uploaded under a particular username Promise.all(files.map((file) => uploadImage('default', file))), { onSuccess(urls) { if (!editor) return let trans = editor.chain().focus() urls.forEach((src) => { trans = trans.createParagraphNear() trans = trans.setImage({ src }) }) trans.run() }, } ) export function RichContent(props: { content: JSONContent | string className?: string smallImage?: boolean }) { const { className, content, smallImage } = props const editor = useEditor({ editorProps: { attributes: { class: proseClass } }, extensions: [ StarterKit, smallImage ? DisplayImage : Image, DisplayLink.configure({ openOnClick: false }), // stop link opening twice (browser still opens) DisplayMention, DisplayContractMention, GridComponent, Iframe, TiptapTweet, TiptapSpoiler.configure({ spoilerOpenClass: 'rounded-sm bg-greyscale-2 cursor-text', spoilerCloseClass: 'rounded-sm bg-greyscale-6 text-transparent [&_*]:invisible cursor-pointer select-none', }), ], content, editable: false, }) useEffect( // Check isDestroyed here so hot reload works, see https://github.com/ueberdosis/tiptap/issues/1451#issuecomment-941988769 () => void !editor?.isDestroyed && editor?.commands?.setContent(content), [editor, content] ) return } // backwards compatibility: we used to store content as strings export function Content(props: { content: JSONContent | string className?: string smallImage?: boolean }) { const { className, content } = props return typeof content === 'string' ? ( ) : ( ) }