From 6ca29a351f72db8ff9cb894cbb9aea1c593e8c91 Mon Sep 17 00:00:00 2001 From: Untone Date: Thu, 19 Sep 2024 19:51:56 +0300 Subject: [PATCH] editor-wip --- package.json | 3 - src/components/Editor/Editor.stories.tsx | 66 ++---- src/components/Editor/Editor.tsx | 286 ++++++++++------------- 3 files changed, 154 insertions(+), 201 deletions(-) diff --git a/package.json b/package.json index 3d71ac47..e1bd9e49 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/Editor/Editor.stories.tsx b/src/components/Editor/Editor.stories.tsx index d8e31abd..e7d0f255 100644 --- a/src/components/Editor/Editor.stories.tsx +++ b/src/components/Editor/Editor.stories.tsx @@ -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({ }) const [_formErrors, setFormErrors] = createStore({} as Record) const [editor, setEditor] = createSignal() + 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) => { + 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, - setEditor, editor, - saveShout: (_form: ShoutForm): Promise => { - throw new Error('Function not implemented.') + saveShout: async (_form: ShoutForm) => { + // Simulate save }, - saveDraft: (_form: ShoutForm): Promise => { - 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 => { - throw new Error('Function not implemented.') + publishShoutById: async (_shoutId: number) => { + // Simulate publish by ID }, - publishShoutById: (_shoutId: number): Promise => { - throw new Error('Function not implemented.') - }, - deleteShout: (_shoutId: number): Promise => { - throw new Error('Function not implemented.') - }, - toggleEditorPanel: (): void => { - throw new Error('Function not implemented.') + deleteShout: async (_shoutId: number): Promise => true, + toggleEditorPanel: () => { + // Simulate toggle }, setForm, setFormErrors diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx index 71fe5613..81d31163 100644 --- a/src/components/Editor/Editor.tsx +++ b/src/components/Editor/Editor.tsx @@ -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([]) + const { createEditor, countWords, editor } = useEditorContext() + const [editorOptions, setEditorOptions] = createSignal>({}) const [editorElRef, setEditorElRef] = createSignal() const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal() const [incutBubbleMenuRef, setIncutBubbleMenuRef] = createSignal() @@ -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(//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(//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) + createEditor(options as unknown as Partial) + } + }) ) 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 } + }) + ] + }) } ) )