editor-wip
This commit is contained in:
parent
64224720f5
commit
1db4224827
|
@ -144,12 +144,7 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20"
|
"node": ">= 20"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": ["@biomejs/biome", "@swc/core", "esbuild", "protobufjs"],
|
||||||
"@biomejs/biome",
|
|
||||||
"@swc/core",
|
|
||||||
"esbuild",
|
|
||||||
"protobufjs"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
|
|
133
src/components/Editor/EditorToolbar.tsx
Normal file
133
src/components/Editor/EditorToolbar.tsx
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Show } from 'solid-js'
|
||||||
|
import { createEditorTransaction, useEditorHTML, useEditorIsEmpty } from 'solid-tiptap'
|
||||||
|
import { useEditorContext } from '~/context/editor'
|
||||||
|
import { useLocalize } from '~/context/localize'
|
||||||
|
import { useUI } from '~/context/ui'
|
||||||
|
import { Button } from '../_shared/Button'
|
||||||
|
import { Icon } from '../_shared/Icon'
|
||||||
|
import { Loading } from '../_shared/Loading'
|
||||||
|
import { Popover } from '../_shared/Popover'
|
||||||
|
import { SimplifiedEditorProps } from './SimplifiedEditor'
|
||||||
|
|
||||||
|
import styles from './SimplifiedEditor.module.scss'
|
||||||
|
|
||||||
|
export const ToolbarControls = (
|
||||||
|
props: SimplifiedEditorProps & { setShouldShowLinkBubbleMenu: (x: boolean) => void }
|
||||||
|
) => {
|
||||||
|
const { t } = useLocalize()
|
||||||
|
const { showModal } = useUI()
|
||||||
|
const { editor } = useEditorContext()
|
||||||
|
const isActive = (name: string) => createEditorTransaction(editor, (ed) => ed?.isActive(name))
|
||||||
|
const isBold = isActive('bold')
|
||||||
|
const isItalic = isActive('italic')
|
||||||
|
const isLink = isActive('link')
|
||||||
|
const isBlockquote = isActive('blockquote')
|
||||||
|
const isEmpty = useEditorIsEmpty(editor)
|
||||||
|
const html = useEditorHTML(editor)
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
props.onCancel?.()
|
||||||
|
editor()?.commands.clearContent(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShowLinkBubble = () => {
|
||||||
|
editor()?.chain().focus().run()
|
||||||
|
props.setShouldShowLinkBubbleMenu(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={!props.hideToolbar}>
|
||||||
|
{/* Only show controls if 'hideToolbar' is false */}
|
||||||
|
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
|
||||||
|
<div class={styles.actions}>
|
||||||
|
{/* Bold button */}
|
||||||
|
<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>
|
||||||
|
{/* Italic button */}
|
||||||
|
<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>
|
||||||
|
{/* Link button */}
|
||||||
|
<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>
|
||||||
|
{/* Blockquote button (optional) */}
|
||||||
|
<Show when={props.quoteEnabled}>
|
||||||
|
<Popover content={t('Add blockquote')}>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => editor()?.chain().focus().toggleBlockquote().run()}
|
||||||
|
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
||||||
|
>
|
||||||
|
<Icon name="editor-quote" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
</Show>
|
||||||
|
{/* Image button (optional) */}
|
||||||
|
<Show when={props.imageEnabled}>
|
||||||
|
<Popover content={t('Add image')}>
|
||||||
|
{(triggerRef) => (
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => showModal('simplifiedEditorUploadImage')}
|
||||||
|
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
||||||
|
>
|
||||||
|
<Icon name="editor-image-dd-full" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
{/* Cancel and submit buttons */}
|
||||||
|
<Show when={!props.onChange}>
|
||||||
|
<div class={styles.buttons}>
|
||||||
|
<Show when={props.isCancelButtonVisible}>
|
||||||
|
<Button value={t('Cancel')} variant="secondary" onClick={handleClear} />
|
||||||
|
</Show>
|
||||||
|
<Show when={!props.isPosting} fallback={<Loading />}>
|
||||||
|
<Button
|
||||||
|
value={props.submitButtonText ?? t('Send')}
|
||||||
|
variant="primary"
|
||||||
|
disabled={isEmpty()}
|
||||||
|
onClick={() => props.onSubmit?.(html() || '')}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
|
@ -11,12 +11,11 @@ import {
|
||||||
useEditorIsFocused
|
useEditorIsFocused
|
||||||
} from 'solid-tiptap'
|
} from 'solid-tiptap'
|
||||||
import { Toolbar } from 'terracotta'
|
import { Toolbar } from 'terracotta'
|
||||||
|
|
||||||
import { Icon } from '~/components/_shared/Icon/Icon'
|
import { Icon } from '~/components/_shared/Icon/Icon'
|
||||||
import { Popover } from '~/components/_shared/Popover/Popover'
|
import { Popover } from '~/components/_shared/Popover/Popover'
|
||||||
import { useLocalize } from '~/context/localize'
|
import { useLocalize } from '~/context/localize'
|
||||||
import { useUI } from '~/context/ui'
|
import { useUI } from '~/context/ui'
|
||||||
import { base, custom } from '~/lib/editorOptions'
|
import { base } from '~/lib/editorOptions'
|
||||||
import { InsertLinkForm } from '../InsertLinkForm/InsertLinkForm'
|
import { InsertLinkForm } from '../InsertLinkForm/InsertLinkForm'
|
||||||
|
|
||||||
import styles from '../SimplifiedEditor.module.scss'
|
import styles from '../SimplifiedEditor.module.scss'
|
||||||
|
@ -72,7 +71,6 @@ export default function MiniEditor(props: MiniEditorProps): JSX.Element {
|
||||||
element: editorElement()!,
|
element: editorElement()!,
|
||||||
extensions: [
|
extensions: [
|
||||||
...base,
|
...base,
|
||||||
...custom,
|
|
||||||
Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }),
|
Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }),
|
||||||
CharacterCount.configure({ limit: props.limit })
|
CharacterCount.configure({ limit: props.limit })
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,40 +1,27 @@
|
||||||
import { Editor } from '@tiptap/core'
|
import { Editor, FocusPosition } from '@tiptap/core'
|
||||||
import { Blockquote } from '@tiptap/extension-blockquote'
|
|
||||||
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
import { BubbleMenu } from '@tiptap/extension-bubble-menu'
|
||||||
import { CharacterCount } from '@tiptap/extension-character-count'
|
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 { Placeholder } from '@tiptap/extension-placeholder'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js'
|
import { Show, createEffect, createSignal, on, onCleanup, onMount } from 'solid-js'
|
||||||
import { Portal } from 'solid-js/web'
|
import { Portal } from 'solid-js/web'
|
||||||
import {
|
import { createEditorTransaction, useEditorHTML, useEditorIsEmpty, useEditorIsFocused } from 'solid-tiptap'
|
||||||
createEditorTransaction,
|
import { useEditorContext } from '~/context/editor'
|
||||||
createTiptapEditor,
|
|
||||||
useEditorHTML,
|
|
||||||
useEditorIsEmpty,
|
|
||||||
useEditorIsFocused
|
|
||||||
} from 'solid-tiptap'
|
|
||||||
import { useLocalize } from '~/context/localize'
|
|
||||||
import { useUI } from '~/context/ui'
|
import { useUI } from '~/context/ui'
|
||||||
import { base } from '~/lib/editorOptions'
|
import { base, custom } from '~/lib/editorOptions'
|
||||||
|
import { useEscKeyDownHandler } from '~/lib/useEscKeyDownHandler'
|
||||||
import { UploadedFile } from '~/types/upload'
|
import { UploadedFile } from '~/types/upload'
|
||||||
import { Button } from '../_shared/Button'
|
|
||||||
import { Icon } from '../_shared/Icon'
|
|
||||||
import { Loading } from '../_shared/Loading'
|
|
||||||
import { Modal } from '../_shared/Modal/Modal'
|
import { Modal } from '../_shared/Modal/Modal'
|
||||||
import { Popover } from '../_shared/Popover'
|
|
||||||
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
import { ShowOnlyOnClient } from '../_shared/ShowOnlyOnClient'
|
||||||
|
import { ToolbarControls } from './EditorToolbar'
|
||||||
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
|
import { LinkBubbleMenuModule } from './LinkBubbleMenu'
|
||||||
import { TextBubbleMenu } from './TextBubbleMenu'
|
import { TextBubbleMenu } from './TextBubbleMenu'
|
||||||
import { UploadModalContent } from './UploadModalContent'
|
import { UploadModalContent } from './UploadModalContent'
|
||||||
import { Figcaption } from './extensions/Figcaption'
|
|
||||||
import { Figure } from './extensions/Figure'
|
|
||||||
import { renderUploadedImage } from './renderUploadedImage'
|
import { renderUploadedImage } from './renderUploadedImage'
|
||||||
|
|
||||||
import styles from './SimplifiedEditor.module.scss'
|
import styles from './SimplifiedEditor.module.scss'
|
||||||
|
|
||||||
type Props = {
|
export type SimplifiedEditorProps = {
|
||||||
placeholder: string
|
placeholder: string
|
||||||
initialContent?: string
|
initialContent?: string
|
||||||
label?: string
|
label?: string
|
||||||
|
@ -52,7 +39,7 @@ type Props = {
|
||||||
resetToInitial?: boolean
|
resetToInitial?: boolean
|
||||||
smallHeight?: boolean
|
smallHeight?: boolean
|
||||||
submitByCtrlEnter?: boolean
|
submitByCtrlEnter?: boolean
|
||||||
onlyBubbleControls?: boolean
|
hideToolbar?: boolean
|
||||||
controlsAlwaysVisible?: boolean
|
controlsAlwaysVisible?: boolean
|
||||||
autoFocus?: boolean
|
autoFocus?: boolean
|
||||||
isCancelButtonVisible?: boolean
|
isCancelButtonVisible?: boolean
|
||||||
|
@ -60,100 +47,75 @@ type Props = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MAX_LENGTH = 400
|
const DEFAULT_MAX_LENGTH = 400
|
||||||
const ImageFigure = Figure.extend({ name: 'capturedImage', content: 'figcaption image' })
|
|
||||||
|
|
||||||
const SimplifiedEditor = (props: Props) => {
|
const SimplifiedEditor = (props: SimplifiedEditorProps) => {
|
||||||
const { t } = useLocalize()
|
// local signals
|
||||||
const { showModal, hideModal } = useUI()
|
|
||||||
const [counter, setCounter] = createSignal<number>(0)
|
const [counter, setCounter] = createSignal<number>(0)
|
||||||
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
|
const [shouldShowLinkBubbleMenu, setShouldShowLinkBubbleMenu] = createSignal(false)
|
||||||
const isCancelButtonVisible = createMemo(() => props.isCancelButtonVisible !== false)
|
const [shouldShowTextBubbleMenu, setShouldShowTextBubbleMenu] = createSignal(false)
|
||||||
const [editorElement, setEditorElement] = createSignal<HTMLDivElement>()
|
const [editorElement, setEditorElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const editor = createTiptapEditor(() => ({
|
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
element: editorElement()!,
|
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: [
|
extensions: [
|
||||||
|
// common extensions
|
||||||
...base,
|
...base,
|
||||||
|
...custom,
|
||||||
|
|
||||||
|
// setup from component props
|
||||||
Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }),
|
Placeholder.configure({ emptyNodeClass: styles.emptyNode, placeholder: props.placeholder }),
|
||||||
CharacterCount.configure({ limit: props.noLimits ? undefined : props.maxLength }),
|
CharacterCount.configure({ limit: props.noLimits ? undefined : props.maxLength }),
|
||||||
Link.extend({ inclusive: false }).configure({ autolink: true, openOnClick: false }),
|
|
||||||
Blockquote.configure({ HTMLAttributes: { class: styles.blockQuote } }),
|
// bubble menu 1
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'textBubbleMenu',
|
pluginKey: 'bubble-menu',
|
||||||
element: textBubbleMenuRef(),
|
element: textBubbleMenuRef(),
|
||||||
shouldShow: ({ view, state }) =>
|
shouldShow: ({ view }) => view.hasFocus() && shouldShowTextBubbleMenu()
|
||||||
Boolean(props.onlyBubbleControls && view.hasFocus() && !state.selection.empty)
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// bubble menu 2
|
||||||
BubbleMenu.configure({
|
BubbleMenu.configure({
|
||||||
pluginKey: 'linkBubbleMenu',
|
pluginKey: 'bubble-link-input',
|
||||||
element: linkBubbleMenuRef(),
|
element: linkBubbleMenuRef(),
|
||||||
shouldShow: ({ state }) => !state.selection.empty && shouldShowLinkBubbleMenu(),
|
shouldShow: ({ state }) => !state.selection.empty && shouldShowLinkBubbleMenu(),
|
||||||
tippyOptions: { placement: 'bottom' }
|
tippyOptions: { placement: 'bottom' }
|
||||||
}),
|
})
|
||||||
ImageFigure,
|
|
||||||
Image,
|
|
||||||
Figcaption
|
|
||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: { class: styles.simplifiedEditorField }
|
||||||
class: styles.simplifiedEditorField
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
content: props.initialContent || ''
|
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
|
||||||
|
}
|
||||||
|
|
||||||
const [textBubbleMenuRef, setTextBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
createEditor(opts)
|
||||||
const [linkBubbleMenuRef, setLinkBubbleMenuRef] = createSignal<HTMLDivElement | undefined>()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// editor observers
|
||||||
const isEmpty = useEditorIsEmpty(editor)
|
const isEmpty = useEditorIsEmpty(editor)
|
||||||
const isFocused = useEditorIsFocused(editor)
|
const isFocused = useEditorIsFocused(editor)
|
||||||
const isActive = (name: string) => createEditorTransaction(editor, (ed) => ed?.isActive(name))
|
const selection = createEditorTransaction(editor, (ed) => ed?.state.selection)
|
||||||
const html = useEditorHTML(editor)
|
const html = useEditorHTML(editor)
|
||||||
const isBold = isActive('bold')
|
|
||||||
const isItalic = isActive('italic')
|
|
||||||
const isLink = isActive('link')
|
|
||||||
const isBlockquote = isActive('blockquote')
|
|
||||||
|
|
||||||
const renderImage = (image: UploadedFile) => {
|
/// EFFECTS ///
|
||||||
renderUploadedImage(editor() as Editor, image)
|
|
||||||
hideModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClear = () => {
|
|
||||||
props.onCancel?.()
|
|
||||||
editor()?.commands.clearContent(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (props.setClear) {
|
|
||||||
editor()?.commands.clearContent(true)
|
|
||||||
}
|
|
||||||
if (props.resetToInitial) {
|
|
||||||
editor()?.commands.clearContent(true)
|
|
||||||
if (props.initialContent) editor()?.commands.setContent(props.initialContent)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (isEmpty() || !isFocused()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.code === 'Escape' && editor()) {
|
|
||||||
handleHideLinkBubble()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.code === 'Enter' && props.submitByCtrlEnter && (event.metaKey || event.ctrlKey)) {
|
|
||||||
event.preventDefault()
|
|
||||||
props.onSubmit?.(html() || '')
|
|
||||||
handleClear()
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (event.code === 'KeyK' && (event.metaKey || event.ctrlKey) && !editor().state.selection.empty) {
|
|
||||||
// event.preventDefault()
|
|
||||||
// handleShowLinkBubble()
|
|
||||||
//
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Mount event listeners for handling key events and clean up on component unmount
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
@ -162,26 +124,44 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (props.onChange) {
|
// watch changes
|
||||||
createEffect(() => {
|
createEffect(on(editorElement, initEditor, { defer: true })) // element -> editorOptions -> set editor
|
||||||
props.onChange?.(html() || '')
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (html()) {
|
if (
|
||||||
setCounter(editor()?.storage.characterCount.characters())
|
isFocused() &&
|
||||||
|
!isEmpty() &&
|
||||||
|
event.code === 'Enter' &&
|
||||||
|
props.submitByCtrlEnter &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
props.onSubmit?.(html() || '')
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const maxHeightStyle = {
|
|
||||||
overflow: 'auto',
|
|
||||||
'max-height': `${props.maxHeight}px`
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShowLinkBubble = () => {
|
|
||||||
editor()?.chain().focus().run()
|
|
||||||
setShouldShowLinkBubbleMenu(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleHideLinkBubble = () => {
|
const handleHideLinkBubble = () => {
|
||||||
|
@ -189,6 +169,8 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
setShouldShowLinkBubbleMenu(false)
|
setShouldShowLinkBubbleMenu(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEscKeyDownHandler(handleHideLinkBubble)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShowOnlyOnClient>
|
<ShowOnlyOnClient>
|
||||||
<div
|
<div
|
||||||
|
@ -200,125 +182,63 @@ const SimplifiedEditor = (props: Props) => {
|
||||||
[styles.labelVisible]: props.label && counter() > 0
|
[styles.labelVisible]: props.label && counter() > 0
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Show when={props.maxLength && editor()}>
|
{/* Display label when applicable */}
|
||||||
<div class={styles.limit}>{(props.maxLength || DEFAULT_MAX_LENGTH) - counter()}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.label && counter() > 0}>
|
<Show when={props.label && counter() > 0}>
|
||||||
<div class={styles.label}>{props.label}</div>
|
<div class={styles.label}>{props.label}</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div style={props.maxHeight ? maxHeightStyle : undefined} ref={setEditorElement} />
|
|
||||||
<Show when={!props.onlyBubbleControls}>
|
<Show
|
||||||
<div class={clsx(styles.controls, { [styles.alwaysVisible]: props.controlsAlwaysVisible })}>
|
when={props.hideToolbar}
|
||||||
<div class={styles.actions}>
|
fallback={
|
||||||
<Popover content={t('Bold')}>
|
<ToolbarControls {...props} setShouldShowLinkBubbleMenu={setShouldShowLinkBubbleMenu} />
|
||||||
{(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"
|
|
||||||
onClick={() => editor()?.chain().focus().toggleBlockquote().run()}
|
|
||||||
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
|
||||||
>
|
|
||||||
<Icon name="editor-quote" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Popover>
|
|
||||||
</Show>
|
|
||||||
<Show when={props.imageEnabled}>
|
|
||||||
<Popover content={t('Add image')}>
|
|
||||||
{(triggerRef) => (
|
|
||||||
<button
|
|
||||||
ref={triggerRef}
|
|
||||||
type="button"
|
|
||||||
onClick={() => showModal('simplifiedEditorUploadImage')}
|
|
||||||
class={clsx(styles.actionButton, { [styles.active]: isBlockquote() })}
|
|
||||||
>
|
|
||||||
<Icon name="editor-image-dd-full" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Popover>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<Show when={!props.onChange}>
|
|
||||||
<div class={styles.buttons}>
|
|
||||||
<Show when={isCancelButtonVisible()}>
|
|
||||||
<Button value={t('Cancel')} variant="secondary" onClick={handleClear} />
|
|
||||||
</Show>
|
|
||||||
<Show when={!props.isPosting} fallback={<Loading />}>
|
|
||||||
<Button
|
|
||||||
value={props.submitButtonText ?? t('Send')}
|
|
||||||
variant="primary"
|
|
||||||
disabled={isEmpty()}
|
|
||||||
onClick={() => props.onSubmit?.(html() || '')}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</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
|
<TextBubbleMenu
|
||||||
shouldShow={true}
|
|
||||||
isCommonMarkup={true}
|
|
||||||
editor={editor() as Editor}
|
editor={editor() as Editor}
|
||||||
ref={setTextBubbleMenuRef}
|
ref={setTextBubbleMenuRef}
|
||||||
|
shouldShow={shouldShowTextBubbleMenu()}
|
||||||
|
isCommonMarkup={true}
|
||||||
/>
|
/>
|
||||||
</Show>
|
|
||||||
|
{/* Link bubble menu */}
|
||||||
|
<Show when={shouldShowLinkBubbleMenu()}>
|
||||||
<LinkBubbleMenuModule
|
<LinkBubbleMenuModule
|
||||||
editor={editor() as Editor}
|
editor={editor() as Editor}
|
||||||
ref={setLinkBubbleMenuRef}
|
ref={setLinkBubbleMenuRef}
|
||||||
onClose={handleHideLinkBubble}
|
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>
|
</div>
|
||||||
</ShowOnlyOnClient>
|
</ShowOnlyOnClient>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SimplifiedEditor // "export default" need to use for asynchronous (lazy) imports in the comments tree
|
export default SimplifiedEditor // Export component for lazy loading
|
||||||
|
|
|
@ -371,7 +371,7 @@ export const EditView = (props: Props) => {
|
||||||
<Show when={isLeadVisible()}>
|
<Show when={isLeadVisible()}>
|
||||||
<SimplifiedEditor
|
<SimplifiedEditor
|
||||||
variant="minimal"
|
variant="minimal"
|
||||||
onlyBubbleControls={true}
|
hideToolbar={true}
|
||||||
smallHeight={true}
|
smallHeight={true}
|
||||||
placeholder={t('A short introduction to keep the reader interested')}
|
placeholder={t('A short introduction to keep the reader interested')}
|
||||||
initialContent={form.lead}
|
initialContent={form.lead}
|
||||||
|
|
|
@ -344,7 +344,7 @@ export const ProfileSettings = () => {
|
||||||
resetToInitial={true}
|
resetToInitial={true}
|
||||||
noLimits={true}
|
noLimits={true}
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onlyBubbleControls={true}
|
hideToolbar={true}
|
||||||
smallHeight={true}
|
smallHeight={true}
|
||||||
label={t('About')}
|
label={t('About')}
|
||||||
initialContent={about() || ''}
|
initialContent={about() || ''}
|
||||||
|
|
|
@ -226,7 +226,7 @@ export const PublishSettings = (props: Props) => {
|
||||||
/>
|
/>
|
||||||
<SimplifiedEditor
|
<SimplifiedEditor
|
||||||
variant="bordered"
|
variant="bordered"
|
||||||
onlyBubbleControls={true}
|
hideToolbar={true}
|
||||||
smallHeight={true}
|
smallHeight={true}
|
||||||
placeholder={t('Write a short introduction')}
|
placeholder={t('Write a short introduction')}
|
||||||
label={t('Description')}
|
label={t('Description')}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { useMatch, useNavigate } from '@solidjs/router'
|
import { useMatch, useNavigate } from '@solidjs/router'
|
||||||
import { Editor } from '@tiptap/core'
|
import { Editor, EditorOptions } from '@tiptap/core'
|
||||||
import type { JSX } from 'solid-js'
|
import type { JSX } from 'solid-js'
|
||||||
import { Accessor, createContext, createMemo, createSignal, useContext } from 'solid-js'
|
import { Accessor, createContext, createMemo, createSignal, useContext } from 'solid-js'
|
||||||
import { SetStoreFunction, createStore } from 'solid-js/store'
|
import { SetStoreFunction, createStore } from 'solid-js/store'
|
||||||
|
import { createTiptapEditor } from 'solid-tiptap'
|
||||||
import { coreApiUrl } from '~/config'
|
import { coreApiUrl } from '~/config'
|
||||||
import { useSnackbar } from '~/context/ui'
|
import { useSnackbar } from '~/context/ui'
|
||||||
import deleteShoutQuery from '~/graphql/mutation/core/article-delete'
|
import deleteShoutQuery from '~/graphql/mutation/core/article-delete'
|
||||||
|
@ -51,7 +52,7 @@ export type EditorContextType = {
|
||||||
setForm: SetStoreFunction<ShoutForm>
|
setForm: SetStoreFunction<ShoutForm>
|
||||||
setFormErrors: SetStoreFunction<Record<keyof ShoutForm, string>>
|
setFormErrors: SetStoreFunction<Record<keyof ShoutForm, string>>
|
||||||
editor: Accessor<Editor | undefined>
|
editor: Accessor<Editor | undefined>
|
||||||
setEditor: (e: Editor) => void
|
createEditor: (opts?: Partial<EditorOptions>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorContext = createContext<EditorContextType>({} as EditorContextType)
|
export const EditorContext = createContext<EditorContextType>({} as EditorContextType)
|
||||||
|
@ -270,6 +271,18 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createEditor = (opts?: Partial<EditorOptions>) => {
|
||||||
|
if (!opts) return
|
||||||
|
const old = editor() as Editor
|
||||||
|
const fresh = createTiptapEditor(() => ({
|
||||||
|
...old.options,
|
||||||
|
...opts,
|
||||||
|
element: opts.element as HTMLElement
|
||||||
|
}))
|
||||||
|
old?.destroy()
|
||||||
|
setEditor(fresh())
|
||||||
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
saveShout,
|
saveShout,
|
||||||
saveDraft,
|
saveDraft,
|
||||||
|
@ -283,7 +296,7 @@ export const EditorProvider = (props: { children: JSX.Element }) => {
|
||||||
setForm,
|
setForm,
|
||||||
setFormErrors,
|
setFormErrors,
|
||||||
editor,
|
editor,
|
||||||
setEditor
|
createEditor
|
||||||
}
|
}
|
||||||
|
|
||||||
const value: EditorContextType = {
|
const value: EditorContextType = {
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
"Invalid email": "Check if your email is correct",
|
"Invalid email": "Check if your email is correct",
|
||||||
"Join our maillist": "To receive the best postings, just enter your email",
|
"Join our maillist": "To receive the best postings, just enter your email",
|
||||||
"Join the global community of authors!": "Join the global community of authors from all over the world!",
|
"Join the global community of authors!": "Join the global community of authors from all over the world!",
|
||||||
|
"Registered since {date}": "Registered since {date}",
|
||||||
"shout": "post",
|
"shout": "post",
|
||||||
"some authors": "{count} {count, plural, one {author} other {authors}}",
|
"some authors": "{count} {count, plural, one {author} other {authors}}",
|
||||||
"some comments": "{count, plural, =0 {{count} comments} one {{count} comment} few {{count} comments} other {{count} comments}}",
|
"some comments": "{count, plural, =0 {{count} comments} one {{count} comment} few {{count} comments} other {{count} comments}}",
|
||||||
|
|
|
@ -13,12 +13,6 @@ import { Span } from '~/components/Editor/extensions/Span'
|
||||||
import { ToggleTextWrap } from '~/components/Editor/extensions/ToggleTextWrap'
|
import { ToggleTextWrap } from '~/components/Editor/extensions/ToggleTextWrap'
|
||||||
import { TrailingNode } from '~/components/Editor/extensions/TrailingNode'
|
import { TrailingNode } from '~/components/Editor/extensions/TrailingNode'
|
||||||
|
|
||||||
// Extend the Figure extension to include Figcaption
|
|
||||||
const ImageFigure = Figure.extend({
|
|
||||||
name: 'capturedImage',
|
|
||||||
content: 'figcaption image'
|
|
||||||
})
|
|
||||||
|
|
||||||
export const base: EditorOptions['extensions'] = [
|
export const base: EditorOptions['extensions'] = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
heading: {
|
heading: {
|
||||||
|
@ -32,10 +26,7 @@ export const base: EditorOptions['extensions'] = [
|
||||||
blockquote: undefined
|
blockquote: undefined
|
||||||
}),
|
}),
|
||||||
Underline, // не входит в StarterKit
|
Underline, // не входит в StarterKit
|
||||||
Link.configure({
|
Link.configure({ autolink: true, openOnClick: false }),
|
||||||
autolink: true,
|
|
||||||
openOnClick: false
|
|
||||||
}),
|
|
||||||
Image,
|
Image,
|
||||||
Highlight.configure({
|
Highlight.configure({
|
||||||
multicolor: true,
|
multicolor: true,
|
||||||
|
@ -45,20 +36,28 @@ export const base: EditorOptions['extensions'] = [
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Extend the Figure extension to include Figcaption
|
||||||
|
export const ImageFigure = Figure.extend({
|
||||||
|
name: 'capturedImage',
|
||||||
|
content: 'figcaption image'
|
||||||
|
})
|
||||||
|
|
||||||
export const custom: EditorOptions['extensions'] = [
|
export const custom: EditorOptions['extensions'] = [
|
||||||
ImageFigure,
|
ImageFigure,
|
||||||
Figure,
|
Figure,
|
||||||
Figcaption,
|
Figcaption,
|
||||||
Footnote,
|
|
||||||
CustomBlockquote,
|
|
||||||
Iframe,
|
Iframe,
|
||||||
Span,
|
|
||||||
ToggleTextWrap,
|
ToggleTextWrap,
|
||||||
|
Span,
|
||||||
TrailingNode
|
TrailingNode
|
||||||
// Добавьте другие кастомные расширения здесь
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export const collab: EditorOptions['extensions'] = []
|
export const extended: EditorOptions['extensions'] = [
|
||||||
|
Footnote,
|
||||||
|
CustomBlockquote
|
||||||
|
// TODO: Добавьте другие кастомные расширения здесь
|
||||||
|
]
|
||||||
|
|
||||||
/*
|
/*
|
||||||
content: '',
|
content: '',
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// biome-ignore lint/correctness/noNodejsModules: <explanation>
|
// biome-ignore lint/correctness/noNodejsModules: used during build
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { CSSOptions } from 'vite'
|
import { CSSOptions } from 'vite'
|
||||||
import mkcert from 'vite-plugin-mkcert'
|
import mkcert from 'vite-plugin-mkcert'
|
||||||
|
|
Loading…
Reference in New Issue
Block a user