webapp/src/components/Editor/Editor.tsx
Untone 217c027044
Some checks failed
deploy / test (push) Failing after 1m12s
deploy / Update templates on Mailgun (push) Has been skipped
..
2024-10-01 22:52:45 +03:00

262 lines
9.9 KiB
TypeScript

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 { allowedImageTypes, renderUploadedImage } from '../Upload/renderUploadedImage'
import { BlockquoteBubbleMenu } from './Toolbar/BlockquoteBubbleMenu'
import { EditorFloatingMenu } from './Toolbar/EditorFloatingMenu'
import { FigureBubbleMenu } from './Toolbar/FigureBubbleMenu'
import { IncutBubbleMenu } from './Toolbar/IncutBubbleMenu'
import { TextBubbleMenu } from './Toolbar/TextBubbleMenu'
import './Editor.module.scss'
export type EditorComponentProps = {
shoutId: number
initialContent?: string
onChange: (text: string) => void
disableCollaboration?: boolean
}
const yDocs: Record<string, Doc> = {}
const providers: Record<string, HocuspocusProvider> = {}
export const EditorComponent = (props: EditorComponentProps) => {
const { t } = useLocalize()
const { session } = useSession()
const author = createMemo<Author>(() => 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<Partial<EditorOptions>>({})
const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>()
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLElement | undefined>()
const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLElement | undefined>()
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLElement | undefined>()
const [floatingMenuRef, setFloatingMenuRef] = createSignal<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 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(/<img.*?>/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<EditorOptions>)
createEditor(options as unknown as Partial<EditorOptions>)
}
})
)
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 (
<>
<div class="row">
<div class="col-md-5" />
<div class="col-md-12">
<div ref={setEditorElRef} id="editorBody" />
</div>
</div>
<Show when={editor()}>
<TextBubbleMenu
shouldShow={shouldShowTextBubbleMenu()}
isCommonMarkup={isCommonMarkup()}
editor={editor() as Editor}
ref={setTextBubbleMenuRef}
/>
<BlockquoteBubbleMenu ref={setBlockquoteBubbleMenuRef} editor={editor() as Editor} />
<FigureBubbleMenu editor={editor() as Editor} ref={setFigureBubbleMenuRef} />
<IncutBubbleMenu editor={editor() as Editor} ref={setIncutBubbleMenuRef} />
<EditorFloatingMenu editor={editor() as Editor} ref={setFloatingMenuRef} />
</Show>
</>
)
}