import { Editor, FocusPosition } from '@tiptap/core' import { BubbleMenu } from '@tiptap/extension-bubble-menu' import { CharacterCount } from '@tiptap/extension-character-count' import { Placeholder } from '@tiptap/extension-placeholder' import { clsx } from 'clsx' import { Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js' import { Portal } from 'solid-js/web' import { createEditorTransaction, useEditorHTML, useEditorIsEmpty, useEditorIsFocused } from 'solid-tiptap' import { useEditorContext } from '~/context/editor' import { useUI } from '~/context/ui' import { base, custom } from '~/lib/editorOptions' import { useEscKeyDownHandler } from '~/lib/useEscKeyDownHandler' import { UploadedFile } from '~/types/upload' import { Modal } from '../_shared/Modal/Modal' import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient' import { ToolbarControls } from './EditorToolbar' import { LinkBubbleMenuModule } from './LinkBubbleMenu' import { TextBubbleMenu } from './TextBubbleMenu' import { UploadModalContent } from './UploadModalContent' import { renderUploadedImage } from './renderUploadedImage' import styles from './SimplifiedEditor.module.scss' export type SimplifiedEditorProps = { placeholder: string initialContent?: string label?: string onSubmit?: (text: string) => void onCancel?: () => void onChange?: (text: string) => void variant?: 'minimal' | 'bordered' maxLength?: number noLimits?: boolean maxHeight?: number submitButtonText?: string quoteEnabled?: boolean imageEnabled?: boolean setClear?: boolean resetToInitial?: boolean smallHeight?: boolean submitByCtrlEnter?: boolean hideToolbar?: boolean controlsAlwaysVisible?: boolean autoFocus?: boolean isCancelButtonVisible?: boolean isPosting?: boolean } const DEFAULT_MAX_LENGTH = 400 const SimplifiedEditor = (props: SimplifiedEditorProps) => { // local signals const [counter, setCounter] = createSignal(0) const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false) const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false) const [editorElement, setEditorElement] = createSignal() const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal() const [linkBubbleMenuRef, setLinkBubbleMenuRef] = createSignal() // contexts const { hideModal } = useUI() const { editor, createEditor } = useEditorContext() const initEditor = (element?: HTMLElement) => { if (element instanceof HTMLElement && editor()?.options.element !== element) { const opts = { element, extensions: [ // common extensions ...base, ...custom, // setup from component props Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }), CharacterCount.configure({ limit: props.noLimits ? undefined : props.maxLength }), // bubble menu 1 BubbleMenu.configure({ pluginKey: 'bubble-menu', element: textBubbleMenuRef(), shouldShow: ({ view }) => view.hasFocus() && shouldShowTextBubbleMenu() }), // bubble menu 2 BubbleMenu.configure({ pluginKey: 'bubble-link-input', element: linkBubbleMenuRef(), shouldShow: ({ state }) => !state.selection.empty && shouldShowLinkBubbleMenu(), tippyOptions: { placement: 'bottom' } }) ], editorProps: { attributes: { class: styles.simplifiedEditorField } }, content: props.initialContent || '', onCreate: () => console.info('[SimplifiedEditor] created'), onContentError: console.error, autofocus: (props.autoFocus && 'end') as FocusPosition | undefined, editable: true, enableCoreExtensions: true, enableContentCheck: true, injectNonce: undefined, // TODO: can be useful copyright/copyleft mark parseOptions: undefined // see: https://prosemirror.net/docs/ref/#model.ParseOptions } createEditor(opts) } } // editor observers const isEmpty = useEditorIsEmpty(editor) const isFocused = useEditorIsFocused(editor) const selection = createEditorTransaction(editor, (ed) => ed?.state.selection) const html = useEditorHTML(editor) /// EFFECTS /// // Mount event listeners for handling key events and clean up on component unmount onMount(() => { window.addEventListener('keydown', handleKeyDown) onCleanup(() => { window.removeEventListener('keydown', handleKeyDown) editor()?.destroy() }) }) // watch changes createEffect(on(editorElement, initEditor, { defer: true })) // element -> editorOptions -> set editor createEffect( on(selection, (s?: Editor['state']['selection']) => s && setShouldShowTextBubbleMenu(!s?.empty)) ) createEffect( on( () => props.setClear, (x?: boolean) => x && editor()?.commands.clearContent(true) ) ) createEffect( on( () => props.resetToInitial, (x?: boolean) => x && editor()?.commands.setContent(props.initialContent || '') ) ) createEffect(on([html, () => props.onChange], ([c, handler]) => c && handler && handler(c))) // onChange createEffect(on(html, (c?: string) => c && setCounter(editor()?.storage.characterCount.characters()))) //counter /// HANDLERS /// const handleImageRender = (image?: UploadedFile) => { image && renderUploadedImage(editor() as Editor, image) hideModal() } const handleKeyDown = (event: KeyboardEvent) => { if ( isFocused() && !isEmpty() && event.code === 'Enter' && props.submitByCtrlEnter && (event.metaKey || event.ctrlKey) ) { event.preventDefault() props.onSubmit?.(html() || '') } } const handleHideLinkBubble = () => { editor()?.commands.focus() setShouldShowLinkBubbleMenu(false) } useEscKeyDownHandler(handleHideLinkBubble) return (
0 })} > {/* Display label when applicable */} 0}>
{props.label}
} > {/* Link bubble menu */} {/* editor element */}
{/* Display character limit if maxLength is provided */}
{(props.maxLength || DEFAULT_MAX_LENGTH) - counter()}
{/* Image upload modal (show/hide) */}
) } export default SimplifiedEditor // Export component for lazy loading