import { Editor, isTextSelection } from '@tiptap/core' import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { CharacterCount } from '@tiptap/extension-character-count' import { FloatingMenu } from '@tiptap/extension-floating-menu' import { Link } from '@tiptap/extension-link' import { Placeholder } from '@tiptap/extension-placeholder' import { createEffect, createSignal, onCleanup } from 'solid-js' import { createTiptapEditor } from 'solid-tiptap' import { useSnackbar } from '~/context/ui' import { base, custom, extended } from '~/lib/editorExtensions' import { handleClipboardPaste } from '~/lib/handleImageUpload' import { useEditorContext } from '../../context/editor' import { useLocalize } from '../../context/localize' import { useSession } from '../../context/session' import { BlockquoteBubbleMenu } from './Toolbar/BlockquoteBubbleMenu' import { EditorFloatingMenu } from './Toolbar/EditorFloatingMenu' import { FigureBubbleMenu } from './Toolbar/FigureBubbleMenu' import { FullBubbleMenu } from './Toolbar/FullBubbleMenu' import { IncutBubbleMenu } from './Toolbar/IncutBubbleMenu' import { ArticleNode } from './extensions/Article' import { TrailingNode } from './extensions/TrailingNode' import './Editor.module.scss' type Props = { shoutId: number initialContent?: string onChange: (text: string) => void } export const EditorComponent = (props: Props) => { const { t } = useLocalize() const { session } = useSession() const { showSnackbar } = useSnackbar() const { countWords, setEditing } = useEditorContext() const [isCommonMarkup, setIsCommonMarkup] = createSignal(false) const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false) const [editorElRef, setEditorElRef] = createSignal() const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal() const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal() const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal() const [floatingMenuRef, setFloatingMenuRef] = createSignal() const [textBubbleMenuRef, setFullBubbleMenuRef] = createSignal() const editor = createTiptapEditor(() => ({ element: editorElRef()!, editorProps: { attributes: { class: 'articleEditor' }, transformPastedHTML(html) { return html.replaceAll(//g, '') }, handlePaste: () => { showSnackbar({ body: t('Uploading image') }) handleClipboardPaste(editor(), session()?.access_token || '').then(() => false) return false } }, 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 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) if (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: { sticky: true } }), BubbleMenu.configure({ pluginKey: 'blockquoteBubbleMenu', element: blockquoteBubbleMenuRef()!, shouldShow: ({ editor: e, state }) => { const { selection } = state const { empty } = selection return empty && e.isActive('blockquote') }, tippyOptions: { offset: [0, 0], placement: 'top', getReferenceClientRect: (): DOMRect => { const selectedElement = editor()?.view.dom.querySelector('.has-focus') as HTMLElement | null if (selectedElement) { return selectedElement.getBoundingClientRect() } return new DOMRect() } } }), BubbleMenu.configure({ pluginKey: 'incutBubbleMenu', element: incutBubbleMenuRef()!, shouldShow: ({ editor: e, state }) => { const { selection } = state const { empty } = selection return empty && e.isActive('article') }, tippyOptions: { offset: [0, -16], placement: 'top', getReferenceClientRect: (): DOMRect => { const selectedElement = editor()?.view.dom.querySelector('.has-focus') as HTMLElement | null if (selectedElement) { return selectedElement.getBoundingClientRect() } return new DOMRect() } } }), BubbleMenu.configure({ pluginKey: 'imageBubbleMenu', element: figureBubbleMenuRef()!, shouldShow: ({ editor: e, view }) => { return view.hasFocus() && e.isActive('image') } }), FloatingMenu.configure({ tippyOptions: { placement: 'left' }, element: floatingMenuRef()! }), TrailingNode, ArticleNode ], enablePasteRules: [Link], content: props.initialContent || null, onTransaction: ({ editor: e, transaction }) => { if (transaction.docChanged) { const html = e.getHTML() html && props.onChange(html) const wordCount: number = e.storage.characterCount.words() const charsCount: number = e.storage.characterCount.characters() wordCount && countWords({ words: wordCount, characters: charsCount }) } } })) // store tiptap editor in context provider's signal to use it in Panel createEffect(() => setEditing(editor() || undefined)) onCleanup(() => { editor()?.destroy() }) return ( <>
) }