webapp/src/components/Editor/Editor.tsx

280 lines
7.8 KiB
TypeScript
Raw Normal View History

import { createEffect, createSignal, Show } from 'solid-js'
2023-03-23 17:15:50 +00:00
import { createTiptapEditor, useEditorHTML } from 'solid-tiptap'
import uniqolor from 'uniqolor'
import * as Y from 'yjs'
import type { Doc } from 'yjs/dist/src/utils/Doc'
2023-03-08 16:35:13 +00:00
import { Bold } from '@tiptap/extension-bold'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { Dropcursor } from '@tiptap/extension-dropcursor'
import { Italic } from '@tiptap/extension-italic'
import { Strike } from '@tiptap/extension-strike'
import { HorizontalRule } from '@tiptap/extension-horizontal-rule'
import { Underline } from '@tiptap/extension-underline'
import { FloatingMenu } from '@tiptap/extension-floating-menu'
import { BulletList } from '@tiptap/extension-bullet-list'
import { OrderedList } from '@tiptap/extension-ordered-list'
import { ListItem } from '@tiptap/extension-list-item'
import { CharacterCount } from '@tiptap/extension-character-count'
import { Placeholder } from '@tiptap/extension-placeholder'
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 { Link } from '@tiptap/extension-link'
import { Document } from '@tiptap/extension-document'
import { Text } from '@tiptap/extension-text'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
import { isTextSelection } from '@tiptap/core'
2023-03-08 16:35:13 +00:00
import { Paragraph } from '@tiptap/extension-paragraph'
import Focus from '@tiptap/extension-focus'
2023-03-29 08:51:27 +00:00
import { Collaboration } from '@tiptap/extension-collaboration'
2023-03-29 15:36:12 +00:00
import { HocuspocusProvider } from '@hocuspocus/provider'
import { CustomImage } from './extensions/CustomImage'
import { CustomBlockquote } from './extensions/CustomBlockquote'
import { Figure } from './extensions/Figure'
2023-05-06 12:38:22 +00:00
import { Embed } from './extensions/Embed'
import { useSession } from '../../context/session'
import { useLocalize } from '../../context/localize'
import { useEditorContext } from '../../context/editor'
import { TrailingNode } from './extensions/TrailingNode'
import Article from './extensions/Article'
2023-05-04 12:16:39 +00:00
import { TextBubbleMenu } from './TextBubbleMenu'
import { FigureBubbleMenu, BlockquoteBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
import { EditorFloatingMenu } from './EditorFloatingMenu'
import { TableOfContents } from '../TableOfContents'
import { isDesktop } from '../../utils/media-query'
2023-05-07 13:47:10 +00:00
import './Prosemirror.scss'
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
}
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
2023-07-24 14:09:04 +00:00
export const Editor = (props: Props) => {
2023-03-08 16:35:13 +00:00
const { t } = useLocalize()
2023-03-29 08:51:27 +00:00
const { user } = useSession()
2023-05-11 11:43:14 +00:00
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
2023-03-29 08:51:27 +00:00
const docName = `shout-${props.shoutId}`
2023-03-29 08:51:27 +00:00
2023-05-05 20:05:50 +00:00
if (!yDocs[docName]) {
yDocs[docName] = new Y.Doc()
}
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,
2023-05-05 20:05:50 +00:00
document: yDocs[docName]
2023-03-29 15:36:12 +00:00
})
2023-03-29 10:14:39 +00:00
}
2023-03-08 16:35:13 +00:00
const editorElRef: {
current: HTMLDivElement
} = {
current: null
}
2023-05-04 12:16:39 +00:00
const textBubbleMenuRef: {
current: HTMLDivElement
} = {
current: null
}
const incutBubbleMenuRef: {
current: HTMLElement
} = {
current: null
}
const figureBubbleMenuRef: {
current: HTMLElement
} = {
current: null
}
const blockquoteBubbleMenuRef: {
current: HTMLElement
2023-03-08 16:35:13 +00:00
} = {
current: null
}
const floatingMenuRef: {
current: HTMLDivElement
} = {
current: null
}
const { initialContent } = props
2023-03-08 16:35:13 +00:00
const editor = createTiptapEditor(() => ({
element: editorElRef.current,
2023-07-24 14:09:04 +00:00
editorProps: {
attributes: {
class: 'articleEditor'
}
},
2023-03-08 16:35:13 +00:00
extensions: [
Document,
Text,
Paragraph,
Dropcursor,
CustomBlockquote,
2023-03-08 16:35:13 +00:00
Bold,
Italic,
Strike,
2023-05-04 17:38:50 +00:00
HorizontalRule.configure({
HTMLAttributes: {
class: 'horizontalRule'
}
}),
2023-03-08 16:35:13 +00:00
Underline,
2023-03-20 09:19:14 +00:00
Link.configure({
openOnClick: false
}),
2023-03-22 07:47:51 +00:00
Heading.configure({
levels: [2, 3, 4]
2023-03-22 07:47:51 +00:00
}),
2023-03-08 16:35:13 +00:00
BulletList,
OrderedList,
ListItem,
2023-03-29 08:51:27 +00:00
Collaboration.configure({
2023-05-05 20:05:50 +00:00
document: yDocs[docName]
2023-03-29 08:51:27 +00:00
}),
2023-03-29 10:14:39 +00:00
CollaborationCursor.configure({
provider: providers[docName],
user: {
name: user().name,
2023-03-29 10:28:40 +00:00
color: uniqolor(user().slug).color
2023-03-29 10:14:39 +00:00
}
}),
2023-03-08 16:35:13 +00:00
Placeholder.configure({
placeholder: t('Short opening')
}),
Focus,
Gapcursor,
HardBreak,
2023-05-09 17:31:28 +00:00
Highlight.configure({
multicolor: true,
HTMLAttributes: {
class: 'highlight'
}
}),
2023-05-06 12:38:22 +00:00
CustomImage.configure({
HTMLAttributes: {
class: 'uploadedImage'
}
}),
2023-05-07 13:16:03 +00:00
Figure,
Embed,
CharacterCount,
2023-04-20 13:58:56 +00:00
BubbleMenu.configure({
2023-05-04 12:16:39 +00:00
pluginKey: 'textBubbleMenu',
element: textBubbleMenuRef.current,
2023-05-07 13:47:10 +00:00
shouldShow: ({ editor: e, view, state, from, to }) => {
2023-05-04 15:09:09 +00:00
const { doc, selection } = state
const { empty } = selection
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
2023-05-11 11:43:14 +00:00
setIsCommonMarkup(e.isActive('figure'))
2023-05-29 17:14:58 +00:00
return view.hasFocus() && !empty && !isEmptyTextBlock && !e.isActive('image')
2023-07-18 11:21:55 +00:00
},
tippyOptions: {
sticky: true
}
}),
BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef.current,
2023-05-29 17:14:58 +00:00
shouldShow: ({ editor: e, state }) => {
const { selection } = state
const { empty } = selection
return empty && e.isActive('blockquote')
}
}),
BubbleMenu.configure({
pluginKey: 'incutBubbleMenu',
element: incutBubbleMenuRef.current,
2023-05-29 17:14:58 +00:00
shouldShow: ({ editor: e, state }) => {
const { selection } = state
const { empty } = selection
return empty && e.isActive('article')
2023-05-04 12:16:39 +00:00
}
}),
BubbleMenu.configure({
pluginKey: 'imageBubbleMenu',
element: figureBubbleMenuRef.current,
2023-05-07 13:47:10 +00:00
shouldShow: ({ editor: e, view }) => {
2023-05-04 15:09:09 +00:00
return view.hasFocus() && e.isActive('image')
2023-05-04 12:16:39 +00:00
}
2023-04-20 13:58:56 +00:00
}),
FloatingMenu.configure({
tippyOptions: {
placement: 'left'
},
element: floatingMenuRef.current
2023-05-08 17:21:06 +00:00
}),
TrailingNode,
Article
],
content: initialContent ?? null
2023-03-08 16:35:13 +00:00
}))
const {
actions: { countWords, setEditor }
} = useEditorContext()
setEditor(editor)
const html = useEditorHTML(() => editor())
createEffect(() => {
props.onChange(html())
if (html()) {
countWords({
characters: editor().storage.characterCount.characters(),
words: editor().storage.characterCount.words()
})
}
})
2023-03-08 16:35:13 +00:00
return (
<>
<div ref={(el) => (editorElRef.current = el)} id="editorBody" />
<Show when={isDesktop() && html()}>
<TableOfContents variant="editor" parentSelector="#editorBody" body={html()} />
</Show>
2023-05-11 11:43:14 +00:00
<TextBubbleMenu
isCommonMarkup={isCommonMarkup()}
editor={editor()}
ref={(el) => (textBubbleMenuRef.current = el)}
/>
<BlockquoteBubbleMenu
ref={(el) => {
blockquoteBubbleMenuRef.current = el
}}
editor={editor()}
/>
<FigureBubbleMenu
editor={editor()}
ref={(el) => {
figureBubbleMenuRef.current = el
}}
/>
<IncutBubbleMenu
editor={editor()}
ref={(el) => {
incutBubbleMenuRef.current = el
}}
/>
2023-03-08 16:35:13 +00:00
<EditorFloatingMenu editor={editor()} ref={(el) => (floatingMenuRef.current = el)} />
</>
2023-03-08 16:35:13 +00:00
)
}