editor-wip
This commit is contained in:
parent
d6c6545726
commit
6ca29a351f
|
@ -134,9 +134,6 @@
|
|||
},
|
||||
"overrides": {
|
||||
"sass": "1.77.6",
|
||||
"sass-embedded": {
|
||||
"sass": "1.77.6"
|
||||
},
|
||||
"vite": "5.3.5",
|
||||
"yjs": "13.6.18",
|
||||
"y-prosemirror": "1.2.12"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Editor } from '@tiptap/core'
|
||||
import { Editor, EditorOptions } from '@tiptap/core'
|
||||
import { createSignal } from 'solid-js'
|
||||
import { createStore } from 'solid-js/store'
|
||||
import { Meta, StoryObj } from 'storybook-solidjs'
|
||||
|
@ -8,7 +8,7 @@ import { SessionContext, SessionContextType } from '~/context/session'
|
|||
import { SnackbarContext, SnackbarContextType } from '~/context/ui'
|
||||
import { EditorComponent, EditorComponentProps } from './Editor'
|
||||
|
||||
// Mock any necessary data
|
||||
// Mock data
|
||||
const mockSession = {
|
||||
session: () => ({
|
||||
user: {
|
||||
|
@ -37,54 +37,38 @@ const [_form, setForm] = createStore<ShoutForm>({
|
|||
})
|
||||
const [_formErrors, setFormErrors] = createStore({} as Record<keyof ShoutForm, string>)
|
||||
const [editor, setEditor] = createSignal<Editor | undefined>()
|
||||
|
||||
const mockEditorContext: EditorContextType = {
|
||||
countWords: () => 0,
|
||||
isEditorPanelVisible: (): boolean => {
|
||||
throw new Error('Function not implemented.')
|
||||
isEditorPanelVisible: () => false,
|
||||
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,
|
||||
saveShout: (_form: ShoutForm): Promise<void> => {
|
||||
throw new Error('Function not implemented.')
|
||||
saveShout: async (_form: ShoutForm) => {
|
||||
// Simulate save
|
||||
},
|
||||
saveDraft: (_form: ShoutForm): Promise<void> => {
|
||||
throw new Error('Function not implemented.')
|
||||
saveDraft: async (_form: ShoutForm) => {
|
||||
// Simulate save draft
|
||||
},
|
||||
saveDraftToLocalStorage: (_form: ShoutForm): void => {
|
||||
throw new Error('Function not implemented.')
|
||||
saveDraftToLocalStorage: (_form: ShoutForm) => {
|
||||
// Simulate save to local storage
|
||||
},
|
||||
getDraftFromLocalStorage: (_shoutId: number): ShoutForm => {
|
||||
throw new Error('Function not implemented.')
|
||||
getDraftFromLocalStorage: (_shoutId: number): ShoutForm => _form,
|
||||
publishShout: async (_form: ShoutForm) => {
|
||||
// Simulate publish
|
||||
},
|
||||
publishShout: (_form: ShoutForm): Promise<void> => {
|
||||
throw new Error('Function not implemented.')
|
||||
publishShoutById: async (_shoutId: number) => {
|
||||
// Simulate publish by ID
|
||||
},
|
||||
publishShoutById: (_shoutId: number): Promise<void> => {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
deleteShout: (_shoutId: number): Promise<boolean> => {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
toggleEditorPanel: (): void => {
|
||||
throw new Error('Function not implemented.')
|
||||
deleteShout: async (_shoutId: number): Promise<boolean> => true,
|
||||
toggleEditorPanel: () => {
|
||||
// Simulate toggle
|
||||
},
|
||||
setForm,
|
||||
setFormErrors
|
||||
|
|
|
@ -1,34 +1,21 @@
|
|||
import { HocuspocusProvider } from '@hocuspocus/provider'
|
||||
import { Editor, EditorOptions, isTextSelection } from '@tiptap/core'
|
||||
import { Bold } from '@tiptap/extension-bold'
|
||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
||||
import { BulletList } from '@tiptap/extension-bullet-list'
|
||||
import { CharacterCount } from '@tiptap/extension-character-count'
|
||||
import { Collaboration } from '@tiptap/extension-collaboration'
|
||||
import { CollaborationCursor } from '@tiptap/extension-collaboration-cursor'
|
||||
import { Document } from '@tiptap/extension-document'
|
||||
import { Dropcursor } from '@tiptap/extension-dropcursor'
|
||||
import { FloatingMenu } from '@tiptap/extension-floating-menu'
|
||||
import Focus from '@tiptap/extension-focus'
|
||||
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 { HorizontalRule } from '@tiptap/extension-horizontal-rule'
|
||||
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 { 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 { createTiptapEditor } from 'solid-tiptap'
|
||||
import uniqolor from 'uniqolor'
|
||||
import { Doc } from 'yjs'
|
||||
import { Doc, Transaction } from 'yjs'
|
||||
import { useEditorContext } from '~/context/editor'
|
||||
import { useLocalize } from '~/context/localize'
|
||||
import { useSession } from '~/context/session'
|
||||
|
@ -50,6 +37,7 @@ import { TrailingNode } from './extensions/TrailingNode'
|
|||
import { renderUploadedImage } from './renderUploadedImage'
|
||||
|
||||
import './Prosemirror.scss'
|
||||
import { base } from '~/lib/editorOptions'
|
||||
|
||||
export type EditorComponentProps = {
|
||||
shoutId: number
|
||||
|
@ -79,8 +67,8 @@ export const EditorComponent = (props: EditorComponentProps) => {
|
|||
const [isCommonMarkup, setIsCommonMarkup] = createSignal(false)
|
||||
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
|
||||
const { showSnackbar } = useSnackbar()
|
||||
const { setEditor, countWords } = useEditorContext()
|
||||
const [extensions, setExtensions] = createSignal<EditorOptions['extensions']>([])
|
||||
const { createEditor, countWords, editor } = useEditorContext()
|
||||
const [editorOptions, setEditorOptions] = createSignal<Partial<EditorOptions>>({})
|
||||
const [editorElRef, setEditorElRef] = createSignal<HTMLElement | undefined>()
|
||||
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||
const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal<HTMLElement | undefined>()
|
||||
|
@ -115,152 +103,136 @@ export const EditorComponent = (props: EditorComponentProps) => {
|
|||
} catch (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(
|
||||
on(
|
||||
[extensions, editorElRef, author, () => `shout-${props.shoutId}`],
|
||||
([eee, element, a, docName]) =>
|
||||
eee.length === 0 &&
|
||||
a &&
|
||||
element &&
|
||||
setExtensions([
|
||||
Document,
|
||||
Text,
|
||||
Paragraph,
|
||||
Bold,
|
||||
Italic,
|
||||
Strike,
|
||||
Heading.configure({ levels: [2, 3, 4] }),
|
||||
BulletList,
|
||||
OrderedList,
|
||||
ListItem,
|
||||
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,
|
||||
|
||||
HorizontalRule.configure({ HTMLAttributes: { class: 'horizontalRule' } }),
|
||||
Dropcursor,
|
||||
CustomBlockquote,
|
||||
Span,
|
||||
ToggleTextWrap,
|
||||
Underline,
|
||||
Link.extend({ inclusive: false }).configure({ autolink: true, openOnClick: false }),
|
||||
Collaboration.configure({ document: yDocs[docName] }),
|
||||
CollaborationCursor.configure({
|
||||
provider: providers[docName],
|
||||
user: { name: a.name, color: uniqolor(a.slug).color }
|
||||
}),
|
||||
Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }),
|
||||
Focus,
|
||||
Gapcursor,
|
||||
HardBreak,
|
||||
Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }),
|
||||
Image,
|
||||
Iframe,
|
||||
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()
|
||||
HorizontalRule.configure({ HTMLAttributes: { class: 'horizontalRule' } }),
|
||||
Dropcursor,
|
||||
CustomBlockquote,
|
||||
Span,
|
||||
ToggleTextWrap,
|
||||
Placeholder.configure({ placeholder: t('Add a link or click plus to embed media') }),
|
||||
Focus,
|
||||
Gapcursor,
|
||||
HardBreak,
|
||||
Highlight.configure({ multicolor: true, HTMLAttributes: { class: 'highlight' } }),
|
||||
Image,
|
||||
Iframe,
|
||||
Figure,
|
||||
Figcaption,
|
||||
Footnote,
|
||||
ToggleTextWrap,
|
||||
CharacterCount.configure(), // https://github.com/ueberdosis/tiptap/issues/2589#issuecomment-1093084689
|
||||
TrailingNode,
|
||||
ArticleNode,
|
||||
|
||||
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
|
||||
// menus
|
||||
|
||||
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'))
|
||||
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'))
|
||||
}
|
||||
})
|
||||
|
||||
// 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({
|
||||
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'))
|
||||
}
|
||||
}),
|
||||
TrailingNode,
|
||||
ArticleNode
|
||||
])
|
||||
)
|
||||
},
|
||||
content: props.initialContent || ''
|
||||
}
|
||||
setEditorOptions(options as unknown as Partial<EditorOptions>)
|
||||
createEditor(options as unknown as Partial<EditorOptions>)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
editor,
|
||||
() => !props.disableCollaboration,
|
||||
() => `shout-${props.shoutId}`,
|
||||
() => session()?.access_token || '',
|
||||
author
|
||||
],
|
||||
([collab, docName, token, profile]) => {
|
||||
([e, collab, docName, token, profile]) => {
|
||||
if (!e) return
|
||||
|
||||
if (!yDocs[docName]) {
|
||||
yDocs[docName] = new Doc()
|
||||
}
|
||||
|
@ -275,17 +247,17 @@ export const EditorComponent = (props: EditorComponentProps) => {
|
|||
}
|
||||
|
||||
collab &&
|
||||
setExtensions((old: EditorOptions['extensions']) => [
|
||||
...old,
|
||||
Collaboration.configure({ document: yDocs[docName] }),
|
||||
CollaborationCursor.configure({
|
||||
provider: providers[docName],
|
||||
user: {
|
||||
name: profile.name,
|
||||
color: uniqolor(profile.slug).color
|
||||
}
|
||||
})
|
||||
])
|
||||
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 }
|
||||
})
|
||||
]
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue
Block a user