2023-11-14 15:10:00 +00:00
|
|
|
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'
|
2023-08-28 11:48:54 +00:00
|
|
|
import { Portal } from 'solid-js/web'
|
2023-07-24 08:58:07 +00:00
|
|
|
import {
|
|
|
|
createEditorTransaction,
|
|
|
|
createTiptapEditor,
|
|
|
|
useEditorHTML,
|
|
|
|
useEditorIsEmpty,
|
2024-06-26 08:22:05 +00:00
|
|
|
useEditorIsFocused
|
2023-07-24 08:58:07 +00:00
|
|
|
} 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'
|
2023-11-14 15:10:00 +00:00
|
|
|
import { Button } from '../_shared/Button'
|
2023-07-24 08:58:07 +00:00
|
|
|
import { Icon } from '../_shared/Icon'
|
2024-06-24 17:50:27 +00:00
|
|
|
import { Loading } from '../_shared/Loading'
|
2023-07-24 08:58:07 +00:00
|
|
|
import { Popover } from '../_shared/Popover'
|
2023-11-18 14:10:02 +00:00
|
|
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
2023-11-15 14:52:10 +00:00
|
|
|
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
|
2023-08-22 13:37:54 +00:00
|
|
|
import { TextBubbleMenu } from './TextBubbleMenu'
|
2023-11-14 15:10:00 +00:00
|
|
|
import { UploadModalContent } from './UploadModalContent'
|
2024-02-04 11:25:21 +00:00
|
|
|
import { Figcaption } from './extensions/Figcaption'
|
|
|
|
import { Figure } from './extensions/Figure'
|
2023-11-14 15:10:00 +00:00
|
|
|
|
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
|
|
|
|
2023-07-24 08:58:07 +00:00
|
|
|
type Props = {
|
2023-08-28 11:48:54 +00:00
|
|
|
placeholder: string
|
2023-07-24 08:58:07 +00:00
|
|
|
initialContent?: string
|
2023-08-22 13:37:54 +00:00
|
|
|
label?: string
|
2023-08-13 21:26:40 +00:00
|
|
|
onSubmit?: (text: string) => void
|
2023-08-28 11:48:54 +00:00
|
|
|
onCancel?: () => void
|
2023-08-17 10:36:27 +00:00
|
|
|
onChange?: (text: string) => void
|
2023-08-22 13:37:54 +00:00
|
|
|
variant?: 'minimal' | 'bordered'
|
|
|
|
maxLength?: number
|
2024-05-12 23:36:46 +00:00
|
|
|
noLimits?: boolean
|
2023-09-05 06:47:44 +00:00
|
|
|
maxHeight?: number
|
2023-07-27 17:38:38 +00:00
|
|
|
submitButtonText?: string
|
2023-07-24 08:58:07 +00:00
|
|
|
quoteEnabled?: boolean
|
|
|
|
imageEnabled?: boolean
|
|
|
|
setClear?: boolean
|
2024-05-12 23:36:46 +00:00
|
|
|
resetToInitial?: boolean
|
2023-07-24 08:58:07 +00:00
|
|
|
smallHeight?: boolean
|
2023-10-16 07:40:34 +00:00
|
|
|
submitByCtrlEnter?: boolean
|
2023-08-22 13:37:54 +00:00
|
|
|
onlyBubbleControls?: boolean
|
2023-09-05 05:49:19 +00:00
|
|
|
controlsAlwaysVisible?: boolean
|
2023-10-16 07:40:34 +00:00
|
|
|
autoFocus?: boolean
|
2023-11-01 15:13:54 +00:00
|
|
|
isCancelButtonVisible?: boolean
|
2024-05-06 18:45:17 +00:00
|
|
|
isPosting?: boolean
|
2023-07-24 08:58:07 +00:00
|
|
|
}
|
|
|
|
|
2023-11-18 14:10:02 +00:00
|
|
|
const DEFAULT_MAX_LENGTH = 400
|
2024-09-15 20:17:02 +00:00
|
|
|
const ImageFigure = Figure.extend({ name: 'capturedImage', content: 'figcaption image' })
|
2023-09-05 05:49:19 +00:00
|
|
|
|
2023-07-24 08:58:07 +00:00
|
|
|
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(),
|
2024-09-15 20:17:21 +00:00
|
|
|
shouldShow: ({ view, state }) =>
|
|
|
|
Boolean(props.onlyBubbleControls && view.hasFocus() && !state.selection.empty)
|
2024-09-15 20:17:02 +00:00
|
|
|
}),
|
|
|
|
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 || ''
|
|
|
|
}))
|
2023-07-24 08:58:07 +00:00
|
|
|
|
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)
|
2023-07-24 08:58:07 +00:00
|
|
|
const isBold = isActive('bold')
|
|
|
|
const isItalic = isActive('italic')
|
|
|
|
const isLink = isActive('link')
|
|
|
|
const isBlockquote = isActive('blockquote')
|
|
|
|
|
2023-08-15 09:38:49 +00:00
|
|
|
const renderImage = (image: UploadedFile) => {
|
2024-09-15 20:17:02 +00:00
|
|
|
renderUploadedImage(editor() as Editor, image)
|
2023-07-24 08:58:07 +00:00
|
|
|
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)
|
2023-07-24 08:58:07 +00:00
|
|
|
}
|
2023-08-13 21:26:40 +00:00
|
|
|
|
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) => {
|
2023-08-13 21:26:40 +00:00
|
|
|
if (isEmpty() || !isFocused()) {
|
2023-07-28 09:47:19 +00:00
|
|
|
return
|
2023-07-24 08:58:07 +00:00
|
|
|
}
|
|
|
|
|
2023-11-21 05:31:17 +00:00
|
|
|
if (event.code === 'Escape' && editor()) {
|
|
|
|
handleHideLinkBubble()
|
|
|
|
}
|
|
|
|
|
2023-10-16 07:40:34 +00:00
|
|
|
if (event.code === 'Enter' && props.submitByCtrlEnter && (event.metaKey || event.ctrlKey)) {
|
2023-07-24 08:58:07 +00:00
|
|
|
event.preventDefault()
|
2024-06-24 17:50:27 +00:00
|
|
|
props.onSubmit?.(html() || '')
|
2023-07-24 08:58:07 +00:00
|
|
|
handleClear()
|
|
|
|
}
|
2023-07-28 09:47:19 +00:00
|
|
|
|
2023-11-21 05:31:17 +00:00
|
|
|
// if (event.code === 'KeyK' && (event.metaKey || event.ctrlKey) && !editor().state.selection.empty) {
|
|
|
|
// event.preventDefault()
|
|
|
|
// handleShowLinkBubble()
|
|
|
|
//
|
|
|
|
// }
|
2023-07-24 08:58:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
onMount(() => {
|
2023-07-27 17:38:38 +00:00
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
2023-09-16 16:32:29 +00:00
|
|
|
onCleanup(() => {
|
|
|
|
window.removeEventListener('keydown', handleKeyDown)
|
|
|
|
editor()?.destroy()
|
|
|
|
})
|
2023-07-24 08:58:07 +00:00
|
|
|
})
|
2023-07-28 19:53:21 +00:00
|
|
|
|
2024-09-15 16:41:02 +00:00
|
|
|
if (props.onChange) {
|
|
|
|
createEffect(() => {
|
|
|
|
props.onChange?.(html() || '')
|
|
|
|
})
|
|
|
|
}
|
2023-08-17 10:36:27 +00:00
|
|
|
|
2024-09-15 16:41:02 +00:00
|
|
|
createEffect(() => {
|
|
|
|
if (html()) {
|
|
|
|
setCounter(editor()?.storage.characterCount.characters())
|
|
|
|
}
|
|
|
|
})
|
2023-09-05 05:49:19 +00:00
|
|
|
|
2023-09-05 06:47:44 +00:00
|
|
|
const maxHeightStyle = {
|
|
|
|
overflow: 'auto',
|
2024-06-26 08:22:05 +00:00
|
|
|
'max-height': `${props.maxHeight}px`
|
2023-09-05 06:47:44 +00:00
|
|
|
}
|
|
|
|
|
2023-11-15 14:52:10 +00:00
|
|
|
const handleShowLinkBubble = () => {
|
2024-06-24 17:50:27 +00:00
|
|
|
editor()?.chain().focus().run()
|
2023-11-15 14:52:10 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2023-07-24 08:58:07 +00:00
|
|
|
return (
|
2023-11-18 14:10:02 +00:00
|
|
|
<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')}>
|
2023-11-18 14:10:02 +00:00
|
|
|
{(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() })}
|
2023-11-18 14:10:02 +00:00
|
|
|
>
|
2024-07-26 15:49:15 +00:00
|
|
|
<Icon name="editor-quote" />
|
2023-11-18 14:10:02 +00:00
|
|
|
</button>
|
|
|
|
)}
|
|
|
|
</Popover>
|
2024-07-26 15:49:15 +00:00
|
|
|
</Show>
|
|
|
|
<Show when={props.imageEnabled}>
|
|
|
|
<Popover content={t('Add image')}>
|
2023-11-18 14:10:02 +00:00
|
|
|
{(triggerRef) => (
|
|
|
|
<button
|
|
|
|
ref={triggerRef}
|
|
|
|
type="button"
|
2024-07-26 15:49:15 +00:00
|
|
|
onClick={() => showModal('simplifiedEditorUploadImage')}
|
|
|
|
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
2023-11-18 14:10:02 +00:00
|
|
|
>
|
2024-07-26 15:49:15 +00:00
|
|
|
<Icon name="editor-image-dd-full" />
|
2023-11-18 14:10:02 +00:00
|
|
|
</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} />
|
2023-11-18 14:10:02 +00:00
|
|
|
</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>
|
2023-11-18 14:10:02 +00:00
|
|
|
</div>
|
2023-08-22 13:37:54 +00:00
|
|
|
</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>
|
2023-11-18 14:10:02 +00:00
|
|
|
</ShowOnlyOnClient>
|
2023-07-24 08:58:07 +00:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2024-09-15 16:41:02 +00:00
|
|
|
export default SimplifiedEditor // "export default" need to use for asynchronous (lazy) imports in the comments tree
|