Skip to content

Commit d29bdfc

Browse files
authored
feat(next): improved lexical richText diffing in version view (#11760)
This replaces our JSON-based richtext diffing with HTML-based richtext diffing for lexical. It uses [this HTML diff library](https://github.com/Arman19941113/html-diff) that I then modified to handle diffing more complex elements like links, uploads and relationships. This makes it way easier to spot changes, replacing the lengthy Lexical JSON with a clean visual diff that shows exactly what's different. ## Before ![CleanShot 2025-03-18 at 13 54 51@2x](https://github.com/user-attachments/assets/811a7c14-d592-4fdc-a1f4-07eeb78255fe) ## After ![CleanShot 2025-03-31 at 18 14 10@2x](https://github.com/user-attachments/assets/efb64da0-4ff8-4965-a458-558a18375c46) ![CleanShot 2025-03-31 at 18 14 26@2x](https://github.com/user-attachments/assets/133652ce-503b-4b86-9c4c-e5c7706d8ea6)
1 parent f34eb22 commit d29bdfc

File tree

43 files changed

+2444
-55
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2444
-55
lines changed

Diff for: packages/next/src/views/Version/RenderFieldsToDiff/DiffCollapser/index.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
'use client'
22
import type { ClientField } from 'payload'
33

4-
import { ChevronIcon, Pill, useConfig, useTranslation } from '@payloadcms/ui'
4+
import { ChevronIcon, FieldDiffLabel, Pill, useConfig, useTranslation } from '@payloadcms/ui'
55
import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared'
66
import React, { useState } from 'react'
77

8-
import Label from '../Label/index.js'
98
import './index.scss'
109
import { countChangedFields, countChangedFieldsInRows } from '../utilities/countChangedFields.js'
1110

@@ -100,7 +99,7 @@ export const DiffCollapser: React.FC<Props> = ({
10099

101100
return (
102101
<div className={baseClass}>
103-
<Label>
102+
<FieldDiffLabel>
104103
<button
105104
aria-label={isCollapsed ? 'Expand' : 'Collapse'}
106105
className={`${baseClass}__toggle-button`}
@@ -115,7 +114,7 @@ export const DiffCollapser: React.FC<Props> = ({
115114
{t('version:changedFieldsCount', { count: changeCount })}
116115
</Pill>
117116
)}
118-
</Label>
117+
</FieldDiffLabel>
119118
<div className={contentClassNames}>{children}</div>
120119
</div>
121120
)

Diff for: packages/next/src/views/Version/RenderFieldsToDiff/buildVersionFields.tsx

+33-15
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
import type { I18nClient } from '@payloadcms/translations'
2-
import type {
3-
BaseVersionField,
4-
ClientField,
5-
ClientFieldSchemaMap,
6-
Field,
7-
FieldDiffClientProps,
8-
FieldDiffServerProps,
9-
FieldTypes,
10-
FlattenedBlock,
11-
PayloadComponent,
12-
PayloadRequest,
13-
SanitizedFieldPermissions,
14-
VersionField,
15-
} from 'payload'
162
import type { DiffMethod } from 'react-diff-viewer-continued'
173

184
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
195
import { dequal } from 'dequal/lite'
6+
import {
7+
type BaseVersionField,
8+
type ClientField,
9+
type ClientFieldSchemaMap,
10+
type Field,
11+
type FieldDiffClientProps,
12+
type FieldDiffServerProps,
13+
type FieldTypes,
14+
type FlattenedBlock,
15+
MissingEditorProp,
16+
type PayloadComponent,
17+
type PayloadRequest,
18+
type SanitizedFieldPermissions,
19+
type VersionField,
20+
} from 'payload'
2021
import { fieldIsID, fieldShouldBeLocalized, getUniqueListBy, tabHasName } from 'payload/shared'
2122

2223
import { diffMethods } from './fields/diffMethods.js'
@@ -238,7 +239,24 @@ const buildVersionField = ({
238239
return null
239240
}
240241

241-
const CustomComponent = field?.admin?.components?.Diff ?? customDiffComponents?.[field.type]
242+
let CustomComponent = customDiffComponents?.[field.type]
243+
if (field?.type === 'richText') {
244+
if (!field?.editor) {
245+
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
246+
}
247+
248+
if (typeof field?.editor === 'function') {
249+
throw new Error('Attempted to access unsanitized rich text editor.')
250+
}
251+
252+
if (field.editor.CellComponent) {
253+
CustomComponent = field.editor.DiffComponent
254+
}
255+
}
256+
if (field?.admin?.components?.Diff) {
257+
CustomComponent = field.admin.components.Diff
258+
}
259+
242260
const DefaultComponent = diffComponents?.[field.type]
243261

244262
const baseVersionField: BaseVersionField = {

Diff for: packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ import type {
77
} from 'payload'
88

99
import { getTranslation } from '@payloadcms/translations'
10-
import { useConfig, useTranslation } from '@payloadcms/ui'
10+
import { FieldDiffLabel, useConfig, useTranslation } from '@payloadcms/ui'
1111
import { fieldAffectsData, fieldIsPresentationalOnly, fieldShouldBeLocalized } from 'payload/shared'
1212
import React from 'react'
1313
import ReactDiffViewer from 'react-diff-viewer-continued'
1414

15-
import Label from '../../Label/index.js'
1615
import './index.scss'
1716
import { diffStyles } from '../styles.js'
1817

@@ -169,10 +168,10 @@ export const Relationship: RelationshipFieldDiffClientComponent = ({
169168

170169
return (
171170
<div className={baseClass}>
172-
<Label>
171+
<FieldDiffLabel>
173172
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
174173
{getTranslation(label, i18n)}
175-
</Label>
174+
</FieldDiffLabel>
176175
<ReactDiffViewer
177176
hideLineNumbers
178177
newValue={versionToRender}

Diff for: packages/next/src/views/Version/RenderFieldsToDiff/fields/Select/index.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import type { I18nClient } from '@payloadcms/translations'
33
import type { Option, SelectField, SelectFieldDiffClientComponent } from 'payload'
44

55
import { getTranslation } from '@payloadcms/translations'
6-
import { useTranslation } from '@payloadcms/ui'
6+
import { FieldDiffLabel, useTranslation } from '@payloadcms/ui'
77
import React from 'react'
88

9-
import Label from '../../Label/index.js'
109
import './index.scss'
1110
import { diffStyles } from '../styles.js'
1211
import { DiffViewer } from './DiffViewer/index.js'
@@ -103,10 +102,10 @@ export const Select: SelectFieldDiffClientComponent = ({
103102

104103
return (
105104
<div className={baseClass}>
106-
<Label>
105+
<FieldDiffLabel>
107106
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
108107
{'label' in field && getTranslation(field.label || '', i18n)}
109-
</Label>
108+
</FieldDiffLabel>
110109
<DiffViewer
111110
comparisonToRender={comparisonToRender}
112111
diffMethod={diffMethod}

Diff for: packages/next/src/views/Version/RenderFieldsToDiff/fields/Text/index.tsx

+3-4
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
import type { TextFieldDiffClientComponent } from 'payload'
33

44
import { getTranslation } from '@payloadcms/translations'
5-
import { useTranslation } from '@payloadcms/ui'
5+
import { FieldDiffLabel, useTranslation } from '@payloadcms/ui'
66
import React from 'react'
77

8-
import Label from '../../Label/index.js'
98
import './index.scss'
109
import { diffStyles } from '../styles.js'
1110
import { DiffViewer } from './DiffViewer/index.js'
@@ -34,12 +33,12 @@ export const Text: TextFieldDiffClientComponent = ({
3433

3534
return (
3635
<div className={baseClass}>
37-
<Label>
36+
<FieldDiffLabel>
3837
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
3938
{'label' in field &&
4039
typeof field.label !== 'function' &&
4140
getTranslation(field.label || '', i18n)}
42-
</Label>
41+
</FieldDiffLabel>
4342
<DiffViewer
4443
comparisonToRender={comparisonToRender}
4544
diffMethod={diffMethod}

Diff for: packages/next/src/views/Version/RenderFieldsToDiff/fields/styles.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
export const diffStyles = {
1+
import type { ReactDiffViewerStylesOverride } from 'react-diff-viewer-continued'
2+
3+
export const diffStyles: ReactDiffViewerStylesOverride = {
24
diffContainer: {
35
minWidth: 'unset',
46
},
@@ -26,4 +28,11 @@ export const diffStyles = {
2628
wordRemovedBackground: 'var(--theme-error-200)',
2729
},
2830
},
31+
wordAdded: {
32+
color: 'var(--theme-success-600)',
33+
},
34+
wordRemoved: {
35+
color: 'var(--theme-error-600)',
36+
textDecorationLine: 'line-through',
37+
},
2938
}

Diff for: packages/payload/src/admin/RichText.ts

+17-4
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ import type { JSONSchema4 } from 'json-schema'
55
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
66
import type { Config, PayloadComponent, SanitizedConfig } from '../config/types.js'
77
import type { ValidationFieldError } from '../errors/ValidationError.js'
8-
import type { FieldAffectingData, RichTextField, Validate } from '../fields/config/types.js'
8+
import type {
9+
FieldAffectingData,
10+
RichTextField,
11+
RichTextFieldClient,
12+
Validate,
13+
} from '../fields/config/types.js'
914
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
1015
import type { RequestContext } from '../index.js'
1116
import type { JsonObject, PayloadRequest, PopulateType } from '../types/index.js'
12-
import type { RichTextFieldClientProps } from './fields/RichText.js'
13-
import type { FieldSchemaMap } from './types.js'
17+
import type { RichTextFieldClientProps, RichTextFieldServerProps } from './fields/RichText.js'
18+
import type { FieldDiffClientProps, FieldDiffServerProps, FieldSchemaMap } from './types.js'
1419

1520
export type AfterReadRichTextHookArgs<
1621
TData extends TypeWithID = any,
@@ -248,7 +253,15 @@ export type RichTextAdapter<
248253
ExtraFieldProperties = any,
249254
> = {
250255
CellComponent: PayloadComponent<never>
251-
FieldComponent: PayloadComponent<never, RichTextFieldClientProps>
256+
/**
257+
* Component that will be displayed in the version diff view.
258+
* If not provided, richtext content will be diffed as JSON.
259+
*/
260+
DiffComponent?: PayloadComponent<
261+
FieldDiffServerProps<RichTextField, RichTextFieldClient>,
262+
FieldDiffClientProps<RichTextFieldClient>
263+
>
264+
FieldComponent: PayloadComponent<RichTextFieldServerProps, RichTextFieldClientProps>
252265
} & RichTextAdapterBase<Value, AdapterProps, ExtraFieldProperties>
253266

254267
export type RichTextAdapterProvider<

Diff for: packages/payload/src/fields/config/types.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import type {
5757
EmailFieldLabelServerComponent,
5858
FieldDescriptionClientProps,
5959
FieldDescriptionServerProps,
60-
FieldDiffClientComponent,
60+
FieldDiffClientProps,
6161
FieldDiffServerProps,
6262
GroupFieldClientProps,
6363
GroupFieldLabelClientComponent,
@@ -326,7 +326,7 @@ type Admin = {
326326
components?: {
327327
Cell?: PayloadComponent<DefaultServerCellComponentProps, DefaultCellComponentProps>
328328
Description?: PayloadComponent<FieldDescriptionServerProps, FieldDescriptionClientProps>
329-
Diff?: PayloadComponent<FieldDiffServerProps, FieldDiffClientComponent>
329+
Diff?: PayloadComponent<FieldDiffServerProps, FieldDiffClientProps>
330330
Field?: PayloadComponent<FieldClientComponent | FieldServerComponent>
331331
/**
332332
* The Filter component has to be a client component

Diff for: packages/richtext-lexical/src/exports/server/rsc.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { RscEntryLexicalCell } from '../../cell/rscEntry.js'
2+
export { LexicalDiffComponent } from '../../field/Diff/index.js'
23
export { RscEntryLexicalField } from '../../field/rscEntry.js'

Diff for: packages/richtext-lexical/src/features/converters/lexicalToHtml/async/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export type HTMLConvertersAsync<
8383
: SerializedInlineBlockNode
8484
>
8585
}
86+
unknown?: HTMLConverterAsync<SerializedLexicalNode>
8687
}
8788

8889
export type HTMLConvertersFunctionAsync<

Diff for: packages/richtext-lexical/src/features/converters/lexicalToHtml/shared/findConverterForNode.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable no-console */
12
import type { SerializedLexicalNode } from 'lexical'
23

34
import type { SerializedBlockNode, SerializedInlineBlockNode } from '../../../../nodeTypes.js'
@@ -30,7 +31,7 @@ export function findConverterForNode<
3031
converterForNode = converters?.blocks?.[
3132
(node as SerializedBlockNode)?.fields?.blockType
3233
] as TConverter
33-
if (!converterForNode) {
34+
if (!converterForNode && !unknownConverter) {
3435
console.error(
3536
`Lexical => HTML converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`,
3637
)
@@ -39,7 +40,7 @@ export function findConverterForNode<
3940
converterForNode = converters?.inlineBlocks?.[
4041
(node as SerializedInlineBlockNode)?.fields?.blockType
4142
] as TConverter
42-
if (!converterForNode) {
43+
if (!converterForNode && !unknownConverter) {
4344
console.error(
4445
`Lexical => HTML converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`,
4546
)

Diff for: packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export type HTMLConverters<
7171
: SerializedInlineBlockNode
7272
>
7373
}
74+
unknown?: HTMLConverter<SerializedLexicalNode>
7475
}
7576

7677
export type HTMLConvertersFunction<

Diff for: packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/index.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable no-console */
12
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
23

34
import React from 'react'
@@ -51,15 +52,15 @@ export function convertLexicalNodesToJSX({
5152
let converterForNode: JSXConverter<any> | undefined
5253
if (node.type === 'block') {
5354
converterForNode = converters?.blocks?.[(node as SerializedBlockNode)?.fields?.blockType]
54-
if (!converterForNode) {
55+
if (!converterForNode && !unknownConverter) {
5556
console.error(
5657
`Lexical => JSX converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`,
5758
)
5859
}
5960
} else if (node.type === 'inlineBlock') {
6061
converterForNode =
6162
converters?.inlineBlocks?.[(node as SerializedInlineBlockNode)?.fields?.blockType]
62-
if (!converterForNode) {
63+
if (!converterForNode && !unknownConverter) {
6364
console.error(
6465
`Lexical => JSX converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`,
6566
)

Diff for: packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export type JSXConverters<
6666
: SerializedInlineBlockNode
6767
>
6868
}
69+
unknown?: JSXConverter<SerializedLexicalNode>
6970
}
7071
export type SerializedLexicalNodeWithParent = {
7172
parent?: SerializedLexicalNode

Diff for: packages/richtext-lexical/src/field/Diff/colors.scss

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
@import '../../scss/styles.scss';
2+
3+
@layer payload-default {
4+
:root {
5+
--diff-delete-pill-bg: var(--theme-error-200);
6+
--diff-delete-pill-color: var(--theme-error-600);
7+
--diff-delete-pill-border: var(--theme-error-400);
8+
--diff-delete-parent-bg: var(--theme-error-100);
9+
--diff-delete-parent-color: var(--theme-error-800);
10+
--diff-delete-link-color: var(--theme-error-600);
11+
12+
--diff-create-pill-bg: var(--theme-success-200);
13+
--diff-create-pill-color: var(--theme-success-600);
14+
--diff-create-pill-border: var(--theme-success-400);
15+
--diff-create-parent-bg: var(--theme-success-100);
16+
--diff-create-parent-color: var(--theme-success-800);
17+
--diff-create-link-color: var(--theme-success-600);
18+
}
19+
20+
html[data-theme='dark'] {
21+
--diff-delete-pill-bg: var(--theme-error-200);
22+
--diff-delete-pill-color: var(--theme-error-650);
23+
--diff-delete-pill-border: var(--theme-error-400);
24+
--diff-delete-parent-bg: var(--theme-error-100);
25+
--diff-delete-parent-color: var(--theme-error-900);
26+
--diff-delete-link-color: var(--theme-error-750);
27+
28+
--diff-create-pill-bg: var(--theme-success-200);
29+
--diff-create-pill-color: var(--theme-success-650);
30+
--diff-create-pill-border: var(--theme-success-400);
31+
--diff-create-parent-bg: var(--theme-success-100);
32+
--diff-create-parent-color: var(--theme-success-900);
33+
--diff-create-link-color: var(--theme-success-750);
34+
}
35+
}

0 commit comments

Comments
 (0)