mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-13 07:46:10 +00:00
213 lines
5.9 KiB
TypeScript
213 lines
5.9 KiB
TypeScript
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
import { Color } from '@tiptap/extension-color'
|
|
import { Link } from '@tiptap/extension-link'
|
|
import { Placeholder } from '@tiptap/extension-placeholder'
|
|
import { Table } from '@tiptap/extension-table'
|
|
import { TableCell } from '@tiptap/extension-table-cell'
|
|
import { TableHeader } from '@tiptap/extension-table-header'
|
|
import { TableRow } from '@tiptap/extension-table-row'
|
|
import { TaskItem } from '@tiptap/extension-task-item'
|
|
import { TaskList } from '@tiptap/extension-task-list'
|
|
import { Youtube } from '@tiptap/extension-youtube'
|
|
import { EditorContent, mergeAttributes, useEditor } from '@tiptap/react'
|
|
import { StarterKit } from '@tiptap/starter-kit'
|
|
import { MarkdownSerializer } from 'prosemirror-markdown'
|
|
import { useEffect } from 'react'
|
|
import { Markdown } from 'tiptap-markdown'
|
|
|
|
import { useTags } from '#components/Map/hooks/useTags'
|
|
|
|
import { CustomHeading } from './Extensions/CustomHeading'
|
|
import { CustomImage } from './Extensions/CustomImage'
|
|
import { HashtagMention } from './Extensions/HashtagMention'
|
|
import { suggestion } from './Extensions/suggestion'
|
|
import { TextEditorMenu } from './TextEditorMenu'
|
|
|
|
import type { Editor } from '@tiptap/react'
|
|
import type { MarkdownSerializerState } from 'prosemirror-markdown'
|
|
import type { Node as ProseMirrorNode } from 'prosemirror-model'
|
|
|
|
interface RichTextEditorProps {
|
|
labelTitle?: string
|
|
labelStyle?: string
|
|
containerStyle?: string
|
|
defaultValue: string
|
|
placeholder?: string
|
|
showMenu?: boolean
|
|
readOnly?: boolean
|
|
updateFormValue?: (value: string) => void
|
|
}
|
|
|
|
interface ImageAttrs {
|
|
src: string
|
|
alt?: string
|
|
title?: string
|
|
style?: string
|
|
}
|
|
|
|
type NodeSerializerFn = (
|
|
state: MarkdownSerializerState,
|
|
node: ProseMirrorNode,
|
|
parent: ProseMirrorNode,
|
|
index: number,
|
|
) => void
|
|
|
|
/**
|
|
* @category Input
|
|
*/
|
|
export function RichTextEditor({
|
|
labelTitle,
|
|
labelStyle,
|
|
containerStyle,
|
|
defaultValue,
|
|
placeholder,
|
|
showMenu = true,
|
|
readOnly = false,
|
|
updateFormValue,
|
|
}: RichTextEditorProps) {
|
|
const handleChange = () => {
|
|
if (updateFormValue) {
|
|
if (editor) {
|
|
updateFormValue(getStyledMarkdown(editor))
|
|
}
|
|
}
|
|
}
|
|
|
|
const tags = useTags()
|
|
|
|
const editor = useEditor({
|
|
editable: !readOnly,
|
|
extensions: [
|
|
Color.configure({ types: ['textStyle', 'listItem'] }),
|
|
Youtube.configure({
|
|
nocookie: true,
|
|
allowFullscreen: true,
|
|
addPasteHandler: true,
|
|
height: undefined,
|
|
width: undefined,
|
|
modestBranding: true,
|
|
}),
|
|
StarterKit.configure({
|
|
bulletList: {
|
|
keepMarks: true,
|
|
keepAttributes: false,
|
|
},
|
|
orderedList: {
|
|
keepMarks: true,
|
|
keepAttributes: false,
|
|
},
|
|
heading: false,
|
|
}),
|
|
HashtagMention.configure({
|
|
HTMLAttributes: { class: 'mention' },
|
|
renderHTML: ({ node, options }) => {
|
|
return [
|
|
'span',
|
|
mergeAttributes(options.HTMLAttributes, {
|
|
'data-id': node.attrs.id,
|
|
}),
|
|
`#${node.attrs.id}`,
|
|
]
|
|
},
|
|
suggestion: {
|
|
char: '#',
|
|
items: ({ query }) => {
|
|
return tags
|
|
.map((tag) => tag.name)
|
|
.filter((tag) => tag.toLowerCase().startsWith(query.toLowerCase()))
|
|
.slice(0, 5)
|
|
},
|
|
...suggestion,
|
|
},
|
|
}),
|
|
Markdown.configure({
|
|
html: true,
|
|
linkify: true,
|
|
transformCopiedText: true,
|
|
transformPastedText: true,
|
|
}),
|
|
Table.configure({
|
|
resizable: true,
|
|
}),
|
|
TableCell,
|
|
TableHeader,
|
|
TableRow,
|
|
TaskList,
|
|
TaskItem,
|
|
CustomImage,
|
|
Link,
|
|
CustomHeading,
|
|
Placeholder.configure({
|
|
placeholder,
|
|
emptyEditorClass: 'is-editor-empty',
|
|
}),
|
|
],
|
|
content: defaultValue,
|
|
onUpdate: handleChange,
|
|
editorProps: {
|
|
attributes: {
|
|
class: `tw:h-full markdown tw:max-h-full ${readOnly ? `` : `tw:p-2`} tw:overflow-y-auto tw:overflow-x-hidden`,
|
|
},
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (editor?.storage.markdown.getMarkdown() === '' || !editor?.storage.markdown.getMarkdown()) {
|
|
editor?.commands.setContent(defaultValue)
|
|
}
|
|
}, [defaultValue, editor])
|
|
|
|
return (
|
|
<div
|
|
className={`tw:form-control tw:w-full tw:flex tw:flex-col tw:min-h-0 ${containerStyle ?? ''}`}
|
|
>
|
|
{labelTitle ? (
|
|
<label className='tw:label tw:pb-1'>
|
|
<span className={`tw:label-text tw:text-base-content ${labelStyle ?? ''}`}>
|
|
{labelTitle}
|
|
</span>
|
|
</label>
|
|
) : null}
|
|
<div
|
|
className={`${readOnly ? `` : `editor-wrapper tw:border-base-content/20 tw:rounded-box tw:border`} tw:flex tw:flex-col tw:flex-1 tw:min-h-0`}
|
|
>
|
|
{editor ? (
|
|
<>
|
|
{showMenu && !readOnly ? <TextEditorMenu editor={editor} /> : null}
|
|
<EditorContent editor={editor} />
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function getStyledMarkdown(editor: Editor): string {
|
|
const { serializer } = editor.storage.markdown as { serializer: MarkdownSerializer }
|
|
const baseNodes = serializer.nodes as Record<string, NodeSerializerFn>
|
|
const marks = serializer.marks
|
|
const customImage: NodeSerializerFn = (state, node) => {
|
|
const { src, alt, title, style } = node.attrs as ImageAttrs
|
|
let tag = '<img src="' + src + '"'
|
|
if (alt) tag += ' alt="' + alt + '"'
|
|
if (title) tag += ' title="' + title + '"'
|
|
if (style) tag += ' style="' + style + '"'
|
|
tag += ' />'
|
|
tag += '\n\n'
|
|
state.write(tag)
|
|
}
|
|
|
|
const customSerializer = new MarkdownSerializer(
|
|
{
|
|
...baseNodes,
|
|
image: customImage,
|
|
},
|
|
marks,
|
|
)
|
|
|
|
return customSerializer.serialize(editor.state.doc)
|
|
}
|