fix(richtext-lexical): prevent use of text formats whose features were not enabled (#9507)

Fixes #8811

Before this PR, even if you did not include text formatting features
(such as BoldFeature, ItalicFeature, etc), it was possible to apply that
formatting by (a) pasting content from the clipboard and (b) using
keyboard shortcuts.

This PR fixes that by requiring the formatting features to be registered
so that they can be inserted in the editor.
This commit is contained in:
Germán Jabloñski
2024-11-26 20:50:24 -03:00
committed by GitHub
parent 5d18a5288e
commit 90f893a558
11 changed files with 69 additions and 1 deletions

View File

@@ -40,6 +40,7 @@ export const BoldFeatureClient = createClientFeature(({ featureProviderMap }) =>
}
return {
enableFormats: ['bold'],
markdownTransformers,
toolbarFixed: {
groups: toolbarGroups,

View File

@@ -30,6 +30,7 @@ const toolbarGroups: ToolbarGroup[] = [
]
export const InlineCodeFeatureClient = createClientFeature({
enableFormats: ['code'],
markdownTransformers: [INLINE_CODE],
toolbarFixed: {
groups: toolbarGroups,

View File

@@ -30,6 +30,7 @@ const toolbarGroups: ToolbarGroup[] = [
]
export const ItalicFeatureClient = createClientFeature({
enableFormats: ['italic'],
markdownTransformers: [ITALIC_STAR, ITALIC_UNDERSCORE],
toolbarFixed: {
groups: toolbarGroups,

View File

@@ -28,6 +28,7 @@ const toolbarGroups = [
]
export const StrikethroughFeatureClient = createClientFeature({
enableFormats: ['strikethrough'],
markdownTransformers: [STRIKETHROUGH],
toolbarFixed: {
groups: toolbarGroups,

View File

@@ -29,6 +29,7 @@ const toolbarGroups: ToolbarGroup[] = [
]
export const SubscriptFeatureClient = createClientFeature({
enableFormats: ['subscript'],
toolbarFixed: {
groups: toolbarGroups,
},

View File

@@ -29,6 +29,7 @@ const toolbarGroups: ToolbarGroup[] = [
]
export const SuperscriptFeatureClient = createClientFeature({
enableFormats: ['superscript'],
toolbarFixed: {
groups: toolbarGroups,
},

View File

@@ -29,6 +29,7 @@ const toolbarGroups: ToolbarGroup[] = [
]
export const UnderlineFeatureClient = createClientFeature({
enableFormats: ['underline'],
toolbarFixed: {
groups: toolbarGroups,
},

View File

@@ -1,4 +1,10 @@
import type { Klass, LexicalEditor, LexicalNode, LexicalNodeReplacement } from 'lexical'
import type {
Klass,
LexicalEditor,
LexicalNode,
LexicalNodeReplacement,
TextFormatType,
} from 'lexical'
import type { RichTextFieldClient } from 'payload'
import type React from 'react'
import type { JSX } from 'react'
@@ -95,6 +101,10 @@ export type SanitizedPlugin =
}
export type ClientFeature<ClientFeatureProps> = {
/**
* The text formats which are enabled by this feature.
*/
enableFormats?: Array<Omit<TextFormatType, 'highlight'>>
markdownTransformers?: (
| ((props: {
allNodes: Array<Klass<LexicalNode> | LexicalNodeReplacement>
@@ -204,6 +214,7 @@ export type ClientFeatureProviderMap = Map<string, FeatureProviderClient<any, an
export type SanitizedClientFeatures = {
/** The keys of all enabled features */
enabledFeatures: string[]
enabledFormats: Array<Omit<TextFormatType, 'highlight'>>
markdownTransformers: Transformer[]
/**

View File

@@ -18,6 +18,7 @@ import { AddBlockHandlePlugin } from './plugins/handles/AddBlockHandlePlugin/ind
import { DraggableBlockPlugin } from './plugins/handles/DraggableBlockPlugin/index.js'
import { MarkdownShortcutPlugin } from './plugins/MarkdownShortcut/index.js'
import { SlashMenuPlugin } from './plugins/SlashMenu/index.js'
import { TextPlugin } from './plugins/TextPlugin/index.js'
import { LexicalContentEditable } from './ui/ContentEditable.js'
export const LexicalEditor: React.FC<
@@ -121,6 +122,7 @@ export const LexicalEditor: React.FC<
}
ErrorBoundary={LexicalErrorBoundary}
/>
<TextPlugin features={editorConfig.features} />
<OnChangePlugin
// Selection changes can be ignored here, reducing the
// frequency that the FieldComponent and Payload receive updates.

View File

@@ -14,6 +14,7 @@ export const sanitizeClientFeatures = (
): SanitizedClientFeatures => {
const sanitized: SanitizedClientFeatures = {
enabledFeatures: [],
enabledFormats: [],
markdownTransformers: [],
nodes: [],
plugins: [],
@@ -39,6 +40,10 @@ export const sanitizeClientFeatures = (
sanitized.providers = sanitized.providers.concat(feature.providers)
}
if (feature.enableFormats?.length) {
sanitized.enabledFormats.push(...feature.enableFormats)
}
if (feature.nodes?.length) {
// Important: do not use concat
for (const node of feature.nodes) {

View File

@@ -0,0 +1,43 @@
'use client'
import type { TextFormatType } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { TEXT_TYPE_TO_FORMAT, TextNode } from 'lexical'
import { type SanitizedClientFeatures } from 'packages/richtext-lexical/src/features/typesClient.js'
import { useEffect } from 'react'
export function TextPlugin({ features }: { features: SanitizedClientFeatures }) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const disabledFormats = getDisabledFormats(features.enabledFormats as TextFormatType[])
if (disabledFormats.length === 0) {
return
}
// Ideally override the TextNode with our own TextNode (changing its setFormat or toggleFormat methods),
// would be more performant. If we find a noticeable perf regression we can switch to that option.
// Overriding the FORMAT_TEXT_COMMAND and PASTE_COMMAND commands is not an option I considered because
// there might be other forms of mutation that we might not be considering. For example:
// browser extensions or Payload/Lexical plugins that have their own commands.
return editor.registerNodeTransform(TextNode, (textNode) => {
disabledFormats.forEach((disabledFormat) => {
if (textNode.hasFormat(disabledFormat)) {
textNode.toggleFormat(disabledFormat)
}
})
})
}, [editor, features])
return null
}
function getDisabledFormats(enabledFormats: TextFormatType[]): TextFormatType[] {
// not sure why Lexical added highlight as TextNode format.
// see https://github.com/facebook/lexical/pull/3583
// We are going to implement it in other way to support multiple colors
delete TEXT_TYPE_TO_FORMAT.highlight
const allFormats = Object.keys(TEXT_TYPE_TO_FORMAT) as TextFormatType[]
const enabledSet = new Set(enabledFormats)
return allFormats.filter((format) => !enabledSet.has(format))
}