hensei-web/components/common/Editor/index.tsx

270 lines
7.3 KiB
TypeScript

import { ComponentProps, useCallback, useEffect } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import { useRouter } from 'next/router'
import { useTranslation } from 'next-i18next'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Highlight from '@tiptap/extension-highlight'
import Placeholder from '@tiptap/extension-placeholder'
import Typography from '@tiptap/extension-typography'
import Youtube from '@tiptap/extension-youtube'
import CustomMention from '~extensions/CustomMention'
import classNames from 'classnames'
import { mentionSuggestionOptions } from '~utils/mentionSuggestions'
import type { JSONContent } from '@tiptap/core'
import ToolbarButton from '~components/common/ToolbarButton'
import BoldIcon from 'remixicon-react/BoldIcon'
import ItalicIcon from 'remixicon-react/ItalicIcon'
import StrikethroughIcon from 'remixicon-react/StrikethroughIcon'
import UnorderedListIcon from 'remixicon-react/ListUnorderedIcon'
import OrderedListIcon from '~public/icons/remix/list-ordered-2.svg'
import PaintbrushIcon from 'remixicon-react/PaintBrushLineIcon'
import H1Icon from 'remixicon-react/H1Icon'
import H2Icon from 'remixicon-react/H2Icon'
import H3Icon from 'remixicon-react/H3Icon'
import LinkIcon from 'remixicon-react/LinkIcon'
import YoutubeIcon from 'remixicon-react/YoutubeLineIcon'
import styles from './index.module.scss'
interface Props extends ComponentProps<'div'> {
bound: boolean
editable?: boolean
content?: string
onUpdate?: (content: JSONContent) => void
}
const Editor = ({
bound,
className,
content,
editable,
onUpdate,
...props
}: Props) => {
// Hooks: Router
const router = useRouter()
const locale = router.locale || 'en'
const { t } = useTranslation('common')
useEffect(() => {
editor?.commands.setContent(formatContent(content))
}, [content])
// Setup: Editor
const editor = useEditor({
content: formatContent(content),
editable: editable,
editorProps: {
attributes: {
class: classNames(
{
[styles.editor]: true,
[styles.bound]: bound,
},
className?.split(' ').map((c) => styles[c])
),
},
},
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
Link,
Highlight,
Placeholder.configure({
emptyEditorClass: styles.empty,
placeholder: t('modals.edit_team.placeholders.description'),
}),
Typography,
CustomMention.configure({
renderLabel({ options, node }) {
return `${node.attrs.id.name[locale] ?? node.attrs.id.granblue_en}`
},
suggestion: mentionSuggestionOptions,
HTMLAttributes: {
class: classNames({
[styles.mention]: true,
}),
},
}),
Youtube.configure({
inline: false,
modestBranding: true,
interfaceLanguage: locale,
}),
],
onUpdate: ({ editor }) => {
const json = editor.getJSON()
if (onUpdate) onUpdate(json)
},
})
// Methods: Convenience
function isJSON(content?: string) {
if (!content) return false
try {
JSON.parse(content)
} catch (e) {
return false
}
return true
}
function formatContent(content?: string) {
if (!content) return ''
if (isJSON(content)) return JSON.parse(content)
else {
// Otherwise, create a new <p> tag after each double newline.
// Add < br /> tags for single newlines.
// Add a < br /> after each paragraph.
const paragraphs = content.split('\n\n')
const formatted = paragraphs
.map((p) => {
const lines = p.split('\n')
return lines.join('<br />')
})
.join('</p><br /><p>')
return formatted
}
}
// Methods: Actions
const setLink = useCallback(() => {
const previousUrl = editor?.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
// cancelled
if (url === null) {
return
}
// empty
if (url === '') {
editor?.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
// update link
editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}, [editor])
const addYoutubeVideo = () => {
const url = prompt('Enter YouTube URL')
if (editor && url) {
editor.commands.setYoutubeVideo({
src: url,
width: 320,
height: 180,
})
}
}
// Methods: Rendering
if (!editor) {
return null
}
return (
<section className={styles.wrapper}>
{editor && editable === true && (
<nav className={styles.toolbar}>
<ToolbarButton
editor={editor}
action="bold"
icon={<BoldIcon />}
onClick={() => editor.chain().focus().toggleBold().run()}
/>
<ToolbarButton
editor={editor}
action="italic"
icon={<ItalicIcon />}
onClick={() => editor.chain().focus().toggleItalic().run()}
/>
<ToolbarButton
editor={editor}
action="strike"
icon={<StrikethroughIcon />}
onClick={() => editor.chain().focus().toggleStrike().run()}
/>
<ToolbarButton
editor={editor}
action="highlight"
icon={<PaintbrushIcon />}
onClick={() => editor.chain().focus().toggleHighlight().run()}
/>
<div className={styles.divider} />
<ToolbarButton
editor={editor}
action="heading"
level={1}
icon={<H1Icon />}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
/>
<ToolbarButton
editor={editor}
action="heading"
level={2}
icon={<H2Icon />}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
/>
<ToolbarButton
editor={editor}
action="heading"
level={3}
icon={<H3Icon />}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
/>
<div className={styles.divider} />
<ToolbarButton
editor={editor}
action="bulletList"
icon={<UnorderedListIcon />}
onClick={() => editor.chain().focus().toggleBulletList().run()}
/>
<ToolbarButton
editor={editor}
action="orderedList"
icon={<OrderedListIcon />}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
/>
<div className={styles.divider} />
<ToolbarButton
editor={editor}
action="link"
icon={<LinkIcon />}
onClick={setLink}
/>
<ToolbarButton
editor={editor}
action="youtube"
icon={<YoutubeIcon />}
onClick={addYoutubeVideo}
/>
</nav>
)}
<EditorContent editor={editor} />
</section>
)
}
Editor.defaultProps = {
bound: false,
editable: false,
}
export default Editor