webapp/src/components/Editor/Editor.tsx

342 lines
12 KiB
TypeScript
Raw Normal View History

import { HocuspocusProvider } from '@hocuspocus/provider'
2024-10-08 19:50:58 +00:00
import { UploadFile } from '@solid-primitives/upload'
2024-10-09 10:40:20 +00:00
import { Editor, EditorOptions } 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'
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-10-08 19:50:58 +00:00
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js'
2024-10-09 10:40:20 +00:00
import { isServer } from 'solid-js/web'
2024-10-08 19:50:58 +00:00
import { createTiptapEditor } from 'solid-tiptap'
import uniqolor from 'uniqolor'
2024-10-08 19:50:58 +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'
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-10-01 19:52:45 +00:00
import { allowedImageTypes, renderUploadedImage } from '../Upload/renderUploadedImage'
2024-10-09 10:40:20 +00:00
import { Panel } from './Panel/Panel'
2024-10-01 19:39:17 +00:00
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'
2024-10-09 10:40:20 +00:00
import './Prosemirror.scss'
2023-03-08 16:35:13 +00:00
2024-09-16 00:09:07 +00:00
export type EditorComponentProps = {
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-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-10-08 19:50:58 +00:00
const { session, requireAuthentication } = useSession()
2024-06-24 17:50:27 +00:00
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
2024-10-09 10:40:20 +00:00
const [isCommonMarkup, _setIsCommonMarkup] = createSignal(false)
const createMenuSignal = () => createSignal(false)
const [shouldShowTextBubbleMenu, _setShouldShowTextBubbleMenu] = createMenuSignal()
const [shouldShowBlockquoteBubbleMenu, _setShouldShowBlockquoteBubbleMenu] = createMenuSignal()
const [shouldShowFigureBubbleMenu, _setShouldShowFigureBubbleMenu] = createMenuSignal()
const [shouldShowIncutBubbleMenu, _setShouldShowIncutBubbleMenu] = createMenuSignal()
const [shouldShowFloatingMenu, _setShouldShowFloatingMenu] = createMenuSignal()
2024-02-04 17:40:15 +00:00
const { showSnackbar } = useSnackbar()
2024-10-08 19:50:58 +00:00
const { countWords, setEditing } = useEditorContext()
2024-09-19 16:51:56 +00:00
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>()
2024-10-08 19:50:58 +00:00
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
2024-09-15 23:41:48 +00:00
const [floatingMenuRef, setFloatingMenuRef] = createSignal<HTMLDivElement | undefined>()
2024-10-08 19:50:58 +00:00
const [editor, setEditor] = createSignal<Editor | null>(null)
const [menusInitialized, setMenusInitialized] = createSignal(false)
// store tiptap editor in context provider's signal to use it in Panel
createEffect(() => setEditing(editor() || undefined))
/**
* Создает экземпляр редактора с заданными опциями
* @param opts Опции редактора
*/
const createEditorInstance = (opts?: Partial<EditorOptions>) => {
if (!opts?.element) {
console.error('Editor options or element is missing')
return
}
console.log('stage 2: create editor instance without menus', opts)
2024-10-09 10:40:20 +00:00
const old = editor() || { options: {} as EditorOptions }
const uniqueExtensions = [
...new Map(
[...(old?.options?.extensions || []), ...(opts?.extensions || [])].map((ext) => [ext.name, ext])
).values()
]
2024-10-08 19:50:58 +00:00
const fresh = createTiptapEditor(() => ({
...old?.options,
...opts,
2024-10-09 10:40:20 +00:00
element: opts.element as HTMLElement,
extensions: uniqueExtensions
2024-10-08 19:50:58 +00:00
}))
if (old instanceof Editor) old?.destroy()
setEditor(fresh() || null)
}
const handleClipboardPaste = async () => {
try {
2024-10-08 19:50:58 +00:00
const clipboardItems: ClipboardItems = await navigator.clipboard.read()
if (clipboardItems.length === 0) return
const [clipboardItem] = clipboardItems
const { types } = clipboardItem
2024-10-08 19:50:58 +00:00
const imageType: string | undefined = 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}`)
2024-10-08 19:50:58 +00:00
const uplFile: UploadFile = {
source: blob.toString(),
name: file.name,
size: file.size,
2024-06-26 08:22:05 +00:00
file
}
showSnackbar({ body: t('Uploading image') })
2024-10-08 19:50:58 +00:00
const image: { url: string; originalFilename?: string } = await handleImageUpload(
uplFile,
session()?.access_token || ''
)
2024-09-15 16:41:02 +00:00
renderUploadedImage(editor() as Editor, image)
} catch (error) {
console.error('[Paste Image Error]:', error)
}
2024-09-19 16:51:56 +00:00
return false
}
2024-10-08 19:50:58 +00:00
// stage 0: update editor options
const setupEditor = () => {
console.log('stage 0: update editor options')
const options: Partial<EditorOptions> = {
element: editorElRef()!,
editorProps: {
attributes: { class: 'articleEditor' },
transformPastedHTML: (c: string) => c.replaceAll(/<img.*?>/g, ''),
handlePaste: (_view, _event, _slice) => {
handleClipboardPaste().then((result) => result)
return false
2024-09-19 16:51:56 +00:00
}
2024-10-08 19:50:58 +00:00
},
extensions: [
...base,
...custom,
...extended,
Placeholder.configure({
placeholder: t('Add a link or click plus to embed media')
}),
CharacterCount.configure()
],
onTransaction({ transaction, editor }) {
if (transaction.docChanged) {
const html = editor.getHTML()
html && props.onChange(html)
const wordCount: number = editor.storage.characterCount.words()
const charsCount: number = editor.storage.characterCount.characters()
wordCount && countWords({ words: wordCount, characters: charsCount })
}
},
content: props.initialContent ?? null
}
2024-10-09 10:40:20 +00:00
console.log(options)
2024-10-08 19:50:58 +00:00
setEditorOptions(() => options)
}
// stage 1: create editor options when got author profile
createEffect(
on([editorOptions, author], ([opts, a]: [Partial<EditorOptions> | undefined, Author | undefined]) => {
if (isServer) return
console.log('stage 1: create editor options when got author profile', { opts, a })
const noOptions = !opts || Object.keys(opts).length === 0
noOptions && a && setTimeout(setupEditor, 1)
2024-09-19 16:51:56 +00:00
})
2024-09-15 23:41:48 +00:00
)
2024-10-08 19:50:58 +00:00
// Перенос всех эффектов, зависящих от editor, внутрь onMount
onMount(() => {
console.log('Editor component mounted')
editorElRef()?.addEventListener('focus', handleFocus)
requireAuthentication(() => {
setTimeout(() => {
setupEditor()
2024-09-19 16:51:56 +00:00
2024-10-08 19:50:58 +00:00
// Создаем экземпляр редактора после монтирования
createEditorInstance(editorOptions())
// Инициализируем меню после создания редактора
if (editor()) {
initializeMenus()
2024-09-15 23:41:48 +00:00
}
2024-10-08 19:50:58 +00:00
}, 1200)
}, 'edit')
})
2024-09-15 23:41:48 +00:00
2024-10-08 19:50:58 +00:00
const initializeMenus = () => {
if (menusInitialized() || !editor()) return
2024-10-09 10:40:20 +00:00
if (blockquoteBubbleMenuRef() && figureBubbleMenuRef() && incutBubbleMenuRef() && floatingMenuRef()) {
console.log('stage 3: initialize menus when editor instance is ready')
const menuConfigs = [
{ key: 'textBubbleMenu', ref: textBubbleMenuRef, shouldShow: shouldShowTextBubbleMenu },
{
key: 'blockquoteBubbleMenu',
ref: blockquoteBubbleMenuRef,
shouldShow: shouldShowBlockquoteBubbleMenu
},
{ key: 'figureBubbleMenu', ref: figureBubbleMenuRef, shouldShow: shouldShowFigureBubbleMenu },
{ key: 'incutBubbleMenu', ref: incutBubbleMenuRef, shouldShow: shouldShowIncutBubbleMenu },
{ key: 'floatingMenu', ref: floatingMenuRef, shouldShow: shouldShowFloatingMenu, isFloating: true }
2024-10-08 19:50:58 +00:00
]
2024-10-09 10:40:20 +00:00
const menus = menuConfigs.map((config) =>
config.isFloating
? FloatingMenu.configure({
pluginKey: config.key,
element: config.ref(),
shouldShow: config.shouldShow
})
: BubbleMenu.configure({
pluginKey: config.key,
element: config.ref(),
shouldShow: config.shouldShow
})
)
setEditorOptions((prev) => ({ ...prev, extensions: [...(prev.extensions || []), ...menus] }))
2024-10-08 19:50:58 +00:00
setMenusInitialized(true)
} else {
console.error('Some menu references are missing')
}
}
const initializeCollaboration = () => {
if (!editor()) {
console.error('Editor is not initialized')
return
}
try {
const docName = `shout-${props.shoutId}`
const token = session()?.access_token || ''
const profile = author()
if (!(token && profile)) {
throw new Error('Missing authentication data')
2024-09-15 23:41:48 +00:00
}
2024-10-08 19:50:58 +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
})
console.log(`HocuspocusProvider установлен для ${docName}`)
}
setEditorOptions((prev: Partial<EditorOptions>) => {
const extensions = [...(prev.extensions || [])]
2024-10-09 10:40:20 +00:00
if (props.disableCollaboration) {
// Remove collaboration extensions if they exist
const filteredExtensions = extensions.filter(
(ext) => ext.name !== 'collaboration' && ext.name !== 'collaborationCursor'
)
return { ...prev, extensions: filteredExtensions }
}
2024-10-08 19:50:58 +00:00
extensions.push(
Collaboration.configure({ document: yDocs[docName] }),
CollaborationCursor.configure({
provider: providers[docName],
user: { name: profile.name, color: uniqolor(profile.slug).color }
})
)
console.log('collab extensions added:', extensions)
return { ...prev, extensions }
2024-09-15 23:41:48 +00:00
})
2024-10-08 19:50:58 +00:00
} catch (error) {
console.error('Error initializing collaboration:', error)
showSnackbar({ body: t('Failed to initialize collaboration') })
}
}
const handleFocus = (event: FocusEvent) => {
console.log('handling focus event', event)
if (editor()?.isActive('figcaption')) {
editor()?.commands.focus()
console.log('active figcaption detected, focusing editor')
}
}
2024-10-09 10:40:20 +00:00
// Инициализируем коллаборацию если необходимо
createEffect(
on(
() => props.disableCollaboration,
() => {
initializeCollaboration()
},
{ defer: true }
)
)
onCleanup(() => {
2024-10-08 19:50:58 +00:00
editorElRef()?.removeEventListener('focus', handleFocus)
2024-01-25 19:16:38 +00:00
editor()?.destroy()
})
2023-03-08 16:35:13 +00:00
return (
<>
2024-10-08 19:50:58 +00:00
<div>
<Show when={editor()} keyed>
{(ed: Editor) => (
<>
<TextBubbleMenu
shouldShow={shouldShowTextBubbleMenu()}
isCommonMarkup={isCommonMarkup()}
editor={ed}
ref={setTextBubbleMenuRef}
/>
<BlockquoteBubbleMenu editor={ed} ref={setBlockquoteBubbleMenuRef} />
<FigureBubbleMenu editor={ed} ref={setFigureBubbleMenuRef} />
<IncutBubbleMenu editor={ed} ref={setIncutBubbleMenuRef} />
<EditorFloatingMenu editor={ed} ref={setFloatingMenuRef} />
</>
)}
</Show>
</div>
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-10-08 19:50:58 +00:00
<Show when={props.shoutId}>
<Panel shoutId={props.shoutId} />
2024-06-24 17:50:27 +00:00
</Show>
</>
2023-03-08 16:35:13 +00:00
)
}