diff --git a/src/components/Editor/SimplifiedEditor.stories.tsx b/src/components/Editor/SimplifiedEditor.stories.tsx new file mode 100644 index 00000000..2242bd43 --- /dev/null +++ b/src/components/Editor/SimplifiedEditor.stories.tsx @@ -0,0 +1,99 @@ +import { Meta, StoryObj } from 'storybook-solidjs' +import SimplifiedEditor from './SimplifiedEditor' + +const meta: Meta = { + title: 'Components/SimplifiedEditor', + component: SimplifiedEditor, + argTypes: { + placeholder: { + control: 'text', + description: 'Placeholder text when the editor is empty', + defaultValue: 'Type something...' + }, + initialContent: { + control: 'text', + description: 'Initial content for the editor', + defaultValue: '' + }, + maxLength: { + control: 'number', + description: 'Character limit for the editor', + defaultValue: 400 + }, + quoteEnabled: { + control: 'boolean', + description: 'Whether the blockquote feature is enabled', + defaultValue: true + }, + imageEnabled: { + control: 'boolean', + description: 'Whether the image feature is enabled', + defaultValue: true + }, + submitButtonText: { + control: 'text', + description: 'Text for the submit button', + defaultValue: 'Submit' + }, + onSubmit: { + action: 'submitted', + description: 'Callback when the form is submitted' + }, + onCancel: { + action: 'cancelled', + description: 'Callback when the editor is cleared' + }, + onChange: { + action: 'changed', + description: 'Callback when the content changes' + } + } +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + placeholder: 'Type something...', + initialContent: '', + maxLength: 400, + quoteEnabled: true, + imageEnabled: true, + submitButtonText: 'Submit' + } +} + +export const WithInitialContent: Story = { + args: { + placeholder: 'Type something...', + initialContent: 'This is some initial content', + maxLength: 400, + quoteEnabled: true, + imageEnabled: true, + submitButtonText: 'Submit' + } +} + +export const WithCharacterLimit: Story = { + args: { + placeholder: 'You have a 50 character limit...', + initialContent: '', + maxLength: 50, + quoteEnabled: true, + imageEnabled: true, + submitButtonText: 'Submit' + } +} + +export const WithCustomPlaceholder: Story = { + args: { + placeholder: 'Custom placeholder here...', + initialContent: '', + maxLength: 400, + quoteEnabled: true, + imageEnabled: true, + submitButtonText: 'Submit' + } +} diff --git a/src/components/Editor/SimplifiedEditor.tsx b/src/components/Editor/SimplifiedEditor.tsx index 3b201dbe..0483eb68 100644 --- a/src/components/Editor/SimplifiedEditor.tsx +++ b/src/components/Editor/SimplifiedEditor.tsx @@ -1,16 +1,11 @@ import { Blockquote } from '@tiptap/extension-blockquote' -import { Bold } from '@tiptap/extension-bold' import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { CharacterCount } from '@tiptap/extension-character-count' -import { Document } from '@tiptap/extension-document' import { Image } from '@tiptap/extension-image' -import { Italic } from '@tiptap/extension-italic' import { Link } from '@tiptap/extension-link' -import { Paragraph } from '@tiptap/extension-paragraph' import { Placeholder } from '@tiptap/extension-placeholder' -import { Text } from '@tiptap/extension-text' import { clsx } from 'clsx' -import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from 'solid-js' +import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js' import { Portal } from 'solid-js/web' import { createEditorTransaction, @@ -20,7 +15,6 @@ import { useEditorIsFocused } from 'solid-tiptap' -import { useEditorContext } from '~/context/editor' import { useLocalize } from '~/context/localize' import { UploadedFile } from '~/types/upload' import { Button } from '../_shared/Button' @@ -36,8 +30,10 @@ import { Figure } from './extensions/Figure' import { Editor } from '@tiptap/core' import { useUI } from '~/context/ui' +import { base } from '~/lib/editorOptions' import { Modal } from '../_shared/Modal/Modal' import styles from './SimplifiedEditor.module.scss' +import { renderUploadedImage } from './renderUploadedImage' type Props = { placeholder: string @@ -65,6 +61,7 @@ type Props = { } const DEFAULT_MAX_LENGTH = 400 +const ImageFigure = Figure.extend({ name: 'capturedImage', content: 'figcaption image' }) const SimplifiedEditor = (props: Props) => { const { t } = useLocalize() @@ -73,135 +70,55 @@ const SimplifiedEditor = (props: Props) => { const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false) const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false) const [editorElement, setEditorElement] = createSignal() - const { editor, setEditor } = useEditorContext() - - const maxLength = props.maxLength ?? DEFAULT_MAX_LENGTH - let wrapperEditorElRef: HTMLElement | undefined - let textBubbleMenuRef: HTMLDivElement | undefined - let linkBubbleMenuRef: HTMLDivElement | undefined - - const ImageFigure = Figure.extend({ - name: 'capturedImage', - content: 'figcaption image' - }) - - createEffect( - on( - () => editorElement(), - (ee: HTMLDivElement | undefined) => { - if (ee && textBubbleMenuRef && linkBubbleMenuRef) { - const freshEditor = createTiptapEditor(() => ({ - element: ee, - editorProps: { - attributes: { - class: styles.simplifiedEditorField - } - }, - extensions: [ - Document, - Text, - Paragraph, - Bold, - Italic, - Link.extend({ - inclusive: false - }).configure({ - autolink: true, - openOnClick: false - }), - CharacterCount.configure({ - limit: props.noLimits ? null : maxLength - }), - Blockquote.configure({ - HTMLAttributes: { - class: styles.blockQuote - } - }), - BubbleMenu.configure({ - pluginKey: 'textBubbleMenu', - element: textBubbleMenuRef, - shouldShow: ({ view, state }) => { - if (!props.onlyBubbleControls) return false - const { selection } = state - const { empty } = selection - return view.hasFocus() && !empty - } - }), - BubbleMenu.configure({ - pluginKey: 'linkBubbleMenu', - element: linkBubbleMenuRef, - shouldShow: ({ state }) => { - const { selection } = state - const { empty } = selection - return !empty && shouldShowLinkBubbleMenu() - }, - tippyOptions: { - placement: 'bottom' - } - }), - ImageFigure, - Image, - Figcaption, - Placeholder.configure({ - emptyNodeClass: styles.emptyNode, - placeholder: props.placeholder - }) - ], - autofocus: props.autoFocus, - content: props.initialContent || null - })) - const editorInstance = freshEditor() - if (!editorInstance) return - setEditor(editorInstance) - } - }, - { defer: true } - ) - ) - - const isEmpty = useEditorIsEmpty(() => editor()) - const isFocused = useEditorIsFocused(() => editor()) - - const isActive = (name: string) => - createEditorTransaction( - () => editor(), - (ed) => { - return ed?.isActive(name) + const editor = createTiptapEditor(() => ({ + element: editorElement()!, + extensions: [ + ...base, + Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }), + CharacterCount.configure({ limit: props.noLimits ? undefined : props.maxLength }), + Link.extend({ inclusive: false }).configure({ autolink: true, openOnClick: false }), + Blockquote.configure({ HTMLAttributes: { class: styles.blockQuote } }), + BubbleMenu.configure({ + pluginKey: 'textBubbleMenu', + element: textBubbleMenuRef(), + shouldShow: ({ view, state }) => Boolean(props.onlyBubbleControls && view.hasFocus() && !state.selection.empty) + }), + BubbleMenu.configure({ + pluginKey: 'linkBubbleMenu', + element: linkBubbleMenuRef(), + shouldShow: ({ state }) => !state.selection.empty && shouldShowLinkBubbleMenu(), + tippyOptions: { placement: 'bottom' } + }), + ImageFigure, + Image, + Figcaption + ], + editorProps: { + attributes: { + class: styles.simplifiedEditorField } - ) + }, + content: props.initialContent || '' + })) - const html = useEditorHTML(() => editor()) + const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal() + const [linkBubbleMenuRef, setLinkBubbleMenuRef] = createSignal() + const isEmpty = useEditorIsEmpty(editor) + const isFocused = useEditorIsFocused(editor) + const isActive = (name: string) => createEditorTransaction(editor, (ed) => ed?.isActive(name)) + const html = useEditorHTML(editor) const isBold = isActive('bold') const isItalic = isActive('italic') const isLink = isActive('link') const isBlockquote = isActive('blockquote') const renderImage = (image: UploadedFile) => { - editor() - ?.chain() - .focus() - .insertContent({ - type: 'figure', - attrs: { 'data-type': 'image' }, - content: [ - { - type: 'image', - attrs: { src: image.url } - }, - { - type: 'figcaption', - content: [{ type: 'text', text: image.originalFilename }] - } - ] - }) - .run() + renderUploadedImage(editor() as Editor, image) hideModal() } const handleClear = () => { - if (props.onCancel) { - props.onCancel() - } + props.onCancel?.() editor()?.commands.clearContent(true) } @@ -275,7 +192,6 @@ const SimplifiedEditor = (props: Props) => { return (
(wrapperEditorElRef = el)} class={clsx(styles.SimplifiedEditor, { [styles.smallHeight]: props.smallHeight, [styles.minimal]: props.variant === 'minimal', @@ -285,7 +201,7 @@ const SimplifiedEditor = (props: Props) => { })} > -
{maxLength - counter()}
+
{(props.maxLength || DEFAULT_MAX_LENGTH) - counter()}
0}>
{props.label}
@@ -392,12 +308,12 @@ const SimplifiedEditor = (props: Props) => { shouldShow={true} isCommonMarkup={true} editor={editor() as Editor} - ref={(el) => (textBubbleMenuRef = el)} + ref={setTextBubbleMenuRef} />
(linkBubbleMenuRef = el)} + ref={setLinkBubbleMenuRef} onClose={handleHideLinkBubble} />
diff --git a/src/components/_shared/Icon/Icon.module.scss b/src/components/_shared/Icon/Icon.module.scss index b845ee7a..4cafd050 100644 --- a/src/components/_shared/Icon/Icon.module.scss +++ b/src/components/_shared/Icon/Icon.module.scss @@ -10,10 +10,6 @@ } .notificationsCounter { - @include media-breakpoint-up(md) { - left: 1.8rem; - } - align-items: center; background-color: #E84500; border-radius: 0.8rem; @@ -29,4 +25,8 @@ position: absolute; text-align: center; top: -0.5rem; + + @include media-breakpoint-up(md) { + left: 1.8rem; + } } diff --git a/src/context/editor.tsx b/src/context/editor.tsx index f668e5d4..0741b730 100644 --- a/src/context/editor.tsx +++ b/src/context/editor.tsx @@ -1,5 +1,4 @@ import { useMatch, useNavigate } from '@solidjs/router' -import { Editor } from '@tiptap/core' import type { JSX } from 'solid-js' import { Accessor, createContext, createMemo, createSignal, useContext } from 'solid-js' import { SetStoreFunction, createStore } from 'solid-js/store' @@ -39,7 +38,6 @@ type EditorContextType = { wordCounter: Accessor form: ShoutForm formErrors: Record - editor: Accessor saveShout: (form: ShoutForm) => Promise saveDraft: (form: ShoutForm) => Promise saveDraftToLocalStorage: (form: ShoutForm) => void @@ -51,10 +49,9 @@ type EditorContextType = { countWords: (value: WordCounter) => void setForm: SetStoreFunction setFormErrors: SetStoreFunction> - setEditor: (editor: Editor) => void } -const EditorContext = createContext({ editor: () => new Editor() } as EditorContextType) +const EditorContext = createContext({} as EditorContextType) export function useEditorContext() { return useContext(EditorContext) @@ -90,7 +87,6 @@ export const EditorProvider = (props: { children: JSX.Element }) => { const { addFeed } = useFeed() const snackbar = useSnackbar() const [isEditorPanelVisible, setIsEditorPanelVisible] = createSignal(false) - const [editor, setEditor] = createSignal() const [form, setForm] = createStore({ body: '', slug: '', @@ -283,14 +279,12 @@ export const EditorProvider = (props: { children: JSX.Element }) => { countWords, setForm, setFormErrors, - setEditor } const value: EditorContextType = { ...actions, form, formErrors, - editor, isEditorPanelVisible, wordCounter }