bubble-conditions-wip
Some checks failed
deploy / testbuild (push) Failing after 43s
deploy / Update templates on Mailgun (push) Has been skipped

This commit is contained in:
Untone 2024-10-11 21:34:57 +03:00
parent 0857ca8775
commit ffe0ede835
5 changed files with 474 additions and 595 deletions

View File

@ -1,18 +1,21 @@
.articleEditor { .ProseMirror {
font-size: 1.6rem; font-size: 1.6rem;
outline: none; outline: none;
min-height: 300px; min-height: 300px;
}
p.is-editor-empty:first-child::before { .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder); content: attr(data-placeholder);
float: left; float: left;
height: 0; height: 0;
pointer-events: none; pointer-events: none;
opacity: 0.3; opacity: 0.3;
} }
/* Give a remote user a caret */ // Keeping the cursor active when moving outside the editable area
.collaboration-cursor__caret {
/* Give a remote user a caret */
.collaboration-cursor__caret {
border-left: 1px solid #0d0d0d; border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d; border-right: 1px solid #0d0d0d;
margin-left: -1px; margin-left: -1px;
@ -20,11 +23,10 @@
pointer-events: none; pointer-events: none;
position: relative; position: relative;
word-break: normal; word-break: normal;
} }
/* Render the username above the caret */
/* Render the username above the caret */ .collaboration-cursor__label {
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0; border-radius: 3px 3px 3px 0;
color: #0d0d0d; color: #0d0d0d;
font-size: 12px; font-size: 12px;
@ -37,9 +39,9 @@
top: -1.4em; top: -1.4em;
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
} }
.embed-wrapper { .embed-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -51,20 +53,20 @@
border: none; border: none;
overflow: hidden; overflow: hidden;
} }
} }
.horizontalRule { .horizontalRule {
border-top: 2px solid #000; border-top: 2px solid #000;
} }
mark.highlight { mark.highlight {
box-decoration-break: clone; box-decoration-break: clone;
padding: 0.2em 0; padding: 0.2em 0;
} }
// custom atibutes fro TipTap Nodes // custom atibutes fro TipTap Nodes
@include media-breakpoint-up(sm) { @include media-breakpoint-up(sm) {
[data-float] { [data-float] {
max-width: 50%; max-width: 50%;
} }
@ -104,9 +106,9 @@
min-width: 30%; min-width: 30%;
} }
} }
} }
blockquote, .blockquote { .ProseMirror blockquote {
p:last-child { p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
@ -160,9 +162,9 @@
} }
} }
} }
} }
article[data-type='incut'] { .ProseMirror article[data-type='incut'] {
background: #f1f2f3; background: #f1f2f3;
font-size: 1.4rem; font-size: 1.4rem;
margin: 1em -1rem; margin: 1em -1rem;
@ -255,9 +257,15 @@
background: #fff; background: #fff;
box-shadow: 0 0 0 1px #000; box-shadow: 0 0 0 1px #000;
} }
} }
figure[data-type='figure'] { .ProseMirror-hideselection figure[data-type='figure'] {
& > figcaption {
--selection-color: rgb(0 0 0 / 60%);
}
}
figure[data-type='figure'] {
width: 100% !important; width: 100% !important;
.iframe-wrapper { .iframe-wrapper {
@ -271,10 +279,10 @@
width: 100%; width: 100%;
} }
} }
} }
/* stylelint-disable-next-line selector-type-no-unknown */ /* stylelint-disable-next-line selector-type-no-unknown */
footnote, .footnote { footnote {
display: inline-flex; display: inline-flex;
position: relative; position: relative;
cursor: pointer; cursor: pointer;
@ -296,18 +304,11 @@
&:hover { &:hover {
background-color: unset; background-color: unset;
} }
} }
.highlight-fake-selection { .highlight-fake-selection {
background: var(--selection-background); background: var(--selection-background);
color: var(--selection-color); color: var(--selection-color);
border: solid var(--selection-background); border: solid var(--selection-background);
border-width: 0; border-width: 0;
}
&.ProseMirror-hideselection figure[data-type='figure'] {
&>figcaption {
--selection-color: rgb(0 0 0 / 60%);
}
}
} }

View File

@ -1,134 +1,60 @@
import { HocuspocusProvider } from '@hocuspocus/provider' import { Editor, isTextSelection } from '@tiptap/core'
import { UploadFile } from '@solid-primitives/upload'
import { Editor, EditorOptions } from '@tiptap/core'
import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { CharacterCount } from '@tiptap/extension-character-count' 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 { FloatingMenu } from '@tiptap/extension-floating-menu'
import { Link } from '@tiptap/extension-link'
import { Placeholder } from '@tiptap/extension-placeholder' import { Placeholder } from '@tiptap/extension-placeholder'
import { Accessor, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js' import { createEffect, createSignal, onCleanup } from 'solid-js'
import { isServer } from 'solid-js/web' import { createTiptapEditor } from 'solid-tiptap'
import { createEditorTransaction, createTiptapEditor } from 'solid-tiptap'
import uniqolor from 'uniqolor'
import { Doc } from 'yjs'
import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session'
import { useSnackbar } from '~/context/ui' import { useSnackbar } from '~/context/ui'
import { Author } from '~/graphql/schema/core.gen'
import { base, custom, extended } from '~/lib/editorExtensions' import { base, custom, extended } from '~/lib/editorExtensions'
import { handleImageUpload } from '~/lib/handleImageUpload' import { handleClipboardPaste } from '~/lib/handleImageUpload'
import { allowedImageTypes, renderUploadedImage } from '../Upload/renderUploadedImage' import { useEditorContext } from '../../context/editor'
import { useLocalize } from '../../context/localize'
import { useSession } from '../../context/session'
import { BlockquoteBubbleMenu } from './Toolbar/BlockquoteBubbleMenu' import { BlockquoteBubbleMenu } from './Toolbar/BlockquoteBubbleMenu'
import { EditorFloatingMenu } from './Toolbar/EditorFloatingMenu' import { EditorFloatingMenu } from './Toolbar/EditorFloatingMenu'
import { FigureBubbleMenu } from './Toolbar/FigureBubbleMenu' import { FigureBubbleMenu } from './Toolbar/FigureBubbleMenu'
import { FullBubbleMenu } from './Toolbar/FullBubbleMenu' import { FullBubbleMenu } from './Toolbar/FullBubbleMenu'
import { IncutBubbleMenu } from './Toolbar/IncutBubbleMenu' import { IncutBubbleMenu } from './Toolbar/IncutBubbleMenu'
import { ArticleNode } from './extensions/Article'
import { TrailingNode } from './extensions/TrailingNode'
import styles from './Editor.module.scss' import './Editor.module.scss'
export type EditorComponentProps = { type Props = {
shoutId: number shoutId: number
initialContent?: string initialContent?: string
onChange: (text: string) => void onChange: (text: string) => void
} }
const yDocs: Record<string, Doc> = {} export const EditorComponent = (props: Props) => {
const providers: Record<string, HocuspocusProvider> = {}
export const EditorComponent = (props: EditorComponentProps) => {
const { t } = useLocalize() const { t } = useLocalize()
const { session, requireAuthentication } = useSession() const { session } = useSession()
const author = createMemo<Author>(() => session()?.user?.app_data?.profile as Author)
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const { countWords, setEditing, isCollabMode } = useEditorContext() const { countWords, setEditing } = useEditorContext()
const [editorOptions, setEditorOptions] = createSignal<Partial<EditorOptions>>({}) const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>() const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>()
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLDivElement | undefined>() const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLDivElement | undefined>() const [figureBubbleMenuRef, setFigureBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLDivElement | undefined>() const [blockquoteBubbleMenuRef, setBlockquoteBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [floatingMenuRef, setFloatingMenuRef] = createSignal<HTMLDivElement | undefined>() const [floatingMenuRef, setFloatingMenuRef] = createSignal<HTMLDivElement | undefined>()
const [fullBubbleMenuRef, setFullBubbleMenuRef] = createSignal<HTMLDivElement | undefined>() const [textBubbleMenuRef, setFullBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [editor, setEditor] = createSignal<Editor | null>(null)
const [menusInitialized, setMenusInitialized] = createSignal(false)
const [shouldShowFullBubbleMenu, setShouldShowFullBubbleMenu] = createSignal(false)
// store tiptap editor in context provider's signal to use it in Panel const editor = createTiptapEditor(() => ({
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)
const old = editor() || { options: {} as EditorOptions }
const uniqueExtensions = [
...new Map(
[...(old?.options?.extensions || []), ...(opts?.extensions || [])].map((ext) => [ext.name, ext])
).values()
]
const fresh = createTiptapEditor(() => ({
...old?.options,
...opts,
element: opts.element as HTMLElement,
extensions: uniqueExtensions
}))
if (old instanceof Editor) old?.destroy()
setEditor(fresh() || null)
}
const handleClipboardPaste = async () => {
try {
const clipboardItems: ClipboardItems = await navigator.clipboard.read()
if (clipboardItems.length === 0) return
const [clipboardItem] = clipboardItems
const { types } = clipboardItem
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}`)
const uplFile: UploadFile = {
source: blob.toString(),
name: file.name,
size: file.size,
file
}
showSnackbar({ body: t('Uploading image') })
const image: { url: string; originalFilename?: string } = await handleImageUpload(
uplFile,
session()?.access_token || ''
)
renderUploadedImage(editor() as Editor, image)
} catch (error) {
console.error('[Paste Image Error]:', error)
}
return false
}
// stage 0: update editor options
const setupEditor = () => {
console.log('stage 0: update editor options')
const options: Partial<EditorOptions> = {
element: editorElRef()!, element: editorElRef()!,
editorProps: { editorProps: {
attributes: { class: styles.articleEditor }, attributes: {
transformPastedHTML: (c: string) => c.replaceAll(/<img.*?>/g, ''), class: 'articleEditor'
},
transformPastedHTML(html) {
return html.replaceAll(/<img.*?>/g, '')
},
handlePaste: () => { handlePaste: () => {
handleClipboardPaste().then((_) => 0) showSnackbar({ body: t('Uploading image') })
handleClipboardPaste(editor(), session()?.access_token || '').then(() => false)
return false
} }
}, },
extensions: [ extensions: [
@ -138,200 +64,104 @@ export const EditorComponent = (props: EditorComponentProps) => {
Placeholder.configure({ Placeholder.configure({
placeholder: t('Add a link or click plus to embed media') placeholder: t('Add a link or click plus to embed media')
}), }),
CharacterCount.configure() CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
],
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
}
console.log(options)
setEditorOptions(() => options)
return 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)
})
)
const isFigcaptionActive = createEditorTransaction(editor as Accessor<Editor | undefined>, (e) =>
e?.isActive('figcaption')
)
createEffect(() => setIsCommonMarkup(!!isFigcaptionActive()))
const initializeMenus = () => {
if (menusInitialized() || !editor()) return
if (
blockquoteBubbleMenuRef() &&
figureBubbleMenuRef() &&
incutBubbleMenuRef() &&
floatingMenuRef() &&
fullBubbleMenuRef()
) {
console.log('stage 3: initialize menus when editor instance is ready')
const menus = [
BubbleMenu.configure({ BubbleMenu.configure({
element: fullBubbleMenuRef()!, pluginKey: 'textBubbleMenu',
pluginKey: 'fullBubbleMenu', element: textBubbleMenuRef()!,
shouldShow: ({ editor: e, state: { selection } }) => { shouldShow: ({ editor: e, view, state: { doc, selection } , from, to }) => {
const { empty, from, to } = selection const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
const hasSelection = !empty && from !== to if (isEmptyTextBlock) {
const shouldShow = e.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
e.view.hasFocus() && hasSelection && !e.isActive('image') && !e.isActive('figure') }
setShouldShowFullBubbleMenu(shouldShow) setIsCommonMarkup(e.isActive('figcaption'))
return shouldShow 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: { tippyOptions: {
duration: 200, sticky: true
placement: 'top'
} }
}), }),
BubbleMenu.configure({ BubbleMenu.configure({
pluginKey: 'blockquoteBubbleMenu', pluginKey: 'blockquoteBubbleMenu',
element: blockquoteBubbleMenuRef()!, element: blockquoteBubbleMenuRef()!,
shouldShow: ({ editor: e, state: { selection } }) => shouldShow: ({ editor: e, state }) => {
e.isFocused && !selection.empty && e.isActive('blockquote'), const { selection } = state
const { empty } = selection
return empty && e.isActive('blockquote')
},
tippyOptions: { tippyOptions: {
offset: [0, 0], offset: [0, 0],
placement: 'top', placement: 'top',
getReferenceClientRect: () => { getReferenceClientRect: (): DOMRect => {
const selectedElement = editor()?.view.dom.querySelector('.has-focus') const selectedElement = editor()?.view.dom.querySelector('.has-focus') as HTMLElement | null
return selectedElement?.getBoundingClientRect() || new DOMRect() if (selectedElement) {
return selectedElement.getBoundingClientRect()
}
return new DOMRect()
} }
} }
}), }),
BubbleMenu.configure({
pluginKey: 'figureBubbleMenu',
element: figureBubbleMenuRef()!,
shouldShow: ({ editor: e, view }) => view.hasFocus() && e.isActive('figure')
}),
BubbleMenu.configure({ BubbleMenu.configure({
pluginKey: 'incutBubbleMenu', pluginKey: 'incutBubbleMenu',
element: incutBubbleMenuRef()!, element: incutBubbleMenuRef()!,
shouldShow: ({ editor: e, state: { selection } }) => shouldShow: ({ editor: e, state }) => {
e.isFocused && !selection.empty && e.isActive('figcaption'), const { selection } = state
const { empty } = selection
return empty && e.isActive('article')
},
tippyOptions: { tippyOptions: {
offset: [0, -16], offset: [0, -16],
placement: 'top', placement: 'top',
getReferenceClientRect: () => { getReferenceClientRect: (): DOMRect => {
const selectedElement = editor()?.view.dom.querySelector('.has-focus') const selectedElement = editor()?.view.dom.querySelector('.has-focus') as HTMLElement | null
return selectedElement?.getBoundingClientRect() || new DOMRect() if (selectedElement) {
return selectedElement.getBoundingClientRect()
}
return new DOMRect()
} }
} }
}), }),
BubbleMenu.configure({
pluginKey: 'imageBubbleMenu',
element: figureBubbleMenuRef()!,
shouldShow: ({ editor: e, view }) => {
return view.hasFocus() && e.isActive('image')
}
}),
FloatingMenu.configure({ FloatingMenu.configure({
element: floatingMenuRef()!,
pluginKey: 'floatingMenu',
shouldShow: ({ editor: e, state: { selection } }) => {
const { $anchor, empty } = selection
const isRootDepth = $anchor.depth === 1
if (!(isRootDepth && empty)) return false
return !(e.isActive('codeBlock') || e.isActive('heading'))
},
tippyOptions: { tippyOptions: {
placement: 'left' placement: 'left'
} },
}) element: floatingMenuRef()!
] }),
setEditorOptions((prev) => ({ ...prev, extensions: [...(prev.extensions || []), ...menus] })) TrailingNode,
setMenusInitialized(true) ArticleNode
} else { ],
console.error('Some menu references are missing') enablePasteRules: [Link],
content: props.initialContent || null,
onTransaction: ({ editor: e, transaction }) => {
if (transaction.docChanged) {
const html = e.getHTML()
html && props.onChange(html)
const wordCount: number = e.storage.characterCount.words()
const charsCount: number = e.storage.characterCount.characters()
wordCount && countWords({ words: wordCount, characters: charsCount })
} }
} }
}))
const initializeCollaboration = () => { // store tiptap editor in context provider's signal to use it in Panel
if (!editor()) { createEffect(() => setEditing(editor() || undefined))
console.error('Editor is not initialized')
return
}
setEditorOptions((prev: Partial<EditorOptions>) => {
const extensions = [...(prev.extensions || [])]
try {
if (!isCollabMode()) {
// Remove collaboration extensions and return
const filteredExtensions = extensions.filter(
(ext) => ext.name !== 'collaboration' && ext.name !== 'collaborationCursor'
)
return { ...prev, extensions: filteredExtensions }
}
const docName = `shout-${props.shoutId}`
const token = session()?.access_token || ''
const profile = author()
if (!(token && profile)) {
throw new Error('Missing authentication data')
}
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}`)
}
extensions.push(
Collaboration.configure({ document: yDocs[docName] }),
CollaborationCursor.configure({
provider: providers[docName],
user: { name: profile.name, color: uniqolor(profile.slug).color }
})
)
} catch (error) {
console.error('Error initializing collaboration:', error)
showSnackbar({ body: t('Failed to initialize collaboration') })
}
console.log('collab extensions added:', extensions)
return { ...prev, extensions }
})
}
const handleFocus = (event: FocusEvent) => {
console.log('handling focus event', event)
if (editor()?.isActive('figcaption')) {
editor()?.commands.focus()
console.log('active figcaption detected, focusing editor')
}
}
onMount(() => {
console.log('Editor component mounted')
editorElRef()?.addEventListener('focus', handleFocus)
requireAuthentication(() => {
setTimeout(() => {
const opts = setupEditor()
createEditorInstance(opts)
initializeMenus()
}, 120)
}, 'edit')
})
// collab mode on/off
createEffect(on(isCollabMode, (x) => !x && initializeCollaboration(), { defer: true }))
onCleanup(() => { onCleanup(() => {
editorElRef()?.removeEventListener('focus', handleFocus)
editor()?.destroy() editor()?.destroy()
}) })
@ -345,9 +175,9 @@ export const EditorComponent = (props: EditorComponentProps) => {
</div> </div>
<FullBubbleMenu <FullBubbleMenu
editor={editor as Accessor<Editor | undefined>} editor={editor}
ref={setFullBubbleMenuRef} ref={setFullBubbleMenuRef}
shouldShow={shouldShowFullBubbleMenu} shouldShow={shouldShowTextBubbleMenu}
isCommonMarkup={isCommonMarkup()} isCommonMarkup={isCommonMarkup()}
/> />
<BlockquoteBubbleMenu editor={editor() as Editor} ref={setBlockquoteBubbleMenuRef} /> <BlockquoteBubbleMenu editor={editor() as Editor} ref={setBlockquoteBubbleMenuRef} />

View File

@ -31,7 +31,7 @@
outline: none; outline: none;
} }
.blockQuote { .blockquote {
font-weight: 500; font-weight: 500;
color: var(--black-300); color: var(--black-300);
border-left: 2px solid var(--black-100); border-left: 2px solid var(--black-100);

View File

@ -1,6 +1,7 @@
import { Editor } from '@tiptap/core' import { Editor } from '@tiptap/core'
export const renderUploadedImage = (editor: Editor, image: { url: string; originalFilename?: string }) => { export const renderUploadedImage = (editor: Editor, image: { url: string; originalFilename?: string }) => {
image?.url &&
editor editor
.chain() .chain()
.focus() .focus()
@ -20,14 +21,3 @@ export const renderUploadedImage = (editor: Editor, image: { url: string; origin
}) })
.run() .run()
} }
export const allowedImageTypes = new Set([
'image/bmp',
'image/gif',
'image/jpeg',
'image/jpg',
'image/png',
'image/tiff',
'image/webp',
'image/x-icon'
])

View File

@ -1,6 +1,18 @@
import { UploadFile } from '@solid-primitives/upload' import { UploadFile } from '@solid-primitives/upload'
import { Editor } from '@tiptap/core'
import { thumborUrl } from '../config' import { thumborUrl } from '../config'
export const allowedImageTypes = new Set([
'image/bmp',
'image/gif',
'image/jpeg',
'image/jpg',
'image/png',
'image/tiff',
'image/webp',
'image/x-icon'
])
export const handleImageUpload = async (uploadFile: UploadFile, token: string) => { export const handleImageUpload = async (uploadFile: UploadFile, token: string) => {
const formData = new FormData() const formData = new FormData()
formData.append('media', uploadFile.file, uploadFile.name) formData.append('media', uploadFile.file, uploadFile.name)
@ -38,3 +50,49 @@ export const handleImageUpload = async (uploadFile: UploadFile, token: string) =
url url
} }
} }
export const handleClipboardPaste = async (editor?: Editor, token = '') => {
try {
const clipboardItems: 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
}
const result = await handleImageUpload(uplFile, token)
editor
?.chain()
.focus()
.insertContent({
type: 'figure',
attrs: { 'data-type': 'image' },
content: [
{
type: 'image',
attrs: { src: result.url }
},
{
type: 'figcaption',
content: [{ type: 'text', text: result.originalFilename }]
}
]
})
.run()
} catch (error) {
console.error('[Paste Image Error]:', error)
}
}