feat(richtext-lexical): add HorizontalRuleFeature
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
Reference in New Issue
Block a user