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)
This commit is contained in:
Alessio Gravili
2025-04-02 14:10:20 -06:00
committed by GitHub
parent f34eb228c4
commit d29bdfc10f
43 changed files with 2444 additions and 55 deletions

View File

@@ -0,0 +1,628 @@
import type { DefaultTypedEditorState, SerializedBlockNode } from '@payloadcms/richtext-lexical'
import { mediaCollectionSlug, textCollectionSlug } from '../../slugs.js'
export function generateLexicalData(args: {
mediaID: number | string
textID: number | string
updated: boolean
}): DefaultTypedEditorState {
return {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Fugiat esse${args.updated ? ' new ' : ''}in dolor aleiqua ${args.updated ? 'gillum' : 'cillum'} proident ad cillum excepteur mollit reprehenderit mollit commodo. Pariatur incididunt non exercitation est mollit nisi labore${args.updated ? ' ' : 'delete'}officia cupidatat amet commodo commodo proident occaecat.`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
{
children: [
{
detail: 0,
format: args.updated ? 1 : 0,
mode: 'normal',
style: '',
text: 'Some ',
type: 'text',
version: 1,
},
{
detail: 0,
format: args.updated ? 0 : 1,
mode: 'normal',
style: '',
text: 'Bold',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' and ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 1,
mode: 'normal',
style: '',
text: 'Italic',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' text with ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'a link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: args.updated ? 'https://www.payloadcms.com' : 'https://www.google.com',
newTab: true,
linkType: 'custom',
},
id: '67d869aa706b36f346ecffd9',
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' and ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'another link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: 'https://www.payload.ai',
newTab: args.updated ? true : false,
linkType: 'custom',
},
id: '67d869aa706b36f346ecffd0',
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' text ',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: args.updated ? 'third link updated' : 'third link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: 'https://www.payloadcms.com/docs',
newTab: true,
linkType: 'custom',
},
id: '67d869aa706b36f346ecffd0',
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: '.',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'link with description',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: 'https://www.payloadcms.com/docs',
description: args.updated ? 'updated description' : 'description',
newTab: true,
linkType: 'custom',
},
id: '67d869aa706b36f346ecffd0',
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'text',
type: 'text',
version: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'identical link',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'link',
version: 3,
fields: {
url: 'https://www.payloadcms.com/docs2',
description: 'description',
newTab: true,
linkType: 'custom',
},
id: '67d869aa706b36f346ecffd0',
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'One',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: args.updated ? 'Two updated' : 'Two',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 2,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Three',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 3,
},
...(args.updated
? [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Four',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 4,
},
]
: []),
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'number',
start: 1,
tag: 'ol',
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'One',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Two',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 2,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: args.updated ? 'Three' : 'Three original',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
value: 3,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'bullet',
start: 1,
tag: 'ul',
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Checked',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
checked: true,
value: 1,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Unchecked',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
version: 1,
checked: args.updated ? false : true,
value: 2,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'list',
version: 1,
listType: 'check',
start: 1,
tag: 'ul',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading1${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h1',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading2${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h2',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading3${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h3',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading4${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h4',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading5${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h5',
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Heading6${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'heading',
version: 1,
tag: 'h6',
},
{
type: 'upload',
version: 3,
format: '',
id: '67d8693c76b36f346ecffd8',
relationTo: mediaCollectionSlug,
value: args.mediaID,
},
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: `Quote${args.updated ? ' updated' : ''}`,
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'quote',
version: 1,
},
{
type: 'relationship',
version: 2,
format: '',
relationTo: textCollectionSlug,
value: args.textID,
},
{
type: 'block',
version: 2,
format: '',
fields: {
id: '67d8693c706b36f346ecffd7',
radios: args.updated ? 'option1' : 'option3',
someText: `Text1${args.updated ? ' updated' : ''}`,
blockName: '',
someTextRequired: 'Text2',
blockType: 'myBlock',
},
} as SerializedBlockNode,
],
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
}
}

View File

@@ -1,6 +1,6 @@
import type { CollectionConfig } from 'payload'
import { diffCollectionSlug, draftCollectionSlug } from '../slugs.js'
import { diffCollectionSlug, draftCollectionSlug } from '../../slugs.js'
export const Diff: CollectionConfig = {
slug: diffCollectionSlug,

View File

@@ -0,0 +1,17 @@
import type { CollectionConfig } from 'payload'
import { textCollectionSlug } from '../slugs.js'
export const TextCollection: CollectionConfig = {
slug: textCollectionSlug,
admin: {
useAsTitle: 'text',
},
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
}

View File

@@ -6,7 +6,7 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import AutosavePosts from './collections/Autosave.js'
import AutosaveWithValidate from './collections/AutosaveWithValidate.js'
import CustomIDs from './collections/CustomIDs.js'
import { Diff } from './collections/Diff.js'
import { Diff } from './collections/Diff/index.js'
import DisablePublish from './collections/DisablePublish.js'
import DraftPosts from './collections/Drafts.js'
import DraftWithMax from './collections/DraftsWithMax.js'
@@ -14,6 +14,7 @@ import DraftsWithValidate from './collections/DraftsWithValidate.js'
import LocalizedPosts from './collections/Localized.js'
import { Media } from './collections/Media.js'
import Posts from './collections/Posts.js'
import { TextCollection } from './collections/Text.js'
import VersionPosts from './collections/Versions.js'
import AutosaveGlobal from './globals/Autosave.js'
import DisablePublishGlobal from './globals/DisablePublish.js'
@@ -42,6 +43,7 @@ export default buildConfigWithDefaults({
VersionPosts,
CustomIDs,
Diff,
TextCollection,
Media,
],
globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal, LocalizedGlobal],

View File

@@ -1384,12 +1384,17 @@ describe('Versions', () => {
const richtext = page.locator('[data-field-path="richtext"]')
await expect(richtext.locator('tr').nth(16).locator('td').nth(1)).toHaveText(
'"text": "richtext",',
)
await expect(richtext.locator('tr').nth(16).locator('td').nth(3)).toHaveText(
'"text": "richtext2",',
)
const oldDiff = richtext.locator('.lexical-diff__diff-old')
const newDiff = richtext.locator('.lexical-diff__diff-new')
const oldHTML =
`Fugiat <span data-match-type="delete">essein</span> dolor aleiqua <span data-match-type="delete">cillum</span> proident ad cillum excepteur mollit reprehenderit mollit commodo. Pariatur incididunt non exercitation est mollit nisi <span data-match-type="delete">laboredeleteofficia</span> cupidatat amet commodo commodo proident occaecat.
`.trim()
const newHTML =
`Fugiat <span data-match-type="create">esse new in</span> dolor aleiqua <span data-match-type="create">gillum</span> proident ad cillum excepteur mollit reprehenderit mollit commodo. Pariatur incididunt non exercitation est mollit nisi <span data-match-type="create">labore officia</span> cupidatat amet commodo commodo proident occaecat.`.trim()
expect(await oldDiff.locator('p').first().innerHTML()).toEqual(oldHTML)
expect(await newDiff.locator('p').first().innerHTML()).toEqual(newHTML)
})
test('correctly renders diff for richtext fields with custom Diff component', async () => {

View File

@@ -78,6 +78,7 @@ export interface Config {
'version-posts': VersionPost;
'custom-ids': CustomId;
diff: Diff;
text: Text;
media: Media;
users: User;
'payload-jobs': PayloadJob;
@@ -98,6 +99,7 @@ export interface Config {
'version-posts': VersionPostsSelect<false> | VersionPostsSelect<true>;
'custom-ids': CustomIdsSelect<false> | CustomIdsSelect<true>;
diff: DiffSelect<false> | DiffSelect<true>;
text: TextSelect<false> | TextSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
@@ -414,6 +416,16 @@ export interface Media {
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "text".
*/
export interface Text {
id: string;
text: string;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
@@ -574,6 +586,10 @@ export interface PayloadLockedDocument {
relationTo: 'diff';
value: string | Diff;
} | null)
| ({
relationTo: 'text';
value: string | Text;
} | null)
| ({
relationTo: 'media';
value: string | Media;
@@ -842,6 +858,15 @@ export interface DiffSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "text_select".
*/
export interface TextSelect<T extends boolean = true> {
text?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".

View File

@@ -6,6 +6,7 @@ import type { DraftPost } from './payload-types.js'
import { devUser } from '../credentials.js'
import { executePromises } from '../helpers/executePromises.js'
import { generateLexicalData } from './collections/Diff/generateLexicalData.js'
import {
autosaveWithValidateCollectionSlug,
diffCollectionSlug,
@@ -119,6 +120,20 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
},
})
const { id: doc1ID } = await _payload.create({
collection: 'text',
data: {
text: 'Document 1',
},
})
const { id: doc2ID } = await _payload.create({
collection: 'text',
data: {
text: 'Document 2',
},
})
const diffDoc = await _payload.create({
collection: diffCollectionSlug,
locale: 'en',
@@ -165,7 +180,11 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
point: [1, 2],
radio: 'option1',
relationship: manyDraftsID,
richtext: textToLexicalJSON({ text: 'richtext' }),
richtext: generateLexicalData({
mediaID: uploadedImage,
textID: doc1ID,
updated: false,
}) as any,
richtextWithCustomDiff: textToLexicalJSON({ text: 'richtextWithCustomDiff' }),
select: 'option1',
text: 'text',
@@ -225,7 +244,11 @@ export async function seed(_payload: Payload, parallel: boolean = false) {
point: [1, 3],
radio: 'option2',
relationship: draft2.id,
richtext: textToLexicalJSON({ text: 'richtext2' }),
richtext: generateLexicalData({
mediaID: uploadedImage2,
textID: doc2ID,
updated: true,
}) as any,
richtextWithCustomDiff: textToLexicalJSON({ text: 'richtextWithCustomDiff2' }),
select: 'option2',
text: 'text2',

View File

@@ -20,6 +20,8 @@ export const disablePublishSlug = 'disable-publish'
export const disablePublishGlobalSlug = 'disable-publish-global'
export const textCollectionSlug = 'text'
export const collectionSlugs = [
autosaveCollectionSlug,
draftCollectionSlug,
@@ -27,6 +29,7 @@ export const collectionSlugs = [
diffCollectionSlug,
mediaCollectionSlug,
versionCollectionSlug,
textCollectionSlug,
]
export const autoSaveGlobalSlug = 'autosave-global'