import { HocuspocusProvider } from '@hocuspocus/provider' import { Editor, EditorOptions, isTextSelection } from '@tiptap/core' import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { CharacterCount } from '@tiptap/extension-character-count' import { Collaboration } from '@tiptap/extension-collaboration' import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor' import { FloatingMenu } from '@tiptap/extension-floating-menu' import { Placeholder } from '@tiptap/extension-placeholder' import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js' import uniqolor from 'uniqolor' import { Doc, Transaction } from 'yjs' import { useEditorContext } from '~/context/editor' import { useLocalize } from '~/context/localize' import { useSession } from '~/context/session' import { useSnackbar } from '~/context/ui' import { Author } from '~/graphql/schema/core.gen' import { base, custom, extended } from '~/lib/editorExtensions' import { handleImageUpload } from '~/lib/handleImageUpload' import { renderUploadedImage } from '../Upload/renderUploadedImage' import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu' import { TextBubbleMenu } from './BubbleMenu/TextBubbleMenu' import { EditorFloatingMenu } from './EditorFloatingMenu' import './Editor.module.scss' export type EditorComponentProps = { shoutId: number initialContent?: string onChange: (text: string) => void disableCollaboration?: boolean } 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: EditorComponentProps) => { 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 { createEditor, countWords, editor } = useEditorContext() const [editorOptions, setEditorOptions] = createSignal>({}) const [editorElRef, setEditorElRef] = createSignal() const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal() const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal() const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal() const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal() const [floatingMenuRef, setFloatingMenuRef] = createSignal() 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 image = await handleImageUpload(uplFile, session()?.access_token || '') renderUploadedImage(editor() as Editor, image) } catch (error) { console.error('[Paste Image Error]:', error) } return false } createEffect( on([editorOptions, editorElRef, author], ([opts, element, a]) => { if (!opts && a && element) { const options = { element: editorElRef()!, editorProps: { attributes: { class: 'articleEditor' }, transformPastedHTML: (c: string) => c.replaceAll(//g, ''), handlePaste: handleClipboardPaste }, extensions: [ ...base, ...custom, ...extended, Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }), CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689 // menus BubbleMenu.configure({ pluginKey: 'textBubbleMenu', element: textBubbleMenuRef(), shouldShow: ({ editor: e, view, state: { doc, selection }, from, to }) => { const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection) isEmptyTextBlock && e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run() setIsCommonMarkup(e?.isActive('figcaption')) const result = (view.hasFocus() && !selection.empty && !isEmptyTextBlock && !e.isActive('image') && !e.isActive('figure')) || e.isActive('footnote') || (e.isActive('figcaption') && !selection.empty) setShouldShowTextBubbleMenu(result) return result }, tippyOptions: { onHide: () => editor()?.commands.focus() as false } }), BubbleMenu.configure({ pluginKey: 'blockquoteBubbleMenu', element: blockquoteBubbleMenuRef(), shouldShow: ({ editor: e, view, state }) => view.hasFocus() && !state.selection.empty && e.isActive('blockquote') }), BubbleMenu.configure({ pluginKey: 'figureBubbleMenu', element: figureBubbleMenuRef(), shouldShow: ({ editor: e, view, state }) => view.hasFocus() && !state.selection.empty && e.isActive('figure') }), BubbleMenu.configure({ pluginKey: 'incutBubbleMenu', element: incutBubbleMenuRef(), shouldShow: ({ editor: e, view, state }) => view.hasFocus() && !state.selection.empty && e.isActive('figcaption') }), FloatingMenu.configure({ element: floatingMenuRef(), pluginKey: 'floatingMenu', shouldShow: ({ editor: e, state: { selection } }) => { const isRootDepth = selection.$anchor.depth === 1 if (!(isRootDepth && selection.empty)) return false return !(e.isActive('codeBlock') || e.isActive('heading')) } }) // dynamic // Collaboration.configure({ document: yDocs[docName] }), // CollaborationCursor.configure({ provider: providers[docName], user: { name: a.name, color: uniqolor(a.slug).color } }), ], onTransaction({ transaction, editor }: { transaction: Transaction; editor: Editor }) { if (transaction.changed) { // Get the current HTML content from the editor const html = editor.getHTML() // Trigger the onChange callback with the updated HTML html && props.onChange(html) // Get the word count from the editor's storage (using CharacterCount) const wordCount = editor.storage.characterCount.words() // Update the word count wordCount && countWords(wordCount) } }, content: props.initialContent || '' } setEditorOptions(options as unknown as Partial) createEditor(options as unknown as Partial) } }) ) createEffect( on( [ editor, () => !props.disableCollaboration, () => `shout-${props.shoutId}`, () => session()?.access_token || '', author ], ([e, collab, docName, token, profile]) => { if (!e) return 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 }) } collab && createEditor({ ...editorOptions(), extensions: [ ...(editor()?.options.extensions || []), Collaboration.configure({ document: yDocs[docName] }), CollaborationCursor.configure({ provider: providers[docName], user: { name: profile.name, color: uniqolor(profile.slug).color } }) ] }) } ) ) createEffect( on(editorElRef, (ee: HTMLElement | undefined) => { ee?.addEventListener('focus', (_event) => { if (editor()?.isActive('figcaption')) { editor()?.commands.focus() } }) }) ) onCleanup(() => { editor()?.destroy() }) return ( <>
) }