2023-11-14 15:10:00 +00:00
|
|
|
import { HocuspocusProvider } from '@hocuspocus/provider'
|
2024-09-15 23:41:48 +00:00
|
|
|
import { Editor, EditorOptions, isTextSelection } from '@tiptap/core'
|
2023-03-08 16:35:13 +00:00
|
|
|
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
|
|
|
import { CharacterCount } from '@tiptap/extension-character-count'
|
2023-11-14 15:10:00 +00:00
|
|
|
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'
|
2024-06-24 17:50:27 +00:00
|
|
|
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
|
2023-11-14 15:10:00 +00:00
|
|
|
import uniqolor from 'uniqolor'
|
2024-09-19 16:51:56 +00:00
|
|
|
import { Doc, Transaction } from 'yjs'
|
2024-07-04 07:51:15 +00:00
|
|
|
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'
|
2024-09-15 16:41:02 +00:00
|
|
|
import { Author } from '~/graphql/schema/core.gen'
|
2024-09-24 06:48:39 +00:00
|
|
|
import { base, custom, extended } from '~/lib/editorExtensions'
|
2024-07-05 14:08:12 +00:00
|
|
|
import { handleImageUpload } from '~/lib/handleImageUpload'
|
2024-09-27 16:31:54 +00:00
|
|
|
import { renderUploadedImage } from '../Upload/renderUploadedImage'
|
2024-02-04 11:25:21 +00:00
|
|
|
import { BlockquoteBubbleMenu, FigureBubbleMenu, IncutBubbleMenu } from './BubbleMenu'
|
2024-09-27 18:09:50 +00:00
|
|
|
import { TextBubbleMenu } from './BubbleMenu/TextBubbleMenu'
|
2023-11-14 15:10:00 +00:00
|
|
|
import { EditorFloatingMenu } from './EditorFloatingMenu'
|
|
|
|
|
2024-09-27 18:09:50 +00:00
|
|
|
import './Editor.module.scss'
|
2023-03-08 16:35:13 +00:00
|
|
|
|
2024-09-16 00:09:07 +00:00
|
|
|
export type EditorComponentProps = {
|
2023-05-04 04:43:52 +00:00
|
|
|
shoutId: number
|
2023-03-08 16:35:13 +00:00
|
|
|
initialContent?: string
|
2023-03-23 17:15:50 +00:00
|
|
|
onChange: (text: string) => void
|
2024-09-15 23:41:48 +00:00
|
|
|
disableCollaboration?: boolean
|
2023-03-08 16:35:13 +00:00
|
|
|
}
|
|
|
|
|
2023-10-09 05:14:58 +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-10-09 05:14:58 +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
|
|
|
|
2024-09-16 00:09:07 +00:00
|
|
|
export const EditorComponent = (props: EditorComponentProps) => {
|
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)
|
2023-09-05 05:49:19 +00:00
|
|
|
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
|
2024-02-04 17:40:15 +00:00
|
|
|
const { showSnackbar } = useSnackbar()
|
2024-09-19 16:51:56 +00:00
|
|
|
const { createEditor, countWords, editor } = useEditorContext()
|
|
|
|
const [editorOptions, setEditorOptions] = createSignal<Partial<EditorOptions>>({})
|
2024-09-15 23:41:48 +00:00
|
|
|
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>()
|
2023-08-15 09:38:49 +00:00
|
|
|
|
2023-10-09 05:14:58 +00:00
|
|
|
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
|
2023-10-09 05:14:58 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
showSnackbar({ body: t('Uploading image') })
|
2024-09-15 16:41:02 +00:00
|
|
|
const image = await handleImageUpload(uplFile, session()?.access_token || '')
|
|
|
|
renderUploadedImage(editor() as Editor, image)
|
2023-10-09 05:14:58 +00:00
|
|
|
} catch (error) {
|
2023-10-09 09:14:41 +00:00
|
|
|
console.error('[Paste Image Error]:', error)
|
2023-10-09 05:14:58 +00:00
|
|
|
}
|
2024-09-19 16:51:56 +00:00
|
|
|
return false
|
2023-10-09 05:14:58 +00:00
|
|
|
}
|
|
|
|
|
2024-09-19 16:51:56 +00:00
|
|
|
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,
|
2024-09-24 06:48:39 +00:00
|
|
|
...custom,
|
|
|
|
...extended,
|
2024-09-15 23:41:48 +00:00
|
|
|
|
2024-09-19 16:51:56 +00:00
|
|
|
Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }),
|
|
|
|
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
|
2024-09-15 23:41:48 +00:00
|
|
|
|
2024-09-19 16:51:56 +00:00
|
|
|
// menus
|
2024-09-15 23:41:48 +00:00
|
|
|
|
2024-09-19 16:51:56 +00:00
|
|
|
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()
|
2024-09-15 23:41:48 +00:00
|
|
|
|
2024-09-19 16:51:56 +00:00
|
|
|
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'))
|
|
|
|
}
|
|
|
|
})
|
2024-09-15 23:41:48 +00:00
|
|
|
|
2024-09-19 16:51:56 +00:00
|
|
|
// 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()
|
2024-09-15 23:41:48 +00:00
|
|
|
|
2024-09-19 16:51:56 +00:00
|
|
|
// Trigger the onChange callback with the updated HTML
|
|
|
|
html && props.onChange(html)
|
2024-09-15 23:41:48 +00:00
|
|
|
|
2024-09-19 16:51:56 +00:00
|
|
|
// Get the word count from the editor's storage (using CharacterCount)
|
|
|
|
const wordCount = editor.storage.characterCount.words()
|
|
|
|
|
|
|
|
// Update the word count
|
|
|
|
wordCount && countWords(wordCount)
|
2024-09-15 23:41:48 +00:00
|
|
|
}
|
2024-09-19 16:51:56 +00:00
|
|
|
},
|
|
|
|
content: props.initialContent || ''
|
|
|
|
}
|
|
|
|
setEditorOptions(options as unknown as Partial<EditorOptions>)
|
|
|
|
createEditor(options as unknown as Partial<EditorOptions>)
|
|
|
|
}
|
|
|
|
})
|
2024-09-15 23:41:48 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
createEffect(
|
|
|
|
on(
|
|
|
|
[
|
2024-09-19 16:51:56 +00:00
|
|
|
editor,
|
2024-09-15 23:41:48 +00:00
|
|
|
() => !props.disableCollaboration,
|
|
|
|
() => `shout-${props.shoutId}`,
|
|
|
|
() => session()?.access_token || '',
|
|
|
|
author
|
|
|
|
],
|
2024-09-19 16:51:56 +00:00
|
|
|
([e, collab, docName, token, profile]) => {
|
|
|
|
if (!e) return
|
|
|
|
|
2024-09-15 23:41:48 +00:00
|
|
|
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 &&
|
2024-09-19 16:51:56 +00:00
|
|
|
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 }
|
|
|
|
})
|
|
|
|
]
|
|
|
|
})
|
2024-09-15 23:41:48 +00:00
|
|
|
}
|
|
|
|
)
|
|
|
|
)
|
2023-05-12 13:03:46 +00:00
|
|
|
|
2024-09-15 23:41:48 +00:00
|
|
|
createEffect(
|
|
|
|
on(editorElRef, (ee: HTMLElement | undefined) => {
|
|
|
|
ee?.addEventListener('focus', (_event) => {
|
|
|
|
if (editor()?.isActive('figcaption')) {
|
|
|
|
editor()?.commands.focus()
|
2024-06-24 17:50:27 +00:00
|
|
|
}
|
2024-09-15 23:41:48 +00:00
|
|
|
})
|
2024-06-26 08:22:05 +00:00
|
|
|
})
|
2024-06-24 17:50:27 +00:00
|
|
|
)
|
2023-04-26 02:37:29 +00:00
|
|
|
|
2023-09-05 05:49:19 +00:00
|
|
|
onCleanup(() => {
|
2024-01-25 19:16:38 +00:00
|
|
|
editor()?.destroy()
|
2023-09-05 05:49:19 +00:00
|
|
|
})
|
|
|
|
|
2023-03-08 16:35:13 +00:00
|
|
|
return (
|
2023-08-01 10:26:45 +00:00
|
|
|
<>
|
2023-08-23 22:31:39 +00:00
|
|
|
<div class="row">
|
2023-08-31 09:11:32 +00:00
|
|
|
<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}
|
2024-09-15 23:41:48 +00:00
|
|
|
ref={setTextBubbleMenuRef}
|
2024-06-24 17:50:27 +00:00
|
|
|
/>
|
2024-09-15 23:41:48 +00:00
|
|
|
<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} />
|
2024-06-24 17:50:27 +00:00
|
|
|
</Show>
|
2023-08-01 10:26:45 +00:00
|
|
|
</>
|
2023-03-08 16:35:13 +00:00
|
|
|
)
|
|
|
|
}
|