stacker.news/lexical/plugins/toolbar.js

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>
)
}