webapp/src/components/Editor/Editor.tsx
Untone 10ef9e1828
All checks were successful
deploy / testbuild (push) Successful in 2m15s
deploy / Update templates on Mailgun (push) Has been skipped
editor-fullmenu-fix
2024-10-11 21:43:17 +03:00

190 lines
7.0 KiB
TypeScript

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<HTMLElement | undefined>()
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [floatingMenuRef, setFloatingMenuRef] = createSignal<HTMLDivElement | undefined>()
const [textBubbleMenuRef, setFullBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const editor = createTiptapEditor(() => ({
element: editorElRef()!,
editorProps: {
attributes: {
class: 'articleEditor'
},
transformPastedHTML(html) {
return html.replaceAll(/<img.*?>/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()
charsCount && 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 (
<>
<div class="row">
<div class="col-md-5" />
<div class="col-md-12">
<div ref={setEditorElRef} id="editorBody" />
</div>
</div>
<FullBubbleMenu
editor={editor}
ref={setFullBubbleMenuRef}
shouldShow={shouldShowTextBubbleMenu}
isCommonMarkup={isCommonMarkup()}
/>
<BlockquoteBubbleMenu editor={editor() as Editor} ref={setBlockquoteBubbleMenuRef} />
<FigureBubbleMenu editor={editor() as Editor} ref={setFigureBubbleMenuRef} />
<IncutBubbleMenu editor={editor() as Editor} ref={setIncutBubbleMenuRef} />
<EditorFloatingMenu editor={editor() as Editor} ref={setFloatingMenuRef} />
</>
)
}