import { useState, useEffect, useCallback } from 'react'
import { Editor as CoreEditor } from '@tiptap/core'
import { useEditor, Editor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Link from '@tiptap/extension-link'
import { Level } from '@tiptap/extension-heading'
import Placeholder from '@tiptap/extension-placeholder'
import Image from '@tiptap/extension-image'
import classNames from 'classnames'
import IVSelect from '~/components/IVSelect'
import QuoteLeftIcon from '~/icons/compiled/QuoteLeft'
import BulletedListIcon from '~/icons/compiled/BulletedList'
import NumberedListIcon from '~/icons/compiled/NumberedList'
import LinkIcon from '~/icons/compiled/Link'
import RedoIcon from '~/icons/compiled/Redo'
import UndoIcon from '~/icons/compiled/Undo'
import ClearFormattingIcon from '~/icons/compiled/ClearFormatting'
import ImageIcon from '~/icons/compiled/Image'
import { ShortcutMap, getShortcuts } from '~/utils/usePlatform'
const CustomLink = Link.extend({
addKeyboardShortcuts() {
return {
'Mod-K': ({ editor }) => linkButtonHandler(editor),
'Mod-O': ({ editor }) => imageButtonHandler(editor),
'Mod-C': ({ editor }) => clearFormattingButtonHandler(editor),
// Swallow Cmd+Enter
'Mod-Enter': () => true,
}
},
})
function linkButtonHandler(editor: CoreEditor) {
const existing = editor.getAttributes('link').href
// TODO: Use a better dialog
const href = window.prompt('Link destination', existing ?? undefined)
if (href === '') {
editor.chain().focus().unsetLink().run()
return false
}
if (!href) return false
return editor.chain().focus().setLink({ href }).run()
}
function imageButtonHandler(editor: CoreEditor) {
const existing = editor.getAttributes('image').src
// TODO: Use a better dialog
// TODO: Handle image uploads
const src = window.prompt('Image URL', existing ?? undefined)
if (!src) return false
return editor.chain().focus().setImage({ src }).run()
}
function clearFormattingButtonHandler(editor: CoreEditor) {
return editor
.chain()
.focus()
.clearNodes()
.unsetBold()
.unsetItalic()
.unsetUnderline()
.unsetStrike()
.unsetLink()
.run()
}
export interface IVRichTextEditorProps {
id?: string
defaultValue?: string
onChange: (content: string, textContent: string) => void
onBlur?: () => void
disabled?: boolean
placeholder?: string
className?: string
autoFocus?: boolean
hasError?: boolean
}
export default function IVRichTextEditor({
id,
defaultValue,
onChange,
onBlur = () => {
/* */
},
disabled,
placeholder = 'Write something ...',
className,
autoFocus = false,
hasError,
}: IVRichTextEditorProps) {
const editor = useEditor({
content: defaultValue,
editable: !disabled,
extensions: [
StarterKit,
Underline,
CustomLink.configure({
openOnClick: true, // TODO: Disable this after adding custom handler
}),
Placeholder.configure({
placeholder,
}),
Image,
],
onUpdate({ editor }) {
// This might be wastefully expensive to do both always but it's a nice
// way for the parent to ensure that text is entered and not just empty
// blocks.
onChange(editor.getHTML(), editor.getText())
},
editorProps: {
attributes: autoFocus ? { 'data-autofocus-target': 'true' } : undefined,
},
onBlur,
// TODO: Add custom ctrl/cmd+click handler for links
// editorProps: {
// handleClickOn(_view, pos, node, nodePos, event, direct) {
// if (event.ctrlKey) {
// console.log({ node, nodePos, pos })
// }
//
// return true
// },
// },
})
useEffect(() => {
editor?.setEditable(!disabled)
}, [disabled, editor])
return (
)
}
function MenuBar({
editor,
disabled,
}: {
editor: Editor | null
disabled: boolean
}) {
const [headingLevel, setHeadingLevel] = useState(0)
const setHeading = useCallback(({ editor }: { editor: any }) => {
setHeadingLevel(editor.getAttributes('heading')?.level ?? 0)
}, [])
useEffect(() => {
if (editor) {
editor.on('selectionUpdate', setHeading)
editor.on('update', setHeading)
return () => {
editor.off('selectionUpdate', setHeading)
editor.off('update', setHeading)
}
}
}, [editor, setHeading])
if (!editor) return null
return (
{
const level = Number(event.target.value)
if (Number.isNaN(level) || level > 6) return
setHeadingLevel(level)
if (level === 0) {
editor.chain().focus().clearNodes().run()
} else {
editor
.chain()
.focus()
.toggleHeading({ level: level as Level })
.run()
}
}}
/>
B
),
disabled: disabled || !editor.can().toggleBold(),
isActive: editor.isActive('bold'),
onClick() {
editor.chain().focus().toggleBold().run()
},
shortcuts: {
mac: 'Meta+B',
pc: 'Control+B',
},
},
{
title: 'Toggle italic',
label: (
I
),
disabled: disabled || !editor.can().toggleItalic(),
isActive: editor.isActive('italic'),
onClick() {
editor.chain().focus().toggleItalic().run()
},
shortcuts: {
mac: 'Meta+I',
pc: 'Control+I',
},
},
{
title: 'Toggle underline',
label: (
U
),
disabled: disabled || !editor.can().toggleUnderline(),
isActive: editor.isActive('underline'),
onClick() {
editor.chain().focus().toggleUnderline().run()
},
shortcuts: {
mac: 'Meta+U',
pc: 'Control+U',
},
},
{
title: 'Toggle strikethrough',
label: (
S
),
disabled: disabled || !editor.can().toggleStrike(),
isActive: editor.isActive('strikethrough'),
onClick() {
editor.chain().focus().toggleStrike().run()
},
shortcuts: {
mac: 'Meta+Shift+X',
pc: 'Control+Shift+X',
},
},
]}
/>
,
disabled: disabled || !editor.can().toggleBulletList(),
isActive: editor.isActive('bulletList'),
onClick() {
editor.chain().focus().toggleBulletList().run()
},
shortcuts: {
mac: 'Meta+Shift+8',
pc: 'Control+Shift+8',
},
},
{
title: 'Toggle ordered list',
label: ,
disabled: disabled || !editor.can().toggleOrderedList(),
isActive: editor.isActive('orderedList'),
onClick() {
editor.chain().focus().toggleOrderedList().run()
},
shortcuts: {
mac: 'Meta+Shift+7',
pc: 'Control+Shift+7',
},
},
{
title: 'Toggle blockquote',
label: ,
disabled: disabled || !editor.can().toggleBlockquote(),
isActive: editor.isActive('blockquote'),
onClick() {
editor.chain().focus().toggleBlockquote().run()
},
shortcuts: {
mac: 'Meta+Shift+B',
pc: 'Control+Shift+B',
},
},
]}
/>
,
disabled: disabled || !editor.can().setLink({ href: '' }),
onClick: () => linkButtonHandler(editor),
shortcuts: {
mac: 'Meta+Shift+K',
pc: 'Control+Shift+K',
},
},
]}
/>
,
disabled: disabled || !editor.can().undo(),
onClick() {
editor.chain().focus().undo().run()
},
shortcuts: {
mac: 'Meta+U',
pc: 'Control+U',
},
},
{
title: 'Redo',
label: ,
disabled: disabled || !editor.can().redo(),
onClick() {
editor.chain().focus().redo().run()
},
shortcuts: {
mac: 'Meta+R',
pc: 'Control+R',
},
},
]}
/>
,
disabled: disabled || !editor.can().setImage({ src: '' }),
onClick: () => imageButtonHandler(editor),
shortcuts: {
mac: 'Meta+Shift+I',
pc: 'Control+Shift+I',
},
},
]}
/>
,
disabled: disabled || !editor.can().clearNodes(),
onClick: () => clearFormattingButtonHandler(editor),
shortcuts: {
mac: 'Meta+Shift+C',
pc: 'Control+Shift+C',
},
},
]}
/>
)
}
function MenuBarButtonGroup({ buttons }: { buttons: MenuBarButtonProps[] }) {
return (
{buttons.map(({ className, ...button }, i) => {
return (
)
})}
)
}
interface MenuBarButtonProps {
label: React.ReactNode
title?: string
onClick: () => void
disabled?: boolean
className?: string
isActive?: boolean
shortcuts?: string | ShortcutMap
}
function MenuBarButton({
label,
title,
onClick,
disabled,
isActive,
shortcuts,
className = 'rounded-sm',
}: MenuBarButtonProps) {
const deviceShortcuts = getShortcuts(shortcuts)
return (
)
}