import { HocuspocusProvider } from '@hocuspocus/provider' import { Editor, isTextSelection } from '@tiptap/core' import { Bold } from '@tiptap/extension-bold' import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { BulletList } from '@tiptap/extension-bullet-list' import { CharacterCount } from '@tiptap/extension-character-count' import { Collaboration } from '@tiptap/extension-collaboration' import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor' import { Document } from '@tiptap/extension-document' import { Dropcursor } from '@tiptap/extension-dropcursor' import { FloatingMenu } from '@tiptap/extension-floating-menu' import Focus from '@tiptap/extension-focus' import { Gapcursor } from '@tiptap/extension-gapcursor' import { HardBreak } from '@tiptap/extension-hard-break' import { Heading } from '@tiptap/extension-heading' import { Highlight } from '@tiptap/extension-highlight' import { HorizontalRule } from '@tiptap/extension-horizontal-rule' import { Image } from '@tiptap/extension-image' import { Italic } from '@tiptap/extension-italic' import { Link } from '@tiptap/extension-link' import { ListItem } from '@tiptap/extension-list-item' import { OrderedList } from '@tiptap/extension-ordered-list' import { Paragraph } from '@tiptap/extension-paragraph' import { Placeholder } from '@tiptap/extension-placeholder' import { Strike } from '@tiptap/extension-strike' import { Text } from '@tiptap/extension-text' import { Underline } from '@tiptap/extension-underline' import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js' import { createTiptapEditor, useEditorHTML } from 'solid-tiptap' import uniqolor from 'uniqolor' import { Doc } from 'yjs' import { useEditorContext } from '~/context/editor' import { useLocalize } from '~/context/localize' import { useSession } from '~/context/session' import { useSnackbar } from '~/context/ui' import { handleImageUpload } from '~/lib/handleImageUpload' import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu' import { EditorFloatingMenu } from './EditorFloatingMenu' import { TextBubbleMenu } from './TextBubbleMenu' import Article from './extensions/Article' import { CustomBlockquote } from './extensions/CustomBlockquote' import { Figcaption } from './extensions/Figcaption' import { Figure } from './extensions/Figure' import { Footnote } from './extensions/Footnote' import { Iframe } from './extensions/Iframe' import { Span } from './extensions/Span' import { ToggleTextWrap } from './extensions/ToggleTextWrap' import { TrailingNode } from './extensions/TrailingNode' import './Prosemirror.scss' import { Author } from '~/graphql/schema/core.gen' type Props = { shoutId: number initialContent?: string onChange: (text: string) => void } const allowedImageTypes = new Set([ 'image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/tiff', 'image/webp', 'image/x-icon' ]) const yDocs: Record = {} const providers: Record = {} export const EditorComponent = (props: Props) => { const { t } = useLocalize() const { session } = useSession() const author = createMemo(() => session()?.user?.app_data?.profile as Author) const [isCommonMarkup, setIsCommonMarkup] = createSignal(false) const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false) const { showSnackbar } = useSnackbar() const docName = `shout-${props.shoutId}` if (!yDocs[docName]) { yDocs[docName] = new Doc() } if (!providers[docName]) { providers[docName] = new HocuspocusProvider({ url: 'wss://hocuspocus.discours.io', name: docName, document: yDocs[docName], token: session()?.access_token || '' }) } const [editorElRef, setEditorElRef] = createSignal() let textBubbleMenuRef: HTMLDivElement | undefined let incutBubbleMenuRef: HTMLElement | undefined let figureBubbleMenuRef: HTMLElement | undefined let blockquoteBubbleMenuRef: HTMLElement | undefined let floatingMenuRef: HTMLDivElement | undefined const handleClipboardPaste = async () => { try { const clipboardItems = await navigator.clipboard.read() if (clipboardItems.length === 0) return const [clipboardItem] = clipboardItems const { types } = clipboardItem const imageType = types.find((type) => allowedImageTypes.has(type)) if (!imageType) return const blob = await clipboardItem.getType(imageType) const extension = imageType.split('/')[1] const file = new File([blob], `clipboardImage.${extension}`) const uplFile = { source: blob.toString(), name: file.name, size: file.size, file } showSnackbar({ body: t('Uploading image') }) const result = await handleImageUpload(uplFile, session()?.access_token || '') editor() ?.chain() .focus() .insertContent({ type: 'figure', attrs: { 'data-type': 'image' }, content: [ { type: 'image', attrs: { src: result.url } }, { type: 'figcaption', content: [{ type: 'text', text: result.originalFilename }] } ] }) .run() } catch (error) { console.error('[Paste Image Error]:', error) } } const { initialContent } = props const { editor, setEditor, countWords } = useEditorContext() createEffect( on(editorElRef, (ee: HTMLElement | undefined) => { if (ee) { const freshEditor = createTiptapEditor(() => ({ element: ee, editorProps: { attributes: { class: 'articleEditor' }, transformPastedHTML(html) { return html.replaceAll(//g, '') }, handlePaste: () => { handleClipboardPaste() return false } }, extensions: [ Document, Text, Paragraph, Dropcursor, CustomBlockquote, Bold, Italic, Span, ToggleTextWrap, Strike, HorizontalRule.configure({ HTMLAttributes: { class: 'horizontalRule' } }), Underline, Link.extend({ inclusive: false }).configure({ autolink: true, openOnClick: false }), Heading.configure({ levels: [2, 3, 4] }), BulletList, OrderedList, ListItem, Collaboration.configure({ document: yDocs[docName] }), CollaborationCursor.configure({ provider: providers[docName], user: { name: author().name, color: uniqolor(author().slug).color } }), Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }), Focus, Gapcursor, HardBreak, Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }), Image, Iframe, Figure, Figcaption, Footnote, ToggleTextWrap, CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689 BubbleMenu.configure({ pluginKey: 'textBubbleMenu', element: textBubbleMenuRef, shouldShow: ({ editor: e, view, state, from, to }) => { const { doc, selection } = state const { empty } = selection const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection) if (isEmptyTextBlock) { e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run() } setIsCommonMarkup(e?.isActive('figcaption')) const result = (view.hasFocus() && !empty && !isEmptyTextBlock && !e.isActive('image') && !e.isActive('figure')) || e.isActive('footnote') || (e.isActive('figcaption') && !empty) setShouldShowTextBubbleMenu(result) return result }, tippyOptions: { onHide: () => { const fe = freshEditor() as Editor fe?.commands.focus() } } }), BubbleMenu.configure({ pluginKey: 'blockquoteBubbleMenu', element: blockquoteBubbleMenuRef, shouldShow: ({ editor: e, view, state }) => { const { empty } = state.selection return view.hasFocus() && !empty && e.isActive('blockquote') } }), BubbleMenu.configure({ pluginKey: 'figureBubbleMenu', element: figureBubbleMenuRef, shouldShow: ({ editor: e, view, state }) => { const { empty } = state.selection return view.hasFocus() && !empty && e.isActive('figure') } }), BubbleMenu.configure({ pluginKey: 'incutBubbleMenu', element: incutBubbleMenuRef, shouldShow: ({ editor: e, view, state }) => { const { empty } = state.selection return view.hasFocus() && !empty && e.isActive('figcaption') } }), FloatingMenu.configure({ element: floatingMenuRef, pluginKey: 'floatingMenu', shouldShow: ({ editor: e, state }) => { const { $anchor, empty } = state.selection const isRootDepth = $anchor.depth === 1 if (!(isRootDepth && empty)) return false return !(e.isActive('codeBlock') || e.isActive('heading')) } }), TrailingNode, Article ], onTransaction: ({ transaction }) => { if (transaction.docChanged) { const fe = freshEditor() if (fe) { const changeHandle = useEditorHTML(() => fe as Editor | undefined) props.onChange(changeHandle() || '') countWords(fe?.storage.characterCount.words()) } } }, content: initialContent })) if (freshEditor) { editorElRef()?.addEventListener('focus', (_event) => { if (freshEditor()?.isActive('figcaption')) { freshEditor()?.commands.focus() } }) setEditor(freshEditor() as Editor) } } }) ) onCleanup(() => { editor()?.destroy() }) return ( <>
(textBubbleMenuRef = el)} /> { blockquoteBubbleMenuRef = el }} editor={editor() as Editor} /> { figureBubbleMenuRef = el }} /> { incutBubbleMenuRef = el }} /> (floatingMenuRef = el)} /> ) }