webapp/src/components/Editor/SimplifiedEditor.tsx
2024-09-19 19:15:51 +03:00

245 lines
8.1 KiB
TypeScript

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<number>(0)
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
const [editorElement, setEditorElement] = createSignal<HTMLDivElement | undefined>()
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [linkBubbleMenuRef, setLinkBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
// 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 (
<ShowOnlyOnClient>
<div
class={clsx(styles.SimplifiedEditor, {
[styles.smallHeight]: props.smallHeight,
[styles.minimal]: props.variant === 'minimal',
[styles.bordered]: props.variant === 'bordered',
[styles.isFocused]: isFocused() || !isEmpty(),
[styles.labelVisible]: props.label && counter() > 0
})}
>
{/* Display label when applicable */}
<Show when={props.label && counter() > 0}>
<div class={styles.label}>{props.label}</div>
</Show>
<Show
when={props.hideToolbar}
fallback={
<ToolbarControls {...props} setShouldShowLinkBubbleMenu={setShouldShowLinkBubbleMenu} />
}
>
<TextBubbleMenu
editor={editor() as Editor}
ref={setTextBubbleMenuRef}
shouldShow={shouldShowTextBubbleMenu()}
isCommonMarkup={true}
/>
{/* Link bubble menu */}
<Show when={shouldShowLinkBubbleMenu()}>
<LinkBubbleMenuModule
editor={editor() as Editor}
ref={setLinkBubbleMenuRef}
onClose={handleHideLinkBubble}
/>
</Show>
</Show>
{/* editor element */}
<div
style={
props.maxHeight
? {
overflow: 'auto',
'max-height': `${props.maxHeight}px`
}
: undefined
}
ref={setEditorElement}
/>
{/* Display character limit if maxLength is provided */}
<Show when={props.maxLength && editor()}>
<div class={styles.limit}>{(props.maxLength || DEFAULT_MAX_LENGTH) - counter()}</div>
</Show>
{/* Image upload modal (show/hide) */}
<Show when={props.imageEnabled}>
<Portal>
<Modal variant="narrow" name="simplifiedEditorUploadImage">
<UploadModalContent onClose={handleImageRender} />
</Modal>
</Portal>
</Show>
</div>
</ShowOnlyOnClient>
)
}
export default SimplifiedEditor // Export component for lazy loading