feat(richtext-lexical): add HorizontalRuleFeature

This commit is contained in:
Alessio Gravili
2024-04-11 16:24:04 -04:00
parent 844663ce1a
commit c3d8597c13
10 changed files with 404 additions and 2 deletions

View File

@@ -138,7 +138,7 @@ import { CallToAction } from '../blocks/CallToAction'
Here's an overview of all the included features:
| Feature Name | Included by default | Description |
| ------------------------------ | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|--------------------------------|---------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`BoldTextFeature`** | Yes | Handles the bold text format |
| **`ItalicTextFeature`** | Yes | Handles the italic text format |
| **`UnderlineTextFeature`** | Yes | Handles the underline text format |
@@ -157,7 +157,8 @@ Here's an overview of all the included features:
| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents |
| **`BlockQuoteFeature`** | Yes | Allows you to create block-level quotes |
| **`UploadFeature`** | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images |
| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](/docs/fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
| **`HorizontalRuleFeature`** | Yes | Horizontal rules / separators. Basically displays an <hr> element |
| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](/docs/fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
| **`TreeViewFeature`** | No | Adds a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging |
## Creating your own, custom Feature

View File

@@ -0,0 +1,80 @@
'use client'
import type { NodeKey } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
import lexicalUtilsImport from '@lexical/utils'
const { mergeRegister } = lexicalUtilsImport
import lexicalImport from 'lexical'
const {
$getNodeByKey,
$getSelection,
$isNodeSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} = lexicalImport
import { useCallback, useEffect } from 'react'
import { $isHorizontalRuleNode } from '../nodes/HorizontalRuleNode.js'
/**
* React component rendered in the lexical editor, WITHIN the hr element created by createDOM of the HorizontalRuleNode.
*
* @param nodeKey every node has a unique key (this key is not saved to the database and thus may differ between sessions). It's useful for working with the CURRENT lexical editor state
*/
export function HorizontalRuleComponent({ nodeKey }: { nodeKey: NodeKey }) {
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
const onDelete = useCallback(
(event: KeyboardEvent) => {
if (isSelected && $isNodeSelection($getSelection())) {
event.preventDefault()
const node = $getNodeByKey(nodeKey)
if ($isHorizontalRuleNode(node)) {
node.remove()
return true
}
}
return false
},
[isSelected, nodeKey],
)
useEffect(() => {
return mergeRegister(
editor.registerCommand(
CLICK_COMMAND,
(event: MouseEvent) => {
const hrElem = editor.getElementByKey(nodeKey)
if (event.target === hrElem) {
if (!event.shiftKey) {
clearSelection()
}
setSelected(!isSelected)
return true
}
return false
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW),
)
}, [clearSelection, editor, isSelected, nodeKey, onDelete, setSelected])
useEffect(() => {
const hrElem = editor.getElementByKey(nodeKey)
if (hrElem !== null) {
hrElem.className = isSelected ? 'selected' : ''
}
}, [editor, isSelected, nodeKey])
return null
}

View File

@@ -0,0 +1,49 @@
'use client'
import type { FeatureProviderProviderClient } from '../types.js'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js'
import { HorizontalRuleIcon } from '../../lexical/ui/icons/HorizontalRule/index.js'
import { createClientComponent } from '../createClientComponent.js'
import { MarkdownTransformer } from './markdownTransformer.js'
import { HorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND } from './nodes/HorizontalRuleNode.js'
import { HorizontalRulePlugin } from './plugin/index.js'
const HorizontalRuleFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return {
clientFeatureProps: props,
feature: () => ({
clientFeatureProps: props,
markdownTransformers: [MarkdownTransformer],
nodes: [HorizontalRuleNode],
plugins: [
{
Component: HorizontalRulePlugin,
position: 'normal',
},
],
slashMenu: {
options: [
{
displayName: 'Basic',
key: 'basic',
options: [
new SlashMenuOption(`horizontalrule`, {
Icon: HorizontalRuleIcon,
displayName: `Horizontal Rule`,
keywords: ['hr', 'horizontal rule', 'line', 'separator'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
},
}),
],
},
],
},
}),
}
}
export const HorizontalRuleFeatureClientComponent = createClientComponent(
HorizontalRuleFeatureClient,
)

View File

@@ -0,0 +1,37 @@
import type { HTMLConverter } from '../converters/html/converter/types.js'
import type { FeatureProviderProviderServer } from '../types.js'
import type { SerializedHorizontalRuleNode } from './nodes/HorizontalRuleNode.js'
import { HorizontalRuleFeatureClientComponent } from './feature.client.js'
import { MarkdownTransformer } from './markdownTransformer.js'
import { HorizontalRuleNode } from './nodes/HorizontalRuleNode.js'
export const HorizontalRuleFeature: FeatureProviderProviderServer<undefined, undefined> = (
props,
) => {
return {
feature: () => {
return {
ClientComponent: HorizontalRuleFeatureClientComponent,
clientFeatureProps: null,
markdownTransformers: [MarkdownTransformer],
nodes: [
{
converters: {
html: {
converter: () => {
return `<hr/>`
},
nodeTypes: [HorizontalRuleNode.getType()],
} as HTMLConverter<SerializedHorizontalRuleNode>,
},
node: HorizontalRuleNode,
},
],
serverFeatureProps: props,
}
},
key: 'horizontalrule',
serverFeatureProps: props,
}
}

View File

@@ -0,0 +1,26 @@
import type { ElementTransformer } from '@lexical/markdown'
import {
$createHorizontalRuleNode,
$isHorizontalRuleNode,
HorizontalRuleNode,
} from './nodes/HorizontalRuleNode.js'
export const MarkdownTransformer: ElementTransformer = {
type: 'element',
dependencies: [HorizontalRuleNode],
export: (node, exportChildren) => {
if (!$isHorizontalRuleNode(node)) {
return null
}
return '---'
},
// match ---
regExp: /^---\s*$/,
replace: (parentNode) => {
const node = $createHorizontalRuleNode()
if (node) {
parentNode.replace(node)
}
},
}

View File

@@ -0,0 +1,123 @@
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
LexicalCommand,
LexicalNode,
SerializedLexicalNode,
} from 'lexical'
import lexicalImport from 'lexical'
const { $applyNodeReplacement, DecoratorNode, createCommand } = lexicalImport
import * as React from 'react'
const HorizontalRuleComponent = React.lazy(() =>
import('../component/index.js').then((module) => ({
default: module.HorizontalRuleComponent,
})),
)
/**
* Serialized representation of a horizontal rule node. Serialized = converted to JSON. This is what is stored in the database / in the lexical editor state.
*/
export type SerializedHorizontalRuleNode = SerializedLexicalNode
export const INSERT_HORIZONTAL_RULE_COMMAND: LexicalCommand<void> = createCommand(
'INSERT_HORIZONTAL_RULE_COMMAND',
)
/**
* This node is a DecoratorNode. DecoratorNodes allow you to render React components in the editor.
*
* They need both createDom and decorate functions. createDom => outside of the html. decorate => React Component inside of the html.
*
* If we used DecoratorBlockNode instead, we would only need a decorate method
*/
export class HorizontalRuleNode extends DecoratorNode<React.ReactElement> {
static clone(node: HorizontalRuleNode): HorizontalRuleNode {
return new HorizontalRuleNode(node.__key)
}
static getType(): string {
return 'horizontalrule'
}
/**
* Defines what happens if you copy an hr element from another page and paste it into the lexical editor
*
* This also determines the behavior of lexical's internal HTML -> Lexical converter
*/
static importDOM(): DOMConversionMap | null {
return {
hr: () => ({
conversion: convertHorizontalRuleElement,
priority: 0,
}),
}
}
/**
* The data for this node is stored serialized as JSON. This is the "load function" of that node: it takes the saved data and converts it into a node.
*/
static importJSON(serializedNode: SerializedHorizontalRuleNode): HorizontalRuleNode {
return $createHorizontalRuleNode()
}
/**
* Determines how the hr element is rendered in the lexical editor. This is only the "initial" / "outer" HTML element.
*/
createDOM(): HTMLElement {
return document.createElement('hr')
}
/**
* Allows you to render a React component within whatever createDOM returns.
*/
decorate(): React.ReactElement {
return <HorizontalRuleComponent nodeKey={this.__key} />
}
/**
* Opposite of importDOM, this function defines what happens when you copy an hr element from the lexical editor and paste it into another page.
*
* This also determines the behavior of lexical's internal Lexical -> HTML converter
*/
exportDOM(): DOMExportOutput {
return { element: document.createElement('hr') }
}
/**
* Opposite of importJSON. This determines what data is saved in the database / in the lexical editor state.
*/
exportJSON(): SerializedLexicalNode {
return {
type: 'horizontalrule',
version: 1,
}
}
getTextContent(): string {
return '\n'
}
isInline(): false {
return false
}
updateDOM(): boolean {
return false
}
}
function convertHorizontalRuleElement(): DOMConversionOutput {
return { node: $createHorizontalRuleNode() }
}
export function $createHorizontalRuleNode(): HorizontalRuleNode {
return $applyNodeReplacement(new HorizontalRuleNode())
}
export function $isHorizontalRuleNode(
node: LexicalNode | null | undefined,
): node is HorizontalRuleNode {
return node instanceof HorizontalRuleNode
}

View File

@@ -0,0 +1,20 @@
@import '../../../../scss/styles.scss';
hr {
padding: 2px 2px;
border: none;
margin: 1rem 0;
cursor: pointer;
}
hr:after {
content: '';
display: block;
height: 2px;
background-color: var(--theme-elevation-250);
}
hr.selected {
outline: 2px solid var(--theme-success-500);
user-select: none;
}

View File

@@ -0,0 +1,48 @@
'use client'
import lexicalComposerContextImport from '@lexical/react/LexicalComposerContext.js'
const { useLexicalComposerContext } = lexicalComposerContextImport
import lexicalUtilsImport from '@lexical/utils'
const { $insertNodeToNearestRoot } = lexicalUtilsImport
import lexicalImport from 'lexical'
const { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR } = lexicalImport
import { useEffect } from 'react'
import {
$createHorizontalRuleNode,
INSERT_HORIZONTAL_RULE_COMMAND,
} from '../nodes/HorizontalRuleNode.js'
import './index.scss'
/**
* Registers the INSERT_HORIZONTAL_RULE_COMMAND lexical command and defines the behavior for when it is called.
*/
export function HorizontalRulePlugin(): null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return editor.registerCommand(
INSERT_HORIZONTAL_RULE_COMMAND,
(type) => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false
}
const focusNode = selection.focus.getNode()
if (focusNode !== null) {
const horizontalRuleNode = $createHorizontalRuleNode()
$insertNodeToNearestRoot(horizontalRuleNode)
}
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor])
return null
}

View File

@@ -13,6 +13,7 @@ import { SubscriptFeature } from '../../../features/format/subscript/feature.ser
import { SuperscriptFeature } from '../../../features/format/superscript/feature.server.js'
import { UnderlineFeature } from '../../../features/format/underline/feature.server.js'
import { HeadingFeature } from '../../../features/heading/feature.server.js'
import { HorizontalRuleFeature } from '../../../features/horizontalrule/feature.server.js'
import { IndentFeature } from '../../../features/indent/feature.server.js'
import { LinkFeature } from '../../../features/link/feature.server.js'
import { CheckListFeature } from '../../../features/lists/checklist/feature.server.js'
@@ -48,6 +49,7 @@ export const defaultEditorFeatures: FeatureProviderServer<unknown, unknown>[] =
RelationshipFeature(),
BlockQuoteFeature(),
UploadFeature(),
HorizontalRuleFeature(),
]
export const defaultEditorConfig: ServerEditorConfig = {

View File

@@ -0,0 +1,16 @@
import React from 'react'
export const HorizontalRuleIcon: React.FC = () => (
<svg
aria-hidden="true"
className="icon"
fill="none"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<rect fill="currentColor" height="1" width="12" x="4" y="9.5" />
</svg>
)