editor-wip

This commit is contained in:
Untone 2024-09-19 19:51:56 +03:00
parent d6c6545726
commit 6ca29a351f
3 changed files with 154 additions and 201 deletions

View File

@ -134,9 +134,6 @@
}, },
"overrides": { "overrides": {
"sass": "1.77.6", "sass": "1.77.6",
"sass-embedded": {
"sass": "1.77.6"
},
"vite": "5.3.5", "vite": "5.3.5",
"yjs": "13.6.18", "yjs": "13.6.18",
"y-prosemirror": "1.2.12" "y-prosemirror": "1.2.12"

View File

@ -1,4 +1,4 @@
import { Editor } from '@tiptap/core' import { Editor, EditorOptions } from '@tiptap/core'
import { createSignal } from 'solid-js' import { createSignal } from 'solid-js'
import { createStore } from 'solid-js/store' import { createStore } from 'solid-js/store'
import { Meta, StoryObj } from 'storybook-solidjs' import { Meta, StoryObj } from 'storybook-solidjs'
@ -8,7 +8,7 @@ import { SessionContext, SessionContextType } from '~/context/session'
import { SnackbarContext, SnackbarContextType } from '~/context/ui' import { SnackbarContext, SnackbarContextType } from '~/context/ui'
import { EditorComponent, EditorComponentProps } from './Editor' import { EditorComponent, EditorComponentProps } from './Editor'
// Mock any necessary data // Mock data
const mockSession = { const mockSession = {
session: () => ({ session: () => ({
user: { user: {
@ -37,54 +37,38 @@ const [_form, setForm] = createStore<ShoutForm>({
}) })
const [_formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>) const [_formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>)
const [editor, setEditor] = createSignal<Editor | undefined>() const [editor, setEditor] = createSignal<Editor | undefined>()
const mockEditorContext: EditorContextType = { const mockEditorContext: EditorContextType = {
countWords: () => 0, countWords: () => 0,
isEditorPanelVisible: (): boolean => { isEditorPanelVisible: () => false,
throw new Error('Function not implemented.') wordCounter: () => ({ characters: 0, words: 0 }),
form: _form,
formErrors: _formErrors,
createEditor: (opts?: Partial<EditorOptions>) => {
const newEditor = new Editor(opts)
setEditor(newEditor)
return newEditor
}, },
wordCounter: (): { characters: number; words: number } => {
throw new Error('Function not implemented.')
},
form: {
layout: undefined,
shoutId: 0,
slug: '',
title: '',
subtitle: undefined,
lead: undefined,
description: undefined,
selectedTopics: [],
mainTopic: undefined,
body: '',
coverImageUrl: undefined,
media: undefined
},
formErrors: {} as Record<keyof ShoutForm, string>,
setEditor,
editor, editor,
saveShout: (_form: ShoutForm): Promise<void> => { saveShout: async (_form: ShoutForm) => {
throw new Error('Function not implemented.') // Simulate save
}, },
saveDraft: (_form: ShoutForm): Promise<void> => { saveDraft: async (_form: ShoutForm) => {
throw new Error('Function not implemented.') // Simulate save draft
}, },
saveDraftToLocalStorage: (_form: ShoutForm): void => { saveDraftToLocalStorage: (_form: ShoutForm) => {
throw new Error('Function not implemented.') // Simulate save to local storage
}, },
getDraftFromLocalStorage: (_shoutId: number): ShoutForm => { getDraftFromLocalStorage: (_shoutId: number): ShoutForm => _form,
throw new Error('Function not implemented.') publishShout: async (_form: ShoutForm) => {
// Simulate publish
}, },
publishShout: (_form: ShoutForm): Promise<void> => { publishShoutById: async (_shoutId: number) => {
throw new Error('Function not implemented.') // Simulate publish by ID
}, },
publishShoutById: (_shoutId: number): Promise<void> => { deleteShout: async (_shoutId: number): Promise<boolean> => true,
throw new Error('Function not implemented.') toggleEditorPanel: () => {
}, // Simulate toggle
deleteShout: (_shoutId: number): Promise<boolean> => {
throw new Error('Function not implemented.')
},
toggleEditorPanel: (): void => {
throw new Error('Function not implemented.')
}, },
setForm, setForm,
setFormErrors setFormErrors

View File

@ -1,34 +1,21 @@
import { HocuspocusProvider } from '@hocuspocus/provider' import { HocuspocusProvider } from '@hocuspocus/provider'
import { Editor, EditorOptions, isTextSelection } from '@tiptap/core' import { Editor, EditorOptions, isTextSelection } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { BulletList } from '@tiptap/extension-bullet-list'
import { CharacterCount } from '@tiptap/extension-character-count' import { CharacterCount } from '@tiptap/extension-character-count'
import { Collaboration } from '@tiptap/extension-collaboration' import { Collaboration } from '@tiptap/extension-collaboration'
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor' import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
import { Document } from '@tiptap/extension-document'
import { Dropcursor } from '@tiptap/extension-dropcursor' import { Dropcursor } from '@tiptap/extension-dropcursor'
import { FloatingMenu } from '@tiptap/extension-floating-menu' import { FloatingMenu } from '@tiptap/extension-floating-menu'
import Focus from '@tiptap/extension-focus' import Focus from '@tiptap/extension-focus'
import { Gapcursor } from '@tiptap/extension-gapcursor' import { Gapcursor } from '@tiptap/extension-gapcursor'
import { HardBreak } from '@tiptap/extension-hard-break' import { HardBreak } from '@tiptap/extension-hard-break'
import { Heading } from '@tiptap/extension-heading'
import { Highlight } from '@tiptap/extension-highlight' import { Highlight } from '@tiptap/extension-highlight'
import { HorizontalRule } from '@tiptap/extension-horizontal-rule' import { HorizontalRule } from '@tiptap/extension-horizontal-rule'
import { Image } from '@tiptap/extension-image' import { Image } from '@tiptap/extension-image'
import { Italic } from '@tiptap/extension-italic'
import { Link } from '@tiptap/extension-link'
import { ListItem } from '@tiptap/extension-list-item'
import { OrderedList } from '@tiptap/extension-ordered-list'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Placeholder } from '@tiptap/extension-placeholder' import { Placeholder } from '@tiptap/extension-placeholder'
import { Strike } from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
import { Underline } from '@tiptap/extension-underline'
import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js' import { Show, createEffect, createMemo, createSignal, on, onCleanup } from 'solid-js'
import { createTiptapEditor } from 'solid-tiptap'
import uniqolor from 'uniqolor' import uniqolor from 'uniqolor'
import { Doc } from 'yjs' import { Doc, Transaction } from 'yjs'
import { useEditorContext } from '~/context/editor' import { useEditorContext } from '~/context/editor'
import { useLocalize } from '~/context/localize' import { useLocalize } from '~/context/localize'
import { useSession } from '~/context/session' import { useSession } from '~/context/session'
@ -50,6 +37,7 @@ import { TrailingNode } from './extensions/TrailingNode'
import { renderUploadedImage } from './renderUploadedImage' import { renderUploadedImage } from './renderUploadedImage'
import './Prosemirror.scss' import './Prosemirror.scss'
import { base } from '~/lib/editorOptions'
export type EditorComponentProps = { export type EditorComponentProps = {
shoutId: number shoutId: number
@ -79,8 +67,8 @@ export const EditorComponent = (props: EditorComponentProps) => {
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false) const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false) const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
const { showSnackbar } = useSnackbar() const { showSnackbar } = useSnackbar()
const { setEditor, countWords } = useEditorContext() const { createEditor, countWords, editor } = useEditorContext()
const [extensions, setExtensions] = createSignal<EditorOptions['extensions']>([]) const [editorOptions, setEditorOptions] = createSignal<Partial<EditorOptions>>({})
const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>() const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>()
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>() const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLElement | undefined>() const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLElement | undefined>()
@ -115,152 +103,136 @@ export const EditorComponent = (props: EditorComponentProps) => {
} catch (error) { } catch (error) {
console.error('[Paste Image Error]:', error) console.error('[Paste Image Error]:', error)
} }
return false
} }
const editor = createTiptapEditor(() => ({
element: editorElRef()!,
editorProps: {
attributes: {
class: 'articleEditor'
},
transformPastedHTML(html) {
return html.replaceAll(/<img.*?>/g, '')
},
handlePaste: () => {
handleClipboardPaste()
return false
}
},
extensions: extensions(),
onTransaction: ({ transaction, editor }) => {
if (transaction.docChanged) {
// Get the current HTML content from the editor
const html = editor.getHTML()
// Trigger the onChange callback with the updated HTML
html && props.onChange(html)
// Get the word count from the editor's storage (using CharacterCount)
const wordCount = editor.storage.characterCount.words()
// Update the word count
wordCount && countWords(wordCount)
}
},
content: props.initialContent || ''
}))
createEffect(() => editor() && setEditor(editor() as Editor))
createEffect( createEffect(
on( on([editorOptions, editorElRef, author], ([opts, element, a]) => {
[extensions, editorElRef, author, () => `shout-${props.shoutId}`], if (!opts && a && element) {
([eee, element, a, docName]) => const options = {
eee.length === 0 && element: editorElRef()!,
a && editorProps: {
element && attributes: { class: 'articleEditor' },
setExtensions([ transformPastedHTML: (c: string) => c.replaceAll(/<img.*?>/g, ''),
Document, handlePaste: handleClipboardPaste
Text, },
Paragraph, extensions: [
Bold, ...base,
Italic,
Strike,
Heading.configure({ levels: [2, 3, 4] }),
BulletList,
OrderedList,
ListItem,
HorizontalRule.configure({ HTMLAttributes: { class: 'horizontalRule' } }), HorizontalRule.configure({ HTMLAttributes: { class: 'horizontalRule' } }),
Dropcursor, Dropcursor,
CustomBlockquote, CustomBlockquote,
Span, Span,
ToggleTextWrap, ToggleTextWrap,
Underline, Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }),
Link.extend({ inclusive: false }).configure({ autolink: true, openOnClick: false }), Focus,
Collaboration.configure({ document: yDocs[docName] }), Gapcursor,
CollaborationCursor.configure({ HardBreak,
provider: providers[docName], Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }),
user: { name: a.name, color: uniqolor(a.slug).color } Image,
}), Iframe,
Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }), Figure,
Focus, Figcaption,
Gapcursor, Footnote,
HardBreak, ToggleTextWrap,
Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }), CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
Image, TrailingNode,
Iframe, ArticleNode,
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: { doc, selection }, from, to }) => {
const isEmptyTextBlock = doc.textBetween(from, to).length === 0 && isTextSelection(selection)
isEmptyTextBlock &&
e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
setIsCommonMarkup(e?.isActive('figcaption')) // menus
const result =
(view.hasFocus() && BubbleMenu.configure({
!selection.empty && pluginKey: 'textBubbleMenu',
!isEmptyTextBlock && element: textBubbleMenuRef(),
!e.isActive('image') && shouldShow: ({ editor: e, view, state: { doc, selection }, from, to }) => {
!e.isActive('figure')) || const isEmptyTextBlock =
e.isActive('footnote') || doc.textBetween(from, to).length === 0 && isTextSelection(selection)
(e.isActive('figcaption') && !selection.empty) isEmptyTextBlock &&
setShouldShowTextBubbleMenu(result) e?.chain().focus().removeTextWrap({ class: 'highlight-fake-selection' }).run()
return result
}, setIsCommonMarkup(e?.isActive('figcaption'))
tippyOptions: { const result =
onHide: () => editor()?.commands.focus() as false (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'))
}
})
// 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()
// Trigger the onChange callback with the updated HTML
html && props.onChange(html)
// Get the word count from the editor's storage (using CharacterCount)
const wordCount = editor.storage.characterCount.words()
// Update the word count
wordCount && countWords(wordCount)
} }
}), },
BubbleMenu.configure({ content: props.initialContent || ''
pluginKey: 'blockquoteBubbleMenu', }
element: blockquoteBubbleMenuRef(), setEditorOptions(options as unknown as Partial<EditorOptions>)
shouldShow: ({ editor: e, view, state }) => createEditor(options as unknown as Partial<EditorOptions>)
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'))
}
}),
TrailingNode,
ArticleNode
])
)
) )
createEffect( createEffect(
on( on(
[ [
editor,
() => !props.disableCollaboration, () => !props.disableCollaboration,
() => `shout-${props.shoutId}`, () => `shout-${props.shoutId}`,
() => session()?.access_token || '', () => session()?.access_token || '',
author author
], ],
([collab, docName, token, profile]) => { ([e, collab, docName, token, profile]) => {
if (!e) return
if (!yDocs[docName]) { if (!yDocs[docName]) {
yDocs[docName] = new Doc() yDocs[docName] = new Doc()
} }
@ -275,17 +247,17 @@ export const EditorComponent = (props: EditorComponentProps) => {
} }
collab && collab &&
setExtensions((old: EditorOptions['extensions']) => [ createEditor({
...old, ...editorOptions(),
Collaboration.configure({ document: yDocs[docName] }), extensions: [
CollaborationCursor.configure({ ...(editor()?.options.extensions || []),
provider: providers[docName], Collaboration.configure({ document: yDocs[docName] }),
user: { CollaborationCursor.configure({
name: profile.name, provider: providers[docName],
color: uniqolor(profile.slug).color user: { name: profile.name, color: uniqolor(profile.slug).color }
} })
}) ]
]) })
} }
) )
) )