webapp/src/components/Editor/SimplifiedEditor.tsx

325 lines
11 KiB
TypeScript
Raw Normal View History

import { Blockquote } from '@tiptap/extension-blockquote'
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
import { CharacterCount } from '@tiptap/extension-character-count'
import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Placeholder } from '@tiptap/extension-placeholder'
import { clsx } from 'clsx'
2024-09-15 20:17:02 +00:00
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'
import { Portal } from 'solid-js/web'
import {
createEditorTransaction,
createTiptapEditor,
useEditorHTML,
useEditorIsEmpty,
2024-06-26 08:22:05 +00:00
useEditorIsFocused
} from 'solid-tiptap'
2024-09-15 16:41:02 +00:00
2024-07-26 15:49:15 +00:00
import { useLocalize } from '~/context/localize'
2024-06-24 17:50:27 +00:00
import { UploadedFile } from '~/types/upload'
import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon'
2024-06-24 17:50:27 +00:00
import { Loading } from '../_shared/Loading'
import { Popover } from '../_shared/Popover'
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
import { TextBubbleMenu } from './TextBubbleMenu'
import { UploadModalContent } from './UploadModalContent'
2024-02-04 11:25:21 +00:00
import { Figcaption } from './extensions/Figcaption'
import { Figure } from './extensions/Figure'
2024-09-15 16:41:02 +00:00
import { Editor } from '@tiptap/core'
import { useUI } from '~/context/ui'
2024-09-15 20:17:02 +00:00
import { base } from '~/lib/editorOptions'
2024-09-15 16:41:02 +00:00
import { Modal } from '../_shared/Modal/Modal'
import styles from './SimplifiedEditor.module.scss'
2024-09-15 20:17:02 +00:00
import { renderUploadedImage } from './renderUploadedImage'
2024-09-15 16:41:02 +00:00
type Props = {
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
onlyBubbleControls?: boolean
controlsAlwaysVisible?: boolean
autoFocus?: boolean
isCancelButtonVisible?: boolean
2024-05-06 18:45:17 +00:00
isPosting?: boolean
}
const DEFAULT_MAX_LENGTH = 400
2024-09-15 20:17:02 +00:00
const ImageFigure = Figure.extend({ name: 'capturedImage', content: 'figcaption image' })
const SimplifiedEditor = (props: Props) => {
2024-07-22 05:41:10 +00:00
const { t } = useLocalize()
2024-07-26 15:49:15 +00:00
const { showModal, hideModal } = useUI()
2024-07-22 05:41:10 +00:00
const [counter, setCounter] = createSignal<number>(0)
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
2024-09-15 16:41:02 +00:00
const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false)
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
2024-09-15 20:17:02 +00:00
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
2024-09-15 16:41:02 +00:00
}
2024-09-15 20:17:02 +00:00
},
content: props.initialContent || ''
}))
2024-09-15 20:17:02 +00:00
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
const [linkBubbleMenuRef, setLinkBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
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) => {
2024-09-15 20:17:02 +00:00
renderUploadedImage(editor() as Editor, image)
hideModal()
}
const handleClear = () => {
2024-09-15 20:17:02 +00:00
props.onCancel?.()
2024-06-24 17:50:27 +00:00
editor()?.commands.clearContent(true)
}
2024-09-15 16:41:02 +00:00
createEffect(() => {
if (props.setClear) {
editor()?.commands.clearContent(true)
}
if (props.resetToInitial) {
editor()?.commands.clearContent(true)
if (props.initialContent) editor()?.commands.setContent(props.initialContent)
}
})
2024-06-24 17:50:27 +00:00
const handleKeyDown = (event: KeyboardEvent) => {
if (isEmpty() || !isFocused()) {
return
}
2023-11-21 05:31:17 +00:00
if (event.code === 'Escape' && editor()) {
handleHideLinkBubble()
}
if (event.code === 'Enter' && props.submitByCtrlEnter && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
2024-06-24 17:50:27 +00:00
props.onSubmit?.(html() || '')
handleClear()
}
2023-11-21 05:31:17 +00:00
// if (event.code === 'KeyK' && (event.metaKey || event.ctrlKey) && !editor().state.selection.empty) {
// event.preventDefault()
// handleShowLinkBubble()
//
// }
}
onMount(() => {
window.addEventListener('keydown', handleKeyDown)
onCleanup(() => {
window.removeEventListener('keydown', handleKeyDown)
editor()?.destroy()
})
})
2023-07-28 19:53:21 +00:00
2024-09-15 16:41:02 +00:00
if (props.onChange) {
createEffect(() => {
props.onChange?.(html() || '')
})
}
2024-09-15 16:41:02 +00:00
createEffect(() => {
if (html()) {
setCounter(editor()?.storage.characterCount.characters())
}
})
const maxHeightStyle = {
overflow: 'auto',
2024-06-26 08:22:05 +00:00
'max-height': `${props.maxHeight}px`
}
const handleShowLinkBubble = () => {
2024-06-24 17:50:27 +00:00
editor()?.chain().focus().run()
setShouldShowLinkBubbleMenu(true)
}
2023-11-21 05:31:17 +00:00
const handleHideLinkBubble = () => {
2024-06-24 17:50:27 +00:00
editor()?.commands.focus()
2023-11-21 05:31:17 +00:00
setShouldShowLinkBubbleMenu(false)
}
return (
<ShowOnlyOnClient>
2024-07-26 15:49:15 +00:00
<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
})}
>
<Show when={props.maxLength && editor()}>
2024-09-15 20:17:02 +00:00
<div class={styles.limit}>{(props.maxLength || DEFAULT_MAX_LENGTH) - counter()}</div>
2024-07-26 15:49:15 +00:00
</Show>
<Show when={props.label && counter() > 0}>
<div class={styles.label}>{props.label}</div>
</Show>
2024-09-15 16:41:02 +00:00
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={setEditorElement} />
2024-07-26 15:49:15 +00:00
<Show when={!props.onlyBubbleControls}>
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
<div class={styles.actions}>
<Popover content={t('Bold')}>
{(triggerRef: (el: HTMLElement) => void) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: isBold() })}
onClick={() => editor()?.chain().focus().toggleBold().run()}
>
<Icon name="editor-bold" />
</button>
)}
</Popover>
<Popover content={t('Italic')}>
{(triggerRef) => (
<button
ref={triggerRef}
type="button"
class={clsx(styles.actionButton, { [styles.active]: isItalic() })}
onClick={() => editor()?.chain().focus().toggleItalic().run()}
>
<Icon name="editor-italic" />
</button>
)}
</Popover>
<Popover content={t('Add url')}>
{(triggerRef) => (
<button
ref={triggerRef}
type="button"
onClick={handleShowLinkBubble}
class={clsx(styles.actionButton, { [styles.active]: isLink() })}
>
<Icon name="editor-link" />
</button>
)}
</Popover>
<Show when={props.quoteEnabled}>
<Popover content={t('Add blockquote')}>
{(triggerRef) => (
<button
ref={triggerRef}
type="button"
2024-07-26 15:49:15 +00:00
onClick={() => editor()?.chain().focus().toggleBlockquote().run()}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
>
2024-07-26 15:49:15 +00:00
<Icon name="editor-quote" />
</button>
)}
</Popover>
2024-07-26 15:49:15 +00:00
</Show>
<Show when={props.imageEnabled}>
<Popover content={t('Add image')}>
{(triggerRef) => (
<button
ref={triggerRef}
type="button"
2024-07-26 15:49:15 +00:00
onClick={() => showModal('simplifiedEditorUploadImage')}
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
>
2024-07-26 15:49:15 +00:00
<Icon name="editor-image-dd-full" />
</button>
)}
</Popover>
2024-07-26 15:49:15 +00:00
</Show>
</div>
<Show when={!props.onChange}>
<div class={styles.buttons}>
2024-09-15 16:41:02 +00:00
<Show when={isCancelButtonVisible()}>
2024-07-26 15:49:15 +00:00
<Button value={t('Cancel')} variant="secondary" onClick={handleClear} />
</Show>
2024-07-26 15:49:15 +00:00
<Show when={!props.isPosting} fallback={<Loading />}>
<Button
value={props.submitButtonText ?? t('Send')}
variant="primary"
disabled={isEmpty()}
onClick={() => props.onSubmit?.(html() || '')}
/>
2024-05-06 18:45:17 +00:00
</Show>
</div>
</Show>
2024-07-26 15:49:15 +00:00
</div>
</Show>
<Show when={props.imageEnabled}>
<Portal>
<Modal variant="narrow" name="simplifiedEditorUploadImage">
<UploadModalContent
onClose={(value) => {
renderImage(value as UploadedFile)
}}
/>
</Modal>
</Portal>
</Show>
<Show when={props.onlyBubbleControls}>
<TextBubbleMenu
shouldShow={true}
isCommonMarkup={true}
editor={editor() as Editor}
2024-09-15 20:17:02 +00:00
ref={setTextBubbleMenuRef}
2024-07-26 15:49:15 +00:00
/>
</Show>
<LinkBubbleMenuModule
editor={editor() as Editor}
2024-09-15 20:17:02 +00:00
ref={setLinkBubbleMenuRef}
2024-07-26 15:49:15 +00:00
onClose={handleHideLinkBubble}
/>
</div>
</ShowOnlyOnClient>
)
}
2024-09-15 16:41:02 +00:00
export default SimplifiedEditor // "export default" need to use for asynchronous (lazy) imports in the comments tree