384 lines
12 KiB
JavaScript
384 lines
12 KiB
JavaScript
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
|
import {
|
||
|
SELECTION_CHANGE_COMMAND,
|
||
|
FORMAT_TEXT_COMMAND,
|
||
|
INDENT_CONTENT_COMMAND,
|
||
|
OUTDENT_CONTENT_COMMAND,
|
||
|
$getSelection,
|
||
|
$isRangeSelection,
|
||
|
$createParagraphNode
|
||
|
} from 'lexical'
|
||
|
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
|
||
|
import {
|
||
|
$wrapNodes
|
||
|
} from '@lexical/selection'
|
||
|
import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils'
|
||
|
import {
|
||
|
INSERT_ORDERED_LIST_COMMAND,
|
||
|
INSERT_UNORDERED_LIST_COMMAND,
|
||
|
REMOVE_LIST_COMMAND,
|
||
|
$isListNode,
|
||
|
ListNode
|
||
|
} from '@lexical/list'
|
||
|
import {
|
||
|
$createHeadingNode,
|
||
|
$createQuoteNode,
|
||
|
$isHeadingNode
|
||
|
} from '@lexical/rich-text'
|
||
|
// import {
|
||
|
// $createCodeNode
|
||
|
// } from '@lexical/code'
|
||
|
import BoldIcon from '../../svgs/bold.svg'
|
||
|
import ItalicIcon from '../../svgs/italic.svg'
|
||
|
// import StrikethroughIcon from '../../svgs/strikethrough.svg'
|
||
|
import LinkIcon from '../../svgs/link.svg'
|
||
|
import ListOrderedIcon from '../../svgs/list-ordered.svg'
|
||
|
import ListUnorderedIcon from '../../svgs/list-unordered.svg'
|
||
|
import IndentIcon from '../../svgs/indent-increase.svg'
|
||
|
import OutdentIcon from '../../svgs/indent-decrease.svg'
|
||
|
import ImageIcon from '../../svgs/image-line.svg'
|
||
|
import FontSizeIcon from '../../svgs/font-size-2.svg'
|
||
|
import QuoteIcon from '../../svgs/double-quotes-r.svg'
|
||
|
// import CodeIcon from '../../svgs/code-line.svg'
|
||
|
// import CodeBoxIcon from '../../svgs/code-box-line.svg'
|
||
|
import ArrowDownIcon from '../../svgs/arrow-down-s-fill.svg'
|
||
|
import CheckIcon from '../../svgs/check-line.svg'
|
||
|
|
||
|
import styles from '../styles.module.css'
|
||
|
import { Dropdown } from 'react-bootstrap'
|
||
|
import { useLinkInsert } from './link-insert'
|
||
|
import { getSelectedNode } from '../utils/selected-node'
|
||
|
import { getLinkFromSelection } from '../utils/link-from-selection'
|
||
|
import { ImageInsertModal } from './image-insert'
|
||
|
import useModal from '../utils/modal'
|
||
|
|
||
|
const LowPriority = 1
|
||
|
|
||
|
function Divider () {
|
||
|
return <div className={styles.divider} />
|
||
|
}
|
||
|
|
||
|
function FontSizeDropdown ({
|
||
|
editor,
|
||
|
blockType
|
||
|
}) {
|
||
|
const formatParagraph = () => {
|
||
|
if (blockType !== 'paragraph') {
|
||
|
editor.update(() => {
|
||
|
const selection = $getSelection()
|
||
|
|
||
|
if ($isRangeSelection(selection)) {
|
||
|
$wrapNodes(selection, () => $createParagraphNode())
|
||
|
}
|
||
|
setTimeout(() => editor.focus(), 100)
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const formatLargeHeading = () => {
|
||
|
if (blockType !== 'h1') {
|
||
|
editor.update(() => {
|
||
|
const selection = $getSelection()
|
||
|
|
||
|
if ($isRangeSelection(selection)) {
|
||
|
$wrapNodes(selection, () => $createHeadingNode('h1'))
|
||
|
}
|
||
|
|
||
|
setTimeout(() => editor.focus(), 100)
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const formatSmallHeading = () => {
|
||
|
if (blockType !== 'h2') {
|
||
|
editor.update(() => {
|
||
|
const selection = $getSelection()
|
||
|
|
||
|
if ($isRangeSelection(selection)) {
|
||
|
$wrapNodes(selection, () => $createHeadingNode('h2'))
|
||
|
}
|
||
|
|
||
|
setTimeout(() => editor.focus(), 100)
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return (
|
||
|
<Dropdown className='pointer' as='span'>
|
||
|
<Dropdown.Toggle
|
||
|
id='dropdown-basic'
|
||
|
as='button' className={styles.toolbarItem} aria-label='Font size'
|
||
|
>
|
||
|
<FontSizeIcon />
|
||
|
<ArrowDownIcon />
|
||
|
</Dropdown.Toggle>
|
||
|
|
||
|
<Dropdown.Menu>
|
||
|
<Dropdown.Item as='button' className={`${styles.paragraph} my-0`} onClick={formatParagraph}>
|
||
|
<CheckIcon className={`mr-1 ${blockType === 'paragraph' ? 'fill-grey' : 'invisible'}`} />
|
||
|
<span className={styles.text}>normal</span>
|
||
|
</Dropdown.Item>
|
||
|
<Dropdown.Item as='button' className={`${styles.heading2} my-0`} onClick={formatSmallHeading}>
|
||
|
<CheckIcon className={`mr-1 ${['h2', 'h3', 'h4', 'h5', 'h6'].includes(blockType) ? 'fill-grey' : 'invisible'}`} />
|
||
|
<span className={styles.text}>subheading</span>
|
||
|
</Dropdown.Item>
|
||
|
<Dropdown.Item as='button' className={`${styles.heading1} my-0`} onClick={formatLargeHeading}>
|
||
|
<CheckIcon className={`mr-1 ${blockType === 'h1' ? 'fill-grey' : 'invisible'}`} />
|
||
|
<span className={styles.text}>heading</span>
|
||
|
</Dropdown.Item>
|
||
|
</Dropdown.Menu>
|
||
|
</Dropdown>
|
||
|
)
|
||
|
}
|
||
|
|
||
|
export default function ToolbarPlugin () {
|
||
|
const [editor] = useLexicalComposerContext()
|
||
|
const { setLink } = useLinkInsert()
|
||
|
const toolbarRef = useRef(null)
|
||
|
const [blockType, setBlockType] = useState('paragraph')
|
||
|
const [isLink, setIsLink] = useState(false)
|
||
|
const [isBold, setIsBold] = useState(false)
|
||
|
const [isItalic, setIsItalic] = useState(false)
|
||
|
// const [isStrikethrough, setIsStrikethrough] = useState(false)
|
||
|
// const [isCode, setIsCode] = useState(false)
|
||
|
const [modal, showModal] = useModal()
|
||
|
|
||
|
const updateToolbar = useCallback(() => {
|
||
|
const selection = $getSelection()
|
||
|
if ($isRangeSelection(selection)) {
|
||
|
const anchorNode = selection.anchor.getNode()
|
||
|
const element =
|
||
|
anchorNode.getKey() === 'root'
|
||
|
? anchorNode
|
||
|
: anchorNode.getTopLevelElementOrThrow()
|
||
|
const elementKey = element.getKey()
|
||
|
const elementDOM = editor.getElementByKey(elementKey)
|
||
|
if (elementDOM !== null) {
|
||
|
if ($isListNode(element)) {
|
||
|
const parentList = $getNearestNodeOfType(anchorNode, ListNode)
|
||
|
const type = parentList ? parentList.getTag() : element.getTag()
|
||
|
setBlockType(type)
|
||
|
} else {
|
||
|
const type = $isHeadingNode(element)
|
||
|
? element.getTag()
|
||
|
: element.getType()
|
||
|
setBlockType(type)
|
||
|
}
|
||
|
}
|
||
|
// Update text format
|
||
|
setIsBold(selection.hasFormat('bold'))
|
||
|
setIsItalic(selection.hasFormat('italic'))
|
||
|
// setIsStrikethrough(selection.hasFormat('strikethrough'))
|
||
|
// setIsCode(selection.hasFormat('code'))
|
||
|
|
||
|
// Update links
|
||
|
const node = getSelectedNode(selection)
|
||
|
const parent = node.getParent()
|
||
|
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||
|
setIsLink(true)
|
||
|
} else {
|
||
|
setIsLink(false)
|
||
|
}
|
||
|
}
|
||
|
}, [editor])
|
||
|
|
||
|
useEffect(() => {
|
||
|
return mergeRegister(
|
||
|
editor.registerUpdateListener(({ editorState }) => {
|
||
|
editorState.read(() => {
|
||
|
updateToolbar()
|
||
|
})
|
||
|
}),
|
||
|
editor.registerCommand(
|
||
|
SELECTION_CHANGE_COMMAND,
|
||
|
(_payload, newEditor) => {
|
||
|
updateToolbar()
|
||
|
return false
|
||
|
},
|
||
|
LowPriority
|
||
|
)
|
||
|
)
|
||
|
}, [editor, updateToolbar])
|
||
|
|
||
|
const insertLink = useCallback(() => {
|
||
|
if (isLink) {
|
||
|
// unlink it
|
||
|
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
|
||
|
} else {
|
||
|
editor.update(() => {
|
||
|
setLink(getLinkFromSelection())
|
||
|
})
|
||
|
}
|
||
|
}, [editor, isLink])
|
||
|
|
||
|
const formatBulletList = () => {
|
||
|
if (blockType !== 'ul') {
|
||
|
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND)
|
||
|
} else {
|
||
|
editor.dispatchCommand(REMOVE_LIST_COMMAND)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const formatNumberedList = () => {
|
||
|
if (blockType !== 'ol') {
|
||
|
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND)
|
||
|
} else {
|
||
|
editor.dispatchCommand(REMOVE_LIST_COMMAND)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const formatQuote = () => {
|
||
|
if (blockType !== 'quote') {
|
||
|
editor.update(() => {
|
||
|
const selection = $getSelection()
|
||
|
|
||
|
if ($isRangeSelection(selection)) {
|
||
|
$wrapNodes(selection, () => $createQuoteNode())
|
||
|
}
|
||
|
})
|
||
|
} else {
|
||
|
editor.update(() => {
|
||
|
const selection = $getSelection()
|
||
|
|
||
|
if ($isRangeSelection(selection)) {
|
||
|
$wrapNodes(selection, () => $createParagraphNode())
|
||
|
}
|
||
|
})
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// const formatCode = () => {
|
||
|
// if (blockType !== 'code') {
|
||
|
// editor.update(() => {
|
||
|
// const selection = $getSelection()
|
||
|
|
||
|
// if ($isRangeSelection(selection)) {
|
||
|
// $wrapNodes(selection, () => {
|
||
|
// const node = $createCodeNode()
|
||
|
// node.setLanguage('plain')
|
||
|
// return node
|
||
|
// })
|
||
|
// }
|
||
|
// })
|
||
|
// }
|
||
|
// }
|
||
|
|
||
|
return (
|
||
|
<div className={styles.toolbar} ref={toolbarRef}>
|
||
|
<FontSizeDropdown editor={editor} blockType={blockType} />
|
||
|
<Divider />
|
||
|
<>
|
||
|
<button
|
||
|
onClick={() => {
|
||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
|
||
|
}}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced} ${isBold ? styles.active : ''}`}
|
||
|
aria-label='Format Bold'
|
||
|
>
|
||
|
<BoldIcon />
|
||
|
</button>
|
||
|
<button
|
||
|
onClick={() => {
|
||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
|
||
|
}}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced} ${isItalic ? styles.active : ''}`}
|
||
|
aria-label='Format Italics'
|
||
|
>
|
||
|
<ItalicIcon />
|
||
|
</button>
|
||
|
<Divider />
|
||
|
<button
|
||
|
onClick={formatBulletList}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'ul' ? styles.active : ''}`}
|
||
|
>
|
||
|
<ListUnorderedIcon />
|
||
|
</button>
|
||
|
<button
|
||
|
onClick={formatNumberedList}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'ol' ? styles.active : ''}`}
|
||
|
aria-label='Insert numbered list'
|
||
|
>
|
||
|
<ListOrderedIcon />
|
||
|
</button>
|
||
|
<button
|
||
|
onClick={() => {
|
||
|
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
|
||
|
}}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced}`}
|
||
|
aria-label='Indent'
|
||
|
>
|
||
|
<IndentIcon />
|
||
|
</button>
|
||
|
<button
|
||
|
onClick={() => {
|
||
|
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
|
||
|
}}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced}`}
|
||
|
aria-label='Outdent'
|
||
|
>
|
||
|
<OutdentIcon />
|
||
|
</button>
|
||
|
<button
|
||
|
onClick={formatQuote}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'quote' ? styles.active : ''}`}
|
||
|
aria-label='Insert Quote'
|
||
|
>
|
||
|
<QuoteIcon />
|
||
|
</button>
|
||
|
{/* <Divider /> */}
|
||
|
{/* <button
|
||
|
onClick={() => {
|
||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
|
||
|
}}
|
||
|
className={
|
||
|
`${styles.toolbarItem} ${styles.spaced} ${isStrikethrough ? styles.active : ''}`
|
||
|
}
|
||
|
aria-label='Format Strikethrough'
|
||
|
>
|
||
|
<StrikethroughIcon />
|
||
|
</button> */}
|
||
|
{/* <button
|
||
|
onClick={() => {
|
||
|
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
|
||
|
}}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced} ${isCode ? styles.active : ''}`}
|
||
|
aria-label='Insert Code'
|
||
|
>
|
||
|
<CodeIcon />
|
||
|
</button> */}
|
||
|
{/* <button
|
||
|
onClick={formatCode}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced} ${blockType === 'code' ? styles.active : ''}`}
|
||
|
aria-label='Insert Code'
|
||
|
>
|
||
|
<CodeBoxIcon />
|
||
|
</button> */}
|
||
|
<Divider />
|
||
|
<button
|
||
|
onClick={insertLink}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced} ${isLink ? styles.active : ''}`}
|
||
|
aria-label='Insert Link'
|
||
|
>
|
||
|
<LinkIcon />
|
||
|
</button>
|
||
|
<button
|
||
|
onClick={() => {
|
||
|
showModal((onClose) => (
|
||
|
<ImageInsertModal
|
||
|
editor={editor}
|
||
|
onClose={onClose}
|
||
|
/>
|
||
|
))
|
||
|
}}
|
||
|
className={`${styles.toolbarItem} ${styles.spaced}`}
|
||
|
aria-label='Insert Image'
|
||
|
>
|
||
|
<ImageIcon />
|
||
|
</button>
|
||
|
{modal}
|
||
|
</>
|
||
|
</div>
|
||
|
)
|
||
|
}
|