webapp/src/components/Editor/Editor.tsx

365 lines
12 KiB
TypeScript
Raw Normal View History

import { HocuspocusProvider } from '@hocuspocus/provider'
2024-06-24 17:50:27 +00:00
import { Editor, isTextSelection } from '@tiptap/core'
2023-03-08 16:35:13 +00:00
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'
2023-03-08 16:35:13 +00:00
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'
2023-03-08 16:35:13 +00:00
import { Link } from '@tiptap/extension-link'
import { ListItem } from '@tiptap/extension-list-item'
import { OrderedList } from '@tiptap/extension-ordered-list'
2023-03-08 16:35:13 +00:00
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'
2024-06-24 17:50:27 +00:00
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
import uniqolor from 'uniqolor'
2024-05-05 16:13:48 +00:00
import { Doc } from 'yjs'
import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session'
2024-06-24 17:50:27 +00:00
import { useSnackbar } from '~/context/ui'
import { handleImageUpload } from '~/utils/handleImageUpload'
2024-02-04 11:25:21 +00:00
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
2024-02-04 11:25:21 +00:00
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'
2023-05-07 13:47:10 +00:00
import './Prosemirror.scss'
2024-06-24 17:50:27 +00:00
import { Author } from '~/graphql/schema/core.gen'
2023-03-08 16:35:13 +00:00
2023-07-24 14:09:04 +00:00
type Props = {
shoutId: number
2023-03-08 16:35:13 +00:00
initialContent?: string
2023-03-23 17:15:50 +00:00
onChange: (text: string) => void
2023-03-08 16:35:13 +00:00
}
const allowedImageTypes = new Set([
'image/bmp',
'image/gif',
'image/jpeg',
'image/jpg',
'image/png',
'image/tiff',
'image/webp',
2024-06-26 08:22:05 +00:00
'image/x-icon'
])
2023-05-05 20:05:50 +00:00
const yDocs: Record<string, Doc> = {}
2023-03-29 15:36:12 +00:00
const providers: Record<string, HocuspocusProvider> = {}
2023-03-08 16:35:13 +00:00
2024-06-24 17:50:27 +00:00
export const EditorComponent = (props: Props) => {
2023-03-08 16:35:13 +00:00
const { t } = useLocalize()
2024-06-24 17:50:27 +00:00
const { session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
2023-05-11 11:43:14 +00:00
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
2024-02-04 17:40:15 +00:00
const { showSnackbar } = useSnackbar()
const docName = `shout-${props.shoutId}`
2023-03-29 08:51:27 +00:00
2023-05-05 20:05:50 +00:00
if (!yDocs[docName]) {
2024-05-05 16:13:48 +00:00
yDocs[docName] = new Doc()
2023-05-05 20:05:50 +00:00
}
2023-03-29 10:14:39 +00:00
if (!providers[docName]) {
2023-03-29 15:36:12 +00:00
providers[docName] = new HocuspocusProvider({
2023-04-11 13:57:48 +00:00
url: 'wss://hocuspocus.discours.io',
2023-03-29 15:36:12 +00:00
name: docName,
document: yDocs[docName],
2024-06-26 08:22:05 +00:00
token: session()?.access_token || ''
2023-03-29 15:36:12 +00:00
})
2023-03-29 10:14:39 +00:00
}
2023-03-08 16:35:13 +00:00
2024-06-24 17:50:27 +00:00
const [editorElRef, setEditorElRef] = createSignal<HTMLElement>()
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,
2024-06-26 08:22:05 +00:00
file
}
showSnackbar({ body: t('Uploading image') })
2024-06-24 17:50:27 +00:00
const result = await handleImageUpload(uplFile, session()?.access_token || '')
editor()
2024-06-24 17:50:27 +00:00
?.chain()
.focus()
.insertContent({
type: 'figure',
attrs: { 'data-type': 'image' },
content: [
{
type: 'image',
2024-06-26 08:22:05 +00:00
attrs: { src: result.url }
},
{
type: 'figcaption',
2024-06-26 08:22:05 +00:00
content: [{ type: 'text', text: result.originalFilename }]
}
]
})
.run()
} catch (error) {
console.error('[Paste Image Error]:', error)
}
}
const { initialContent } = props
2024-06-24 17:50:27 +00:00
const { editor, setEditor, countWords } = useEditorContext()
createEffect(
on(editorElRef, (ee: HTMLElement | undefined) => {
if (ee) {
const freshEditor = createTiptapEditor<HTMLElement>(() => ({
element: ee,
editorProps: {
attributes: {
2024-06-26 08:22:05 +00:00
class: 'articleEditor'
2024-06-24 17:50:27 +00:00
},
transformPastedHTML(html) {
return html.replaceAll(/<img.*?>/g, '')
},
handlePaste: () => {
handleClipboardPaste()
return false
2024-06-26 08:22:05 +00:00
}
},
2024-06-24 17:50:27 +00:00
extensions: [
Document,
Text,
Paragraph,
Dropcursor,
CustomBlockquote,
Bold,
Italic,
Span,
ToggleTextWrap,
Strike,
HorizontalRule.configure({
HTMLAttributes: {
2024-06-26 08:22:05 +00:00
class: 'horizontalRule'
}
2024-06-24 17:50:27 +00:00
}),
Underline,
Link.extend({
2024-06-26 08:22:05 +00:00
inclusive: false
2024-06-24 17:50:27 +00:00
}).configure({
autolink: true,
2024-06-26 08:22:05 +00:00
openOnClick: false
2024-06-24 17:50:27 +00:00
}),
Heading.configure({
2024-06-26 08:22:05 +00:00
levels: [2, 3, 4]
2024-06-24 17:50:27 +00:00
}),
BulletList,
OrderedList,
ListItem,
Collaboration.configure({
2024-06-26 08:22:05 +00:00
document: yDocs[docName]
2024-06-24 17:50:27 +00:00
}),
CollaborationCursor.configure({
provider: providers[docName],
user: {
name: author().name,
2024-06-26 08:22:05 +00:00
color: uniqolor(author().slug).color
}
2024-06-24 17:50:27 +00:00
}),
Placeholder.configure({
2024-06-26 08:22:05 +00:00
placeholder: t('Add a link or click plus to embed media')
2024-06-24 17:50:27 +00:00
}),
Focus,
Gapcursor,
HardBreak,
Highlight.configure({
multicolor: true,
HTMLAttributes: {
2024-06-26 08:22:05 +00:00
class: 'highlight'
}
2024-06-24 17:50:27 +00:00
}),
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()
2024-06-26 08:22:05 +00:00
}
}
2024-06-24 17:50:27 +00:00
}),
BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef,
shouldShow: ({ editor: e, view, state }) => {
const { empty } = state.selection
return view.hasFocus() && !empty && e.isActive('blockquote')
2024-06-26 08:22:05 +00:00
}
2024-06-24 17:50:27 +00:00
}),
BubbleMenu.configure({
pluginKey: 'figureBubbleMenu',
element: figureBubbleMenuRef,
shouldShow: ({ editor: e, view, state }) => {
const { empty } = state.selection
return view.hasFocus() && !empty && e.isActive('figure')
2024-06-26 08:22:05 +00:00
}
2024-06-24 17:50:27 +00:00
}),
BubbleMenu.configure({
pluginKey: 'incutBubbleMenu',
element: incutBubbleMenuRef,
shouldShow: ({ editor: e, view, state }) => {
const { empty } = state.selection
return view.hasFocus() && !empty && e.isActive('figcaption')
2024-06-26 08:22:05 +00:00
}
2024-06-24 17:50:27 +00:00
}),
FloatingMenu.configure({
element: floatingMenuRef,
pluginKey: 'floatingMenu',
shouldShow: ({ editor: e, state }) => {
const { $anchor, empty } = state.selection
const isRootDepth = $anchor.depth === 1
2023-03-08 16:35:13 +00:00
2024-06-24 17:50:27 +00:00
if (!(isRootDepth && empty)) return false
2024-06-24 17:50:27 +00:00
return !(e.isActive('codeBlock') || e.isActive('heading'))
2024-06-26 08:22:05 +00:00
}
2024-06-24 17:50:27 +00:00
}),
TrailingNode,
2024-06-26 08:22:05 +00:00
Article
2024-06-24 17:50:27 +00:00
],
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())
}
}
},
2024-06-26 08:22:05 +00:00
content: initialContent
2024-06-24 17:50:27 +00:00
}))
2024-06-24 17:50:27 +00:00
if (freshEditor) {
editorElRef()?.addEventListener('focus', (_event) => {
if (freshEditor()?.isActive('figcaption')) {
freshEditor()?.commands.focus()
}
})
setEditor(freshEditor() as Editor)
}
}
2024-06-26 08:22:05 +00:00
})
2024-06-24 17:50:27 +00:00
)
onCleanup(() => {
2024-01-25 19:16:38 +00:00
editor()?.destroy()
})
2023-03-08 16:35:13 +00:00
return (
<>
2023-08-23 22:31:39 +00:00
<div class="row">
<div class="col-md-5" />
2023-08-23 22:31:39 +00:00
<div class="col-md-12">
2024-06-24 17:50:27 +00:00
<div ref={setEditorElRef} id="editorBody" />
2023-08-23 22:31:39 +00:00
</div>
</div>
2024-06-24 17:50:27 +00:00
<Show when={editor()}>
<TextBubbleMenu
shouldShow={shouldShowTextBubbleMenu()}
isCommonMarkup={isCommonMarkup()}
editor={editor() as Editor}
ref={(el) => (textBubbleMenuRef = el)}
/>
<BlockquoteBubbleMenu
ref={(el) => {
blockquoteBubbleMenuRef = el
}}
editor={editor() as Editor}
/>
<FigureBubbleMenu
editor={editor() as Editor}
ref={(el) => {
figureBubbleMenuRef = el
}}
/>
<IncutBubbleMenu
editor={editor() as Editor}
ref={(el) => {
incutBubbleMenuRef = el
}}
/>
<EditorFloatingMenu editor={editor() as Editor} ref={(el) => (floatingMenuRef = el)} />
</Show>
</>
2023-03-08 16:35:13 +00:00
)
}