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

@@ -1,11 +1,10 @@
'use client' 'use client'
import type { ClientField } from 'payload' import type { ClientField } from 'payload'
import { ChevronIcon, Pill, useConfig, useTranslation } from '@payloadcms/ui' import { ChevronIcon, FieldDiffLabel, Pill, useConfig, useTranslation } from '@payloadcms/ui'
import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared' import { fieldIsArrayType, fieldIsBlockType } from 'payload/shared'
import React, { useState } from 'react' import React, { useState } from 'react'
import Label from '../Label/index.js'
import './index.scss' import './index.scss'
import { countChangedFields, countChangedFieldsInRows } from '../utilities/countChangedFields.js' import { countChangedFields, countChangedFieldsInRows } from '../utilities/countChangedFields.js'
@@ -100,7 +99,7 @@ export const DiffCollapser: React.FC<Props> = ({
return ( return (
<div className={baseClass}> <div className={baseClass}>
<Label> <FieldDiffLabel>
<button <button
aria-label={isCollapsed ? 'Expand' : 'Collapse'} aria-label={isCollapsed ? 'Expand' : 'Collapse'}
className={`${baseClass}__toggle-button`} className={`${baseClass}__toggle-button`}
@@ -115,7 +114,7 @@ export const DiffCollapser: React.FC<Props> = ({
{t('version:changedFieldsCount', { count: changeCount })} {t('version:changedFieldsCount', { count: changeCount })}
</Pill> </Pill>
)} )}
</Label> </FieldDiffLabel>
<div className={contentClassNames}>{children}</div> <div className={contentClassNames}>{children}</div>
</div> </div>
) )

View File

@@ -1,22 +1,23 @@
import type { I18nClient } from '@payloadcms/translations' import type { I18nClient } from '@payloadcms/translations'
import type {
BaseVersionField,
ClientField,
ClientFieldSchemaMap,
Field,
FieldDiffClientProps,
FieldDiffServerProps,
FieldTypes,
FlattenedBlock,
PayloadComponent,
PayloadRequest,
SanitizedFieldPermissions,
VersionField,
} from 'payload'
import type { DiffMethod } from 'react-diff-viewer-continued' import type { DiffMethod } from 'react-diff-viewer-continued'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { dequal } from 'dequal/lite' import { dequal } from 'dequal/lite'
import {
type BaseVersionField,
type ClientField,
type ClientFieldSchemaMap,
type Field,
type FieldDiffClientProps,
type FieldDiffServerProps,
type FieldTypes,
type FlattenedBlock,
MissingEditorProp,
type PayloadComponent,
type PayloadRequest,
type SanitizedFieldPermissions,
type VersionField,
} from 'payload'
import { fieldIsID, fieldShouldBeLocalized, getUniqueListBy, tabHasName } from 'payload/shared' import { fieldIsID, fieldShouldBeLocalized, getUniqueListBy, tabHasName } from 'payload/shared'
import { diffMethods } from './fields/diffMethods.js' import { diffMethods } from './fields/diffMethods.js'
@@ -238,7 +239,24 @@ const buildVersionField = ({
return null return null
} }
const CustomComponent = field?.admin?.components?.Diff ?? customDiffComponents?.[field.type] let CustomComponent = customDiffComponents?.[field.type]
if (field?.type === 'richText') {
if (!field?.editor) {
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
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
if (field.editor.CellComponent) {
CustomComponent = field.editor.DiffComponent
}
}
if (field?.admin?.components?.Diff) {
CustomComponent = field.admin.components.Diff
}
const DefaultComponent = diffComponents?.[field.type] const DefaultComponent = diffComponents?.[field.type]
const baseVersionField: BaseVersionField = { const baseVersionField: BaseVersionField = {

View File

@@ -7,12 +7,11 @@ import type {
} from 'payload' } from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { useConfig, useTranslation } from '@payloadcms/ui' import { FieldDiffLabel, useConfig, useTranslation } from '@payloadcms/ui'
import { fieldAffectsData, fieldIsPresentationalOnly, fieldShouldBeLocalized } from 'payload/shared' import { fieldAffectsData, fieldIsPresentationalOnly, fieldShouldBeLocalized } from 'payload/shared'
import React from 'react' import React from 'react'
import ReactDiffViewer from 'react-diff-viewer-continued' import ReactDiffViewer from 'react-diff-viewer-continued'
import Label from '../../Label/index.js'
import './index.scss' import './index.scss'
import { diffStyles } from '../styles.js' import { diffStyles } from '../styles.js'
@@ -169,10 +168,10 @@ export const Relationship: RelationshipFieldDiffClientComponent = ({
return ( return (
<div className={baseClass}> <div className={baseClass}>
<Label> <FieldDiffLabel>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>} {locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{getTranslation(label, i18n)} {getTranslation(label, i18n)}
</Label> </FieldDiffLabel>
<ReactDiffViewer <ReactDiffViewer
hideLineNumbers hideLineNumbers
newValue={versionToRender} newValue={versionToRender}

View File

@@ -3,10 +3,9 @@ import type { I18nClient } from '@payloadcms/translations'
import type { Option, SelectField, SelectFieldDiffClientComponent } from 'payload' import type { Option, SelectField, SelectFieldDiffClientComponent } from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { useTranslation } from '@payloadcms/ui' import { FieldDiffLabel, useTranslation } from '@payloadcms/ui'
import React from 'react' import React from 'react'
import Label from '../../Label/index.js'
import './index.scss' import './index.scss'
import { diffStyles } from '../styles.js' import { diffStyles } from '../styles.js'
import { DiffViewer } from './DiffViewer/index.js' import { DiffViewer } from './DiffViewer/index.js'
@@ -103,10 +102,10 @@ export const Select: SelectFieldDiffClientComponent = ({
return ( return (
<div className={baseClass}> <div className={baseClass}>
<Label> <FieldDiffLabel>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>} {locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{'label' in field && getTranslation(field.label || '', i18n)} {'label' in field && getTranslation(field.label || '', i18n)}
</Label> </FieldDiffLabel>
<DiffViewer <DiffViewer
comparisonToRender={comparisonToRender} comparisonToRender={comparisonToRender}
diffMethod={diffMethod} diffMethod={diffMethod}

View File

@@ -2,10 +2,9 @@
import type { TextFieldDiffClientComponent } from 'payload' import type { TextFieldDiffClientComponent } from 'payload'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { useTranslation } from '@payloadcms/ui' import { FieldDiffLabel, useTranslation } from '@payloadcms/ui'
import React from 'react' import React from 'react'
import Label from '../../Label/index.js'
import './index.scss' import './index.scss'
import { diffStyles } from '../styles.js' import { diffStyles } from '../styles.js'
import { DiffViewer } from './DiffViewer/index.js' import { DiffViewer } from './DiffViewer/index.js'
@@ -34,12 +33,12 @@ export const Text: TextFieldDiffClientComponent = ({
return ( return (
<div className={baseClass}> <div className={baseClass}>
<Label> <FieldDiffLabel>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>} {locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{'label' in field && {'label' in field &&
typeof field.label !== 'function' && typeof field.label !== 'function' &&
getTranslation(field.label || '', i18n)} getTranslation(field.label || '', i18n)}
</Label> </FieldDiffLabel>
<DiffViewer <DiffViewer
comparisonToRender={comparisonToRender} comparisonToRender={comparisonToRender}
diffMethod={diffMethod} diffMethod={diffMethod}

View File

@@ -1,4 +1,6 @@
export const diffStyles = { import type { ReactDiffViewerStylesOverride } from 'react-diff-viewer-continued'
export const diffStyles: ReactDiffViewerStylesOverride = {
diffContainer: { diffContainer: {
minWidth: 'unset', minWidth: 'unset',
}, },
@@ -26,4 +28,11 @@ export const diffStyles = {
wordRemovedBackground: 'var(--theme-error-200)', wordRemovedBackground: 'var(--theme-error-200)',
}, },
}, },
wordAdded: {
color: 'var(--theme-success-600)',
},
wordRemoved: {
color: 'var(--theme-error-600)',
textDecorationLine: 'line-through',
},
} }

View File

@@ -5,12 +5,17 @@ import type { JSONSchema4 } from 'json-schema'
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js' import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
import type { Config, PayloadComponent, SanitizedConfig } from '../config/types.js' import type { Config, PayloadComponent, SanitizedConfig } from '../config/types.js'
import type { ValidationFieldError } from '../errors/ValidationError.js' import type { ValidationFieldError } from '../errors/ValidationError.js'
import type { FieldAffectingData, RichTextField, Validate } from '../fields/config/types.js' import type {
FieldAffectingData,
RichTextField,
RichTextFieldClient,
Validate,
} from '../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../globals/config/types.js' import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { RequestContext } from '../index.js' import type { RequestContext } from '../index.js'
import type { JsonObject, PayloadRequest, PopulateType } from '../types/index.js' import type { JsonObject, PayloadRequest, PopulateType } from '../types/index.js'
import type { RichTextFieldClientProps } from './fields/RichText.js' import type { RichTextFieldClientProps, RichTextFieldServerProps } from './fields/RichText.js'
import type { FieldSchemaMap } from './types.js' import type { FieldDiffClientProps, FieldDiffServerProps, FieldSchemaMap } from './types.js'
export type AfterReadRichTextHookArgs< export type AfterReadRichTextHookArgs<
TData extends TypeWithID = any, TData extends TypeWithID = any,
@@ -248,7 +253,15 @@ export type RichTextAdapter<
ExtraFieldProperties = any, ExtraFieldProperties = any,
> = { > = {
CellComponent: PayloadComponent<never> CellComponent: PayloadComponent<never>
FieldComponent: PayloadComponent<never, RichTextFieldClientProps> /**
* Component that will be displayed in the version diff view.
* If not provided, richtext content will be diffed as JSON.
*/
DiffComponent?: PayloadComponent<
FieldDiffServerProps<RichTextField, RichTextFieldClient>,
FieldDiffClientProps<RichTextFieldClient>
>
FieldComponent: PayloadComponent<RichTextFieldServerProps, RichTextFieldClientProps>
} & RichTextAdapterBase<Value, AdapterProps, ExtraFieldProperties> } & RichTextAdapterBase<Value, AdapterProps, ExtraFieldProperties>
export type RichTextAdapterProvider< export type RichTextAdapterProvider<

View File

@@ -57,7 +57,7 @@ import type {
EmailFieldLabelServerComponent, EmailFieldLabelServerComponent,
FieldDescriptionClientProps, FieldDescriptionClientProps,
FieldDescriptionServerProps, FieldDescriptionServerProps,
FieldDiffClientComponent, FieldDiffClientProps,
FieldDiffServerProps, FieldDiffServerProps,
GroupFieldClientProps, GroupFieldClientProps,
GroupFieldLabelClientComponent, GroupFieldLabelClientComponent,
@@ -326,7 +326,7 @@ type Admin = {
components?: { components?: {
Cell?: PayloadComponent<DefaultServerCellComponentProps, DefaultCellComponentProps> Cell?: PayloadComponent<DefaultServerCellComponentProps, DefaultCellComponentProps>
Description?: PayloadComponent<FieldDescriptionServerProps, FieldDescriptionClientProps> Description?: PayloadComponent<FieldDescriptionServerProps, FieldDescriptionClientProps>
Diff?: PayloadComponent<FieldDiffServerProps, FieldDiffClientComponent> Diff?: PayloadComponent<FieldDiffServerProps, FieldDiffClientProps>
Field?: PayloadComponent<FieldClientComponent | FieldServerComponent> Field?: PayloadComponent<FieldClientComponent | FieldServerComponent>
/** /**
* The Filter component has to be a client component * The Filter component has to be a client component

View File

@@ -1,2 +1,3 @@
export { RscEntryLexicalCell } from '../../cell/rscEntry.js' export { RscEntryLexicalCell } from '../../cell/rscEntry.js'
export { LexicalDiffComponent } from '../../field/Diff/index.js'
export { RscEntryLexicalField } from '../../field/rscEntry.js' export { RscEntryLexicalField } from '../../field/rscEntry.js'

View File

@@ -83,6 +83,7 @@ export type HTMLConvertersAsync<
: SerializedInlineBlockNode : SerializedInlineBlockNode
> >
} }
unknown?: HTMLConverterAsync<SerializedLexicalNode>
} }
export type HTMLConvertersFunctionAsync< export type HTMLConvertersFunctionAsync<

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-console */
import type { SerializedLexicalNode } from 'lexical' import type { SerializedLexicalNode } from 'lexical'
import type { SerializedBlockNode, SerializedInlineBlockNode } from '../../../../nodeTypes.js' import type { SerializedBlockNode, SerializedInlineBlockNode } from '../../../../nodeTypes.js'
@@ -30,7 +31,7 @@ export function findConverterForNode<
converterForNode = converters?.blocks?.[ converterForNode = converters?.blocks?.[
(node as SerializedBlockNode)?.fields?.blockType (node as SerializedBlockNode)?.fields?.blockType
] as TConverter ] as TConverter
if (!converterForNode) { if (!converterForNode && !unknownConverter) {
console.error( console.error(
`Lexical => HTML converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`, `Lexical => HTML converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`,
) )
@@ -39,7 +40,7 @@ export function findConverterForNode<
converterForNode = converters?.inlineBlocks?.[ converterForNode = converters?.inlineBlocks?.[
(node as SerializedInlineBlockNode)?.fields?.blockType (node as SerializedInlineBlockNode)?.fields?.blockType
] as TConverter ] as TConverter
if (!converterForNode) { if (!converterForNode && !unknownConverter) {
console.error( console.error(
`Lexical => HTML converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`, `Lexical => HTML converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`,
) )

View File

@@ -71,6 +71,7 @@ export type HTMLConverters<
: SerializedInlineBlockNode : SerializedInlineBlockNode
> >
} }
unknown?: HTMLConverter<SerializedLexicalNode>
} }
export type HTMLConvertersFunction< export type HTMLConvertersFunction<

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-console */
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical' import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
import React from 'react' import React from 'react'
@@ -51,7 +52,7 @@ export function convertLexicalNodesToJSX({
let converterForNode: JSXConverter<any> | undefined let converterForNode: JSXConverter<any> | undefined
if (node.type === 'block') { if (node.type === 'block') {
converterForNode = converters?.blocks?.[(node as SerializedBlockNode)?.fields?.blockType] converterForNode = converters?.blocks?.[(node as SerializedBlockNode)?.fields?.blockType]
if (!converterForNode) { if (!converterForNode && !unknownConverter) {
console.error( console.error(
`Lexical => JSX converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`, `Lexical => JSX converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`,
) )
@@ -59,7 +60,7 @@ export function convertLexicalNodesToJSX({
} else if (node.type === 'inlineBlock') { } else if (node.type === 'inlineBlock') {
converterForNode = converterForNode =
converters?.inlineBlocks?.[(node as SerializedInlineBlockNode)?.fields?.blockType] converters?.inlineBlocks?.[(node as SerializedInlineBlockNode)?.fields?.blockType]
if (!converterForNode) { if (!converterForNode && !unknownConverter) {
console.error( console.error(
`Lexical => JSX converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`, `Lexical => JSX converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`,
) )

View File

@@ -66,6 +66,7 @@ export type JSXConverters<
: SerializedInlineBlockNode : SerializedInlineBlockNode
> >
} }
unknown?: JSXConverter<SerializedLexicalNode>
} }
export type SerializedLexicalNodeWithParent = { export type SerializedLexicalNodeWithParent = {
parent?: SerializedLexicalNode parent?: SerializedLexicalNode

View File

@@ -0,0 +1,35 @@
@import '../../scss/styles.scss';
@layer payload-default {
:root {
--diff-delete-pill-bg: var(--theme-error-200);
--diff-delete-pill-color: var(--theme-error-600);
--diff-delete-pill-border: var(--theme-error-400);
--diff-delete-parent-bg: var(--theme-error-100);
--diff-delete-parent-color: var(--theme-error-800);
--diff-delete-link-color: var(--theme-error-600);
--diff-create-pill-bg: var(--theme-success-200);
--diff-create-pill-color: var(--theme-success-600);
--diff-create-pill-border: var(--theme-success-400);
--diff-create-parent-bg: var(--theme-success-100);
--diff-create-parent-color: var(--theme-success-800);
--diff-create-link-color: var(--theme-success-600);
}
html[data-theme='dark'] {
--diff-delete-pill-bg: var(--theme-error-200);
--diff-delete-pill-color: var(--theme-error-650);
--diff-delete-pill-border: var(--theme-error-400);
--diff-delete-parent-bg: var(--theme-error-100);
--diff-delete-parent-color: var(--theme-error-900);
--diff-delete-link-color: var(--theme-error-750);
--diff-create-pill-bg: var(--theme-success-200);
--diff-create-pill-color: var(--theme-success-650);
--diff-create-pill-border: var(--theme-success-400);
--diff-create-parent-bg: var(--theme-success-100);
--diff-create-parent-color: var(--theme-success-900);
--diff-create-link-color: var(--theme-success-750);
}
}

View File

@@ -0,0 +1,59 @@
import { createHash } from 'crypto'
import type {
HTMLConvertersAsync,
HTMLPopulateFn,
} from '../../../features/converters/lexicalToHtml/async/types.js'
import type { SerializedAutoLinkNode, SerializedLinkNode } from '../../../nodeTypes.js'
export const LinkDiffHTMLConverterAsync: (args: {
internalDocToHref?: (args: {
linkNode: SerializedLinkNode
populate?: HTMLPopulateFn
}) => Promise<string> | string
}) => HTMLConvertersAsync<SerializedAutoLinkNode | SerializedLinkNode> = ({
internalDocToHref,
}) => ({
autolink: async ({ node, nodesToHTML, providedStyleTag }) => {
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
// hash fields to ensure they are diffed if they change
const nodeFieldsHash = createHash('sha256').update(JSON.stringify(node.fields)).digest('hex')
return `<a${providedStyleTag} data-fields-hash="${nodeFieldsHash}" data-enable-match="true" href="${node.fields.url}"${node.fields.newTab ? ' rel="noopener noreferrer" target="_blank"' : ''}>
${children}
</a>`
},
link: async ({ node, nodesToHTML, populate, providedStyleTag }) => {
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
let href: string = node.fields.url ?? ''
if (node.fields.linkType === 'internal') {
if (internalDocToHref) {
href = await internalDocToHref({ linkNode: node, populate })
} else {
console.error(
'Lexical => HTML converter: Link converter: found internal link, but internalDocToHref is not provided',
)
href = '#' // fallback
}
}
// hash fields to ensure they are diffed if they change
const nodeFieldsHash = createHash('sha256')
.update(JSON.stringify(node.fields ?? {}))
.digest('hex')
return `<a${providedStyleTag} data-fields-hash="${nodeFieldsHash}" data-enable-match="true" href="${href}"${node.fields.newTab ? ' rel="noopener noreferrer" target="_blank"' : ''}>
${children}
</a>`
},
})

View File

@@ -0,0 +1,48 @@
@import '../../../../scss/styles.scss';
@import '../../colors.scss';
@layer payload-default {
.lexical-diff {
ul.list-check {
padding-left: 0;
}
.checkboxItem {
list-style-type: none;
&__wrapper {
display: flex;
align-items: center;
}
&__icon {
width: 16px;
height: 16px;
margin-right: 8px; // Spacing before label text
border: 1px solid var(--theme-text);
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
// Because the checkbox is non-interactive:
pointer-events: none;
.icon--check {
height: 11px;
}
&[data-match-type='create'] {
border-color: var(--diff-create-pill-color);
}
&[data-match-type='delete'] {
border-color: var(--diff-delete-pill-color);
}
}
&--nested {
margin-left: 1.5rem;
}
}
}
}

View File

@@ -0,0 +1,71 @@
import { CheckIcon } from '@payloadcms/ui/rsc'
import type { HTMLConvertersAsync } from '../../../../features/converters/lexicalToHtml/async/types.js'
import type { SerializedListItemNode } from '../../../../nodeTypes.js'
import './index.scss'
export const ListItemDiffHTMLConverterAsync: HTMLConvertersAsync<SerializedListItemNode> = {
listitem: async ({ node, nodesToHTML, parent, providedCSSString }) => {
const hasSubLists = node.children.some((child) => child.type === 'list')
const children = (
await nodesToHTML({
nodes: node.children,
})
).join('')
if ('listType' in parent && parent?.listType === 'check') {
const ReactDOMServer = (await import('react-dom/server')).default
const JSX = (
<li
aria-checked={node.checked ? true : false}
className={`checkboxItem ${node.checked ? 'checkboxItem--checked' : 'checkboxItem--unchecked'}${
hasSubLists ? ' checkboxItem--nested' : ''
}`}
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role="checkbox"
tabIndex={-1}
value={node.value}
>
{hasSubLists ? (
// When sublists exist, just render them safely as HTML
<div dangerouslySetInnerHTML={{ __html: children }} />
) : (
// Otherwise, show our custom styled checkbox
<div className="checkboxItem__wrapper">
<div
className="checkboxItem__icon"
data-checked={node.checked}
data-enable-match="true"
>
{node.checked && <CheckIcon />}
</div>
<span className="checkboxItem__label">{children}</span>
</div>
)}
</li>
)
const html = ReactDOMServer.renderToString(JSX)
// Add style="list-style-type: none;${providedCSSString}" to html
const styleIndex = html.indexOf('class="list-item-checkbox')
const classIndex = html.indexOf('class="list-item-checkbox', styleIndex)
const classEndIndex = html.indexOf('"', classIndex + 6)
const className = html.substring(classIndex, classEndIndex)
const classNameWithStyle = `${className} style="list-style-type: none;${providedCSSString}"`
const htmlWithStyle = html.replace(className, classNameWithStyle)
return htmlWithStyle
} else {
return `<li
class="${hasSubLists ? 'nestedListItem' : ''}"
style="${hasSubLists ? `list-style-type: none;${providedCSSString}` : providedCSSString}"
value="${node.value}"
data-enable-match="true"
>${children}</li>`
}
},
}

View File

@@ -0,0 +1,79 @@
@import '../../../../scss/styles.scss';
@import '../../colors.scss';
@layer payload-default {
.lexical-diff__diff-container {
.lexical-relationship-diff {
@extend %body;
@include shadow-sm;
min-width: calc(var(--base) * 8);
max-width: fit-content;
display: flex;
align-items: center;
background-color: var(--theme-input-bg);
border-radius: $style-radius-s;
border: 1px solid var(--theme-elevation-100);
position: relative;
font-family: var(--font-body);
margin-block: base(0.5);
max-height: calc(var(--base) * 4);
padding: base(0.6);
&[data-match-type='create'] {
border-color: var(--diff-create-pill-border);
color: var(--diff-create-parent-color);
.lexical-relationship-diff__collectionLabel {
color: var(--diff-create-link-color);
}
[data-match-type='create'] {
color: var(--diff-create-parent-color);
}
}
&[data-match-type='delete'] {
border-color: var(--diff-delete-pill-border);
color: var(--diff-delete-parent-color);
text-decoration-line: none;
background-color: var(--diff-delete-pill-bg);
.lexical-relationship-diff__collectionLabel {
color: var(--diff-delete-link-color);
}
[data-match-type='delete'] {
text-decoration-line: none;
}
* {
color: var(--diff-delete-parent-color);
}
}
&__card {
display: flex;
flex-direction: column;
width: 100%;
flex-grow: 1;
display: flex;
align-items: flex-start;
flex-direction: column;
justify-content: space-between;
}
&__title {
display: flex;
flex-direction: row;
font-weight: 600;
}
&__collectionLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}

View File

@@ -0,0 +1,79 @@
import type { FileData, PayloadRequest, TypeWithID } from 'payload'
import { getTranslation, type I18nClient } from '@payloadcms/translations'
import './index.scss'
import type { HTMLConvertersAsync } from '../../../../features/converters/lexicalToHtml/async/types.js'
import type { SerializedRelationshipNode } from '../../../../nodeTypes.js'
const baseClass = 'lexical-relationship-diff'
export const RelationshipDiffHTMLConverterAsync: (args: {
i18n: I18nClient
req: PayloadRequest
}) => HTMLConvertersAsync<SerializedRelationshipNode> = ({ i18n, req }) => {
return {
relationship: async ({ node, populate, providedCSSString }) => {
let data: (Record<string, any> & TypeWithID) | undefined = undefined
// If there's no valid upload data, populate return an empty string
if (typeof node.value !== 'object') {
if (!populate) {
return ''
}
data = await populate<FileData & TypeWithID>({
id: node.value,
collectionSlug: node.relationTo,
})
} else {
data = node.value as unknown as FileData & TypeWithID
}
const relatedCollection = req.payload.collections[node.relationTo]?.config
const ReactDOMServer = (await import('react-dom/server')).default
const JSX = (
<div
className={`${baseClass}${providedCSSString}`}
data-enable-match="true"
data-id={node.value}
data-slug={node.relationTo}
>
<div className={`${baseClass}__card`}>
<div className={`${baseClass}__collectionLabel`}>
{i18n.t('fields:labelRelationship', {
label: relatedCollection?.labels?.singular
? getTranslation(relatedCollection?.labels?.singular, i18n)
: relatedCollection?.slug,
})}
</div>
{data &&
relatedCollection?.admin?.useAsTitle &&
data[relatedCollection.admin.useAsTitle] ? (
<strong className={`${baseClass}__title`} data-enable-match="false">
<a
className={`${baseClass}__link`}
data-enable-match="false"
href={`/${relatedCollection.slug}/${data.id}`}
rel="noopener noreferrer"
target="_blank"
>
{data[relatedCollection.admin.useAsTitle]}
</a>
</strong>
) : (
<strong>{node.value as string}</strong>
)}
</div>
</div>
)
// Render to HTML
const html = ReactDOMServer.renderToString(JSX)
return html
},
}
}

View File

@@ -0,0 +1,43 @@
@import '../../../../scss/styles.scss';
@import '../../colors.scss';
@layer payload-default {
.lexical-diff__diff-container {
.lexical-unknown-diff {
@extend %body;
@include shadow-sm;
max-width: fit-content;
display: flex;
align-items: center;
background: var(--theme-input-bg);
border-radius: $style-radius-s;
border: 1px solid var(--theme-elevation-100);
position: relative;
font-family: var(--font-body);
margin-block: base(0.5);
max-height: calc(var(--base) * 4);
padding: base(0.25);
&__specifier {
font-family: 'SF Mono', Menlo, Consolas, Monaco, monospace;
}
&[data-match-type='create'] {
border-color: var(--diff-create-pill-border);
color: var(--diff-create-parent-color);
}
&[data-match-type='delete'] {
border-color: var(--diff-delete-pill-border);
color: var(--diff-delete-parent-color);
text-decoration-line: none;
background-color: var(--diff-delete-pill-bg);
* {
text-decoration-line: none;
color: var(--diff-delete-parent-color);
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
import type { LexicalNode } from 'lexical'
import type { PayloadRequest } from 'payload'
import { type I18nClient } from '@payloadcms/translations'
import './index.scss'
import { createHash } from 'crypto'
import type { HTMLConvertersAsync } from '../../../../features/converters/lexicalToHtml/async/types.js'
import type { SerializedBlockNode } from '../../../../nodeTypes.js'
const baseClass = 'lexical-unknown-diff'
export const UnknownDiffHTMLConverterAsync: (args: {
i18n: I18nClient
req: PayloadRequest
}) => HTMLConvertersAsync<LexicalNode> = ({ i18n, req }) => {
return {
unknown: async ({ node, providedCSSString }) => {
const ReactDOMServer = (await import('react-dom/server')).default
// hash fields to ensure they are diffed if they change
const nodeFieldsHash = createHash('sha256')
.update(JSON.stringify(node ?? {}))
.digest('hex')
let nodeType = node.type
let nodeTypeSpecifier: null | string = null
if (node.type === 'block') {
nodeTypeSpecifier = (node as SerializedBlockNode).fields.blockType
nodeType = 'Block'
} else if (node.type === 'inlineBlock') {
nodeTypeSpecifier = (node as SerializedBlockNode).fields.blockType
nodeType = 'InlineBlock'
}
const JSX = (
<div
className={`${baseClass}${providedCSSString}`}
data-enable-match="true"
data-fields-hash={`${nodeFieldsHash}`}
>
{nodeTypeSpecifier && (
<span className={`${baseClass}__specifier`}>{nodeTypeSpecifier}&nbsp;</span>
)}
<span>{nodeType}</span>
<div className={`${baseClass}__meta`}>
<br />
</div>
</div>
)
// Render to HTML
const html = ReactDOMServer.renderToString(JSX)
return html
},
}
}

View File

@@ -0,0 +1,116 @@
@import '../../../../scss/styles.scss';
@import '../../colors.scss';
@layer payload-default {
.lexical-diff__diff-container {
.lexical-upload-diff {
@extend %body;
@include shadow-sm;
min-width: calc(var(--base) * 10);
max-width: fit-content;
display: flex;
align-items: center;
background-color: var(--theme-input-bg);
border-radius: $style-radius-s;
border: 1px solid var(--theme-elevation-100);
position: relative;
font-family: var(--font-body);
margin-block: base(0.5);
max-height: calc(var(--base) * 3);
padding: base(0.6);
&[data-match-type='create'] {
border-color: var(--diff-create-pill-border);
color: var(--diff-create-parent-color);
* {
color: var(--diff-create-parent-color);
}
.lexical-upload-diff__meta {
color: var(--diff-create-link-color);
* {
color: var(--diff-create-link-color);
}
}
.lexical-upload-diff__thumbnail {
border-radius: 0px;
border-color: var(--diff-create-pill-border);
background-color: none;
}
}
&[data-match-type='delete'] {
border-color: var(--diff-delete-pill-border);
text-decoration-line: none;
color: var(--diff-delete-parent-color);
background-color: var(--diff-delete-pill-bg);
.lexical-upload-diff__meta {
color: var(--diff-delete-link-color);
* {
color: var(--diff-delete-link-color);
}
}
* {
text-decoration-line: none;
color: var(--diff-delete-parent-color);
}
.lexical-upload-diff__thumbnail {
border-radius: 0px;
border-color: var(--diff-delete-pill-border);
background-color: none;
}
}
&__card {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
}
&__thumbnail {
width: calc(var(--base) * 3 - base(0.6) * 2);
height: calc(var(--base) * 3 - base(0.6) * 2);
position: relative;
overflow: hidden;
flex-shrink: 0;
border-radius: 0px;
border: 1px solid var(--theme-elevation-100);
img,
svg {
position: absolute;
object-fit: cover;
width: 100%;
height: 100%;
border-radius: 0px;
}
}
&__info {
flex-grow: 1;
display: flex;
align-items: flex-start;
flex-direction: column;
padding: calc(var(--base) * 0.25) calc(var(--base) * 0.75);
justify-content: space-between;
font-weight: 400;
strong {
font-weight: 600;
}
}
&__meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}

View File

@@ -0,0 +1,103 @@
import type { FileData, PayloadRequest, TypeWithID } from 'payload'
import { type I18nClient } from '@payloadcms/translations'
import { File } from '@payloadcms/ui/rsc'
import { createHash } from 'crypto'
import './index.scss'
import { formatFilesize } from 'payload/shared'
import React from 'react'
import type { HTMLConvertersAsync } from '../../../../features/converters/lexicalToHtml/async/types.js'
import type { UploadDataImproved } from '../../../../features/upload/server/nodes/UploadNode.js'
import type { SerializedUploadNode } from '../../../../nodeTypes.js'
const baseClass = 'lexical-upload-diff'
export const UploadDiffHTMLConverterAsync: (args: {
i18n: I18nClient
req: PayloadRequest
}) => HTMLConvertersAsync<SerializedUploadNode> = ({ i18n, req }) => {
return {
upload: async ({ node, populate, providedCSSString }) => {
const uploadNode = node as UploadDataImproved
let uploadDoc: (FileData & TypeWithID) | undefined = undefined
// If there's no valid upload data, populate return an empty string
if (typeof uploadNode.value !== 'object') {
if (!populate) {
return ''
}
uploadDoc = await populate<FileData & TypeWithID>({
id: uploadNode.value,
collectionSlug: uploadNode.relationTo,
})
} else {
uploadDoc = uploadNode.value as unknown as FileData & TypeWithID
}
if (!uploadDoc) {
return ''
}
const relatedCollection = req.payload.collections[uploadNode.relationTo]?.config
const thumbnailSRC: string =
('thumbnailURL' in uploadDoc && (uploadDoc?.thumbnailURL as string)) || uploadDoc?.url || ''
const ReactDOMServer = (await import('react-dom/server')).default
// hash fields to ensure they are diffed if they change
const nodeFieldsHash = createHash('sha256')
.update(JSON.stringify(node.fields ?? {}))
.digest('hex')
const JSX = (
<div
className={`${baseClass}${providedCSSString}`}
data-enable-match="true"
data-fields-hash={`${nodeFieldsHash}`}
data-filename={uploadDoc?.filename}
data-lexical-upload-id={uploadNode.value}
data-lexical-upload-relation-to={uploadNode.relationTo}
data-src={thumbnailSRC}
>
<div className={`${baseClass}__card`}>
<div className={`${baseClass}__thumbnail`}>
{thumbnailSRC?.length ? (
<img alt={uploadDoc?.filename} src={thumbnailSRC} />
) : (
<File />
)}
</div>
<div className={`${baseClass}__info`}>
<strong>{uploadDoc?.filename}</strong>
<div className={`${baseClass}__meta`}>
{formatFilesize(uploadDoc?.filesize)}
{typeof uploadDoc?.width === 'number' && typeof uploadDoc?.height === 'number' && (
<React.Fragment>
&nbsp;-&nbsp;
{uploadDoc?.width}x{uploadDoc?.height}
</React.Fragment>
)}
{uploadDoc?.mimeType && (
<React.Fragment>
&nbsp;-&nbsp;
{uploadDoc?.mimeType}
</React.Fragment>
)}
</div>
</div>
</div>
</div>
)
// Render to HTML
const html = ReactDOMServer.renderToString(JSX)
return html
},
}
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Arman Tang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,90 @@
@import '../../../scss/styles.scss';
@import '../colors.scss';
@layer payload-default {
.lexical-diff__diff-container {
font-family: var(--font-serif);
font-size: base(0.8);
letter-spacing: 0.02em;
// Apply background color to parents that have children with diffs
p,
li,
h1,
h2,
h3,
h4,
h5,
blockquote,
h6 {
&:has([data-match-type='create']) {
background-color: var(--diff-create-parent-bg);
color: var(--diff-create-parent-color);
}
&:has([data-match-type='delete']) {
background-color: var(--diff-delete-parent-bg);
color: var(--diff-delete-parent-color);
}
}
li::marker {
color: var(--theme-text);
}
[data-match-type='delete'] {
color: var(--diff-delete-pill-color);
text-decoration-color: var(--diff-delete-pill-color);
text-decoration-line: line-through;
background-color: var(--diff-delete-pill-bg);
border-radius: 4px;
text-decoration-thickness: 1px;
}
a[data-match-type='delete'] {
color: var(--diff-delete-link-color);
}
a[data-match-type='create']:not(img) {
// :not(img) required to increase specificity
color: var(--diff-create-link-color);
}
[data-match-type='create']:not(img) {
background-color: var(--diff-create-pill-bg);
color: var(--diff-create-pill-color);
border-radius: 4px;
}
.html-diff {
&-create-inline-wrapper,
&-delete-inline-wrapper {
display: inline-flex;
}
&-create-block-wrapper,
&-delete-block-wrapper {
display: flex;
}
&-create-inline-wrapper,
&-delete-inline-wrapper,
&-create-block-wrapper,
&-delete-block-wrapper {
position: relative;
align-items: center;
flex-direction: row;
&::after {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
content: '';
}
}
}
}
}

View File

@@ -0,0 +1,659 @@
// Taken and modified from https://github.com/Arman19941113/html-diff/blob/master/packages/html-diff/src/index.ts
interface MatchedBlock {
newEnd: number
newStart: number
oldEnd: number
oldStart: number
size: number
}
interface Operation {
/**
* Index of entry in tokenized token list
*/
newEnd: number
newStart: number
oldEnd: number
oldStart: number
type: 'create' | 'delete' | 'equal' | 'replace'
}
type BaseOpType = 'create' | 'delete'
interface HtmlDiffConfig {
classNames: {
createBlock: string
createInline: string
deleteBlock: string
deleteInline: string
}
greedyBoundary: number
greedyMatch: boolean
minMatchedSize: number
}
export interface HtmlDiffOptions {
/**
* The classNames for wrapper DOM.
* Use this to configure your own styles without importing the built-in CSS file
*/
classNames?: Partial<{
createBlock?: string
createInline?: string
deleteBlock?: string
deleteInline?: string
}>
/**
* @defaultValue 1000
*/
greedyBoundary?: number
/**
* When greedyMatch is enabled, if the length of the sub-tokens exceeds greedyBoundary,
* we will use the matched sub-tokens that are sufficiently good, even if they are not optimal, to enhance performance.
* @defaultValue true
*/
greedyMatch?: boolean
/**
* Determine the minimum threshold for calculating common sub-tokens.
* You may adjust it to a value larger than 2, but not lower, due to the potential inclusion of HTML tags in the count.
* @defaultValue 2
*/
minMatchedSize?: number
}
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/optimal-quantifier-concatenation
const htmlStartTagReg = /^<(?<name>[^\s/>]+)[^>]*>$/
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/optimal-quantifier-concatenation
const htmlTagWithNameReg = /^<(?<isEnd>\/)?(?<name>[^\s>]+)[^>]*>$/
const htmlTagReg = /^<[^>]+>/
const htmlImgTagReg = /^<img[^>]*>$/
const htmlVideoTagReg = /^<video[^>]*>.*?<\/video>$/ms
export class HtmlDiff {
private readonly config: HtmlDiffConfig
private leastCommonLength: number = Infinity
private readonly matchedBlockList: MatchedBlock[] = []
private readonly newTokens: string[] = []
private readonly oldTokens: string[] = []
private readonly operationList: Operation[] = []
private sideBySideContents?: [string, string]
private unifiedContent?: string
constructor(
oldHtml: string,
newHtml: string,
{
classNames = {
createBlock: 'html-diff-create-block-wrapper',
createInline: 'html-diff-create-inline-wrapper',
deleteBlock: 'html-diff-delete-block-wrapper',
deleteInline: 'html-diff-delete-inline-wrapper',
},
greedyBoundary = 1000,
greedyMatch = true,
minMatchedSize = 2,
}: HtmlDiffOptions = {},
) {
// init config
this.config = {
classNames: {
createBlock: 'html-diff-create-block-wrapper',
createInline: 'html-diff-create-inline-wrapper',
deleteBlock: 'html-diff-delete-block-wrapper',
deleteInline: 'html-diff-delete-inline-wrapper',
...classNames,
},
greedyBoundary,
greedyMatch,
minMatchedSize,
}
// white space is junk
oldHtml = oldHtml.trim()
newHtml = newHtml.trim()
// no need to diff
if (oldHtml === newHtml) {
this.unifiedContent = oldHtml
let equalSequence = 0
// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/optimal-quantifier-concatenation
const content = oldHtml.replace(/<([^\s/>]+)[^>]*>/g, (match: string, name: string) => {
const tagNameLength = name.length + 1
return `${match.slice(0, tagNameLength)} data-seq="${++equalSequence}"${match.slice(tagNameLength)}`
})
this.sideBySideContents = [content, content]
return
}
// step1: split HTML to tokens(atomic tokens)
this.oldTokens = this.tokenize(oldHtml)
this.newTokens = this.tokenize(newHtml)
// step2: find matched blocks
this.matchedBlockList = this.getMatchedBlockList()
// step3: generate operation list
this.operationList = this.getOperationList()
}
// Find the longest matched block between tokens
private computeBestMatchedBlock(
oldStart: number,
oldEnd: number,
newStart: number,
newEnd: number,
): MatchedBlock | null {
let bestMatchedBlock = null
for (let i = oldStart; i < oldEnd; i++) {
const len = Math.min(oldEnd - i, newEnd - newStart)
const ret = this.slideBestMatchedBlock(i, newStart, len)
if (ret && (!bestMatchedBlock || ret.size > bestMatchedBlock.size)) {
bestMatchedBlock = ret
if (ret.size > this.leastCommonLength) {
return bestMatchedBlock
}
}
}
for (let j = newStart; j < newEnd; j++) {
const len = Math.min(oldEnd - oldStart, newEnd - j)
const ret = this.slideBestMatchedBlock(oldStart, j, len)
if (ret && (!bestMatchedBlock || ret.size > bestMatchedBlock.size)) {
bestMatchedBlock = ret
if (ret.size > this.leastCommonLength) {
return bestMatchedBlock
}
}
}
return bestMatchedBlock
}
private computeMatchedBlockList(
oldStart: number,
oldEnd: number,
newStart: number,
newEnd: number,
matchedBlockList: MatchedBlock[] = [],
): MatchedBlock[] {
const matchBlock = this.computeBestMatchedBlock(oldStart, oldEnd, newStart, newEnd)
if (!matchBlock) {
return []
}
if (oldStart < matchBlock.oldStart && newStart < matchBlock.newStart) {
this.computeMatchedBlockList(
oldStart,
matchBlock.oldStart,
newStart,
matchBlock.newStart,
matchedBlockList,
)
}
matchedBlockList.push(matchBlock)
if (oldEnd > matchBlock.oldEnd && newEnd > matchBlock.newEnd) {
this.computeMatchedBlockList(
matchBlock.oldEnd,
oldEnd,
matchBlock.newEnd,
newEnd,
matchedBlockList,
)
}
return matchedBlockList
}
private dressUpBlockTag(type: BaseOpType, token: string): string {
if (type === 'create') {
return `<div class="${this.config.classNames.createBlock}">${token}</div>`
}
if (type === 'delete') {
return `<div class="${this.config.classNames.deleteBlock}">${token}</div>`
}
return ''
}
private dressUpDiffContent(type: BaseOpType, tokens: string[]): string {
const tokensLength = tokens.length
if (!tokensLength) {
return ''
}
let result = ''
let textStartIndex = 0
let i = -1
for (const token of tokens) {
i++
// If this is true, this HTML should be diffed as well - not just its children
const isMatchElement = token.includes('data-enable-match="true"')
const isMatchExplicitlyDisabled = token.includes('data-enable-match="false"')
const isHtmlTag = !!token.match(htmlTagReg)?.length
if (isMatchExplicitlyDisabled) {
textStartIndex = i + 1
result += token
}
// this token is html tag
else if (!isMatchElement && isHtmlTag) {
// handle text tokens before
if (i > textStartIndex) {
result += this.dressUpText(type, tokens.slice(textStartIndex, i))
}
// handle this tag
textStartIndex = i + 1
if (token.match(htmlVideoTagReg)) {
result += this.dressUpBlockTag(type, token)
} /* else if ([htmlImgTagReg].some((item) => token.match(item))) {
result += this.dressUpInlineTag(type, token)
}*/ else {
result += token
}
} else if (isMatchElement && isHtmlTag) {
// handle text tokens before
if (i > textStartIndex) {
result += this.dressUpText(type, tokens.slice(textStartIndex, i))
}
// handle this tag
textStartIndex = i + 1
// Add data-match-type to the tag that can be styled
const newToken = this.dressupMatchEnabledHtmlTag(type, token)
result += newToken
}
}
if (textStartIndex < tokensLength) {
result += this.dressUpText(type, tokens.slice(textStartIndex))
}
return result
}
private dressUpInlineTag(type: BaseOpType, token: string): string {
if (type === 'create') {
return `<span class="${this.config.classNames.createInline}">${token}</span>`
}
if (type === 'delete') {
return `<span class="${this.config.classNames.deleteInline}">${token}</span>`
}
return ''
}
private dressupMatchEnabledHtmlTag(type: BaseOpType, token: string): string {
// token is a single html tag, e.g. <a data-enable-match="true" href="https://2" rel=undefined target=undefined>
// add data-match-type to the tag
const tagName = token.match(htmlStartTagReg)?.groups?.name
if (!tagName) {
return token
}
const tagNameLength = tagName.length + 1
const matchType = type === 'create' ? 'create' : 'delete'
return `${token.slice(0, tagNameLength)} data-match-type="${matchType}"${token.slice(
tagNameLength,
token.length,
)}`
}
private dressUpText(type: BaseOpType, tokens: string[]): string {
const text = tokens.join('')
if (!text.trim()) {
return ''
}
if (type === 'create') {
return `<span data-match-type="create">${text}</span>`
}
if (type === 'delete') {
return `<span data-match-type="delete">${text}</span>`
}
return ''
}
/**
* Generates a list of token entries that are matched between the old and new HTML. This list will not
* include token ranges that differ.
*/
private getMatchedBlockList(): MatchedBlock[] {
const n1 = this.oldTokens.length
const n2 = this.newTokens.length
// 1. sync from start
let start: MatchedBlock | null = null
let i = 0
while (i < n1 && i < n2 && this.oldTokens[i] === this.newTokens[i]) {
i++
}
if (i >= this.config.minMatchedSize) {
start = {
newEnd: i,
newStart: 0,
oldEnd: i,
oldStart: 0,
size: i,
}
}
// 2. sync from end
let end: MatchedBlock | null = null
let e1 = n1 - 1
let e2 = n2 - 1
while (i <= e1 && i <= e2 && this.oldTokens[e1] === this.newTokens[e2]) {
e1--
e2--
}
const size = n1 - 1 - e1
if (size >= this.config.minMatchedSize) {
end = {
newEnd: n2,
newStart: e2 + 1,
oldEnd: n1,
oldStart: e1 + 1,
size,
}
}
// 3. handle rest
const oldStart = start ? i : 0
const oldEnd = end ? e1 + 1 : n1
const newStart = start ? i : 0
const newEnd = end ? e2 + 1 : n2
// optimize for large tokens
if (this.config.greedyMatch) {
const commonLength = Math.min(oldEnd - oldStart, newEnd - newStart)
if (commonLength > this.config.greedyBoundary) {
this.leastCommonLength = Math.floor(commonLength / 3)
}
}
const ret = this.computeMatchedBlockList(oldStart, oldEnd, newStart, newEnd)
if (start) {
ret.unshift(start)
}
if (end) {
ret.push(end)
}
return ret
}
// Generate operation list by matchedBlockList
private getOperationList(): Operation[] {
const operationList: Operation[] = []
let walkIndexOld = 0
let walkIndexNew = 0
for (const matchedBlock of this.matchedBlockList) {
const isOldStartIndexMatched = walkIndexOld === matchedBlock.oldStart
const isNewStartIndexMatched = walkIndexNew === matchedBlock.newStart
const operationBase = {
newEnd: matchedBlock.newStart,
newStart: walkIndexNew,
oldEnd: matchedBlock.oldStart,
oldStart: walkIndexOld,
}
if (!isOldStartIndexMatched && !isNewStartIndexMatched) {
operationList.push(Object.assign(operationBase, { type: 'replace' as const }))
} else if (isOldStartIndexMatched && !isNewStartIndexMatched) {
operationList.push(Object.assign(operationBase, { type: 'create' as const }))
} else if (!isOldStartIndexMatched && isNewStartIndexMatched) {
operationList.push(Object.assign(operationBase, { type: 'delete' as const }))
}
operationList.push({
type: 'equal',
newEnd: matchedBlock.newEnd,
newStart: matchedBlock.newStart,
oldEnd: matchedBlock.oldEnd,
oldStart: matchedBlock.oldStart,
})
walkIndexOld = matchedBlock.oldEnd
walkIndexNew = matchedBlock.newEnd
}
// handle the tail content
const maxIndexOld = this.oldTokens.length
const maxIndexNew = this.newTokens.length
const tailOperationBase = {
newEnd: maxIndexNew,
newStart: walkIndexNew,
oldEnd: maxIndexOld,
oldStart: walkIndexOld,
}
const isOldFinished = walkIndexOld === maxIndexOld
const isNewFinished = walkIndexNew === maxIndexNew
if (!isOldFinished && !isNewFinished) {
operationList.push(Object.assign(tailOperationBase, { type: 'replace' as const }))
} else if (isOldFinished && !isNewFinished) {
operationList.push(Object.assign(tailOperationBase, { type: 'create' as const }))
} else if (!isOldFinished && isNewFinished) {
operationList.push(Object.assign(tailOperationBase, { type: 'delete' as const }))
}
return operationList
}
private slideBestMatchedBlock(addA: number, addB: number, len: number): MatchedBlock | null {
let maxSize = 0
let bestMatchedBlock: MatchedBlock | null = null
let continuousSize = 0
for (let i = 0; i < len; i++) {
if (this.oldTokens[addA + i] === this.newTokens[addB + i]) {
continuousSize++
} else {
continuousSize = 0
}
if (continuousSize > maxSize) {
maxSize = continuousSize
bestMatchedBlock = {
newEnd: addB + i + 1,
newStart: addB + i - continuousSize + 1,
oldEnd: addA + i + 1,
oldStart: addA + i - continuousSize + 1,
size: continuousSize,
}
}
}
return maxSize >= this.config.minMatchedSize ? bestMatchedBlock : null
}
/**
* convert HTML to tokens
* @example
* tokenize("<a> Hello World </a>")
* ["<a>"," ", "Hello", " ", "World", " ", "</a>"]
*/
private tokenize(html: string): string[] {
// atomic token: html tag、continuous numbers or letters、blank spaces、other symbol
return (
html.match(
/<picture[^>]*>.*?<\/picture>|<video[^>]*>.*?<\/video>|<[^>]+>|\w+\b|\s+|[^<>\w]/gs,
) || []
)
}
public getSideBySideContents(): string[] {
if (this.sideBySideContents !== undefined) {
return this.sideBySideContents
}
let oldHtml = ''
let newHtml = ''
let equalSequence = 0
this.operationList.forEach((operation) => {
switch (operation.type) {
case 'create': {
newHtml += this.dressUpDiffContent(
'create',
this.newTokens.slice(operation.newStart, operation.newEnd),
)
break
}
case 'delete': {
const deletedTokens = this.oldTokens.slice(operation.oldStart, operation.oldEnd)
oldHtml += this.dressUpDiffContent('delete', deletedTokens)
break
}
case 'equal': {
const equalTokens = this.newTokens.slice(operation.newStart, operation.newEnd)
let equalString = ''
for (const token of equalTokens) {
// find start tags and add data-seq to enable sync scroll
const startTagMatch = token.match(htmlStartTagReg)
if (startTagMatch) {
equalSequence += 1
const tagNameLength = (startTagMatch?.groups?.name?.length ?? 0) + 1
equalString += `${token.slice(0, tagNameLength)} data-seq="${equalSequence}"${token.slice(tagNameLength)}`
} else {
equalString += token
}
}
oldHtml += equalString
newHtml += equalString
break
}
case 'replace': {
oldHtml += this.dressUpDiffContent(
'delete',
this.oldTokens.slice(operation.oldStart, operation.oldEnd),
)
newHtml += this.dressUpDiffContent(
'create',
this.newTokens.slice(operation.newStart, operation.newEnd),
)
break
}
default: {
console.error('Richtext diff error - invalid operation: ' + String(operation.type))
}
}
})
const result: [string, string] = [oldHtml, newHtml]
this.sideBySideContents = result
return result
}
public getUnifiedContent(): string {
if (this.unifiedContent !== undefined) {
return this.unifiedContent
}
let result = ''
this.operationList.forEach((operation) => {
switch (operation.type) {
case 'create': {
result += this.dressUpDiffContent(
'create',
this.newTokens.slice(operation.newStart, operation.newEnd),
)
break
}
case 'delete': {
result += this.dressUpDiffContent(
'delete',
this.oldTokens.slice(operation.oldStart, operation.oldEnd),
)
break
}
case 'equal': {
for (const token of this.newTokens.slice(operation.newStart, operation.newEnd)) {
result += token
}
break
}
case 'replace': {
// handle specially tag replace
const olds = this.oldTokens.slice(operation.oldStart, operation.oldEnd)
const news = this.newTokens.slice(operation.newStart, operation.newEnd)
if (
olds.length === 1 &&
news.length === 1 &&
olds[0]?.match(htmlTagReg) &&
news[0]?.match(htmlTagReg)
) {
result += news[0]
break
}
const deletedTokens: string[] = []
const createdTokens: string[] = []
let createIndex = operation.newStart
for (
let deleteIndex = operation.oldStart;
deleteIndex < operation.oldEnd;
deleteIndex++
) {
const deletedToken = this.oldTokens[deleteIndex]
if (!deletedToken) {
continue
}
const matchTagResultD = deletedToken?.match(htmlTagWithNameReg)
if (matchTagResultD) {
// handle replaced tag token
// skip special tag
if ([htmlImgTagReg, htmlVideoTagReg].some((item) => deletedToken?.match(item))) {
deletedTokens.push(deletedToken)
continue
}
// handle normal tag
result += this.dressUpDiffContent('delete', deletedTokens)
deletedTokens.splice(0)
let isTagInNewFind = false
for (
let tempCreateIndex = createIndex;
tempCreateIndex < operation.newEnd;
tempCreateIndex++
) {
const createdToken = this.newTokens[tempCreateIndex]
if (!createdToken) {
continue
}
const matchTagResultC = createdToken?.match(htmlTagWithNameReg)
if (
matchTagResultC &&
matchTagResultC.groups?.name === matchTagResultD.groups?.name &&
matchTagResultC.groups?.isEnd === matchTagResultD.groups?.isEnd
) {
// find first matched tag, but not maybe the expected tag(to optimize)
isTagInNewFind = true
result += this.dressUpDiffContent('create', createdTokens)
result += createdToken
createdTokens.splice(0)
createIndex = tempCreateIndex + 1
break
} else {
createdTokens.push(createdToken)
}
}
if (!isTagInNewFind) {
result += deletedToken
createdTokens.splice(0)
}
} else {
// token is not a tag
deletedTokens.push(deletedToken)
}
}
if (createIndex < operation.newEnd) {
createdTokens.push(...this.newTokens.slice(createIndex, operation.newEnd))
}
result += this.dressUpDiffContent('delete', deletedTokens)
result += this.dressUpDiffContent('create', createdTokens)
break
}
default: {
console.error('Richtext diff error - invalid operation: ' + String(operation.type))
}
}
})
this.unifiedContent = result
return result
}
}

View File

@@ -0,0 +1,95 @@
@import '../../scss/styles.scss';
@import './colors.scss';
@layer payload-default {
.lexical-diff {
&__diff-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
blockquote {
font-size: base(0.8);
margin-block: base(0.8);
margin-inline: base(0.2);
border-inline-start-color: var(--theme-elevation-150);
border-inline-start-width: base(0.2);
border-inline-start-style: solid;
padding-inline-start: base(0.6);
padding-block: base(0.2);
&:has([data-match-type='create']) {
border-inline-start-color: var(--theme-success-150);
}
&:has([data-match-type='delete']) {
border-inline-start-color: var(--theme-error-150);
}
}
a {
border-bottom: 1px dotted;
text-decoration: none;
}
h1 {
padding: base(0.7) 0px base(0.55);
line-height: base(1.2);
font-weight: 600;
font-size: base(1.4);
font-family: var(--font-body);
}
h2 {
padding: base(0.7) 0px base(0.5);
line-height: base(1);
font-weight: 600;
font-size: base(1.25);
font-family: var(--font-body);
}
h3 {
padding: base(0.65) 0px base(0.45);
line-height: base(0.9);
font-weight: 600;
font-size: base(1.1);
font-family: var(--font-body);
}
h4 {
padding: base(0.65) 0px base(0.4);
line-height: base(0.7);
font-weight: 600;
font-size: base(1);
font-family: var(--font-body);
}
h5 {
padding: base(0.65) 0px base(0.35);
line-height: base(0.5);
font-weight: 600;
font-size: base(0.9);
font-family: var(--font-body);
}
h6 {
padding: base(0.65) 0px base(0.35);
line-height: base(0.5);
font-weight: 600;
font-size: base(0.8);
font-family: var(--font-body);
}
p {
padding: base(0.4) 0 base(0.4);
// First paraagraph has no top padding
&:first-child {
padding: 0 0 base(0.4);
}
}
ul,
ol {
padding-top: base(0.4);
padding-bottom: base(0.4);
}
}
}

View File

@@ -0,0 +1,74 @@
import type { SerializedEditorState } from 'lexical'
import type { RichTextFieldDiffServerComponent } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { FieldDiffLabel } from '@payloadcms/ui/rsc'
import React from 'react'
import './htmlDiff/index.scss'
import './index.scss'
import type { HTMLConvertersFunctionAsync } from '../../features/converters/lexicalToHtml/async/types.js'
import { convertLexicalToHTMLAsync } from '../../features/converters/lexicalToHtml/async/index.js'
import { getPayloadPopulateFn } from '../../features/converters/utilities/payloadPopulateFn.js'
import { LinkDiffHTMLConverterAsync } from './converters/link.js'
import { ListItemDiffHTMLConverterAsync } from './converters/listitem/index.js'
import { RelationshipDiffHTMLConverterAsync } from './converters/relationship/index.js'
import { UnknownDiffHTMLConverterAsync } from './converters/unknown/index.js'
import { UploadDiffHTMLConverterAsync } from './converters/upload/index.js'
import { HtmlDiff } from './htmlDiff/index.js'
const baseClass = 'lexical-diff'
export const LexicalDiffComponent: RichTextFieldDiffServerComponent = async (args) => {
const { comparisonValue, field, i18n, locale, versionValue } = args
const converters: HTMLConvertersFunctionAsync = ({ defaultConverters }) => ({
...defaultConverters,
...LinkDiffHTMLConverterAsync({}),
...ListItemDiffHTMLConverterAsync,
...UploadDiffHTMLConverterAsync({ i18n: args.i18n, req: args.req }),
...RelationshipDiffHTMLConverterAsync({ i18n: args.i18n, req: args.req }),
...UnknownDiffHTMLConverterAsync({ i18n: args.i18n, req: args.req }),
})
const payloadPopulateFn = await getPayloadPopulateFn({
currentDepth: 0,
depth: 1,
req: args.req,
})
const comparisonHTML = await convertLexicalToHTMLAsync({
converters,
data: comparisonValue as SerializedEditorState,
populate: payloadPopulateFn,
})
const versionHTML = await convertLexicalToHTMLAsync({
converters,
data: versionValue as SerializedEditorState,
populate: payloadPopulateFn,
})
const diffHTML = new HtmlDiff(comparisonHTML, versionHTML)
const [oldHTML, newHTML] = diffHTML.getSideBySideContents()
return (
<div className={baseClass}>
<FieldDiffLabel>
{locale && <span className={`${baseClass}__locale-label`}>{locale}</span>}
{'label' in field &&
typeof field.label !== 'function' &&
getTranslation(field.label || '', i18n)}
</FieldDiffLabel>
<div className={`${baseClass}__diff-container`}>
{oldHTML && (
<div className={`${baseClass}__diff-old`} dangerouslySetInnerHTML={{ __html: oldHTML }} />
)}
{newHTML && (
<div className={`${baseClass}__diff-new`} dangerouslySetInnerHTML={{ __html: newHTML }} />
)}
</div>
</div>
)
}

View File

@@ -101,6 +101,13 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter
sanitizedEditorConfig: finalSanitizedEditorConfig, sanitizedEditorConfig: finalSanitizedEditorConfig,
}, },
}, },
DiffComponent: {
path: '@payloadcms/richtext-lexical/rsc#LexicalDiffComponent',
serverProps: {
admin: args?.admin,
sanitizedEditorConfig: finalSanitizedEditorConfig,
},
},
editorConfig: finalSanitizedEditorConfig, editorConfig: finalSanitizedEditorConfig,
features, features,
FieldComponent: { FieldComponent: {

View File

@@ -10,6 +10,7 @@ export const getGenerateImportMap =
({ addToImportMap, baseDir, config, importMap, imports }) => { ({ addToImportMap, baseDir, config, importMap, imports }) => {
addToImportMap('@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell') addToImportMap('@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell')
addToImportMap('@payloadcms/richtext-lexical/rsc#RscEntryLexicalField') addToImportMap('@payloadcms/richtext-lexical/rsc#RscEntryLexicalField')
addToImportMap('@payloadcms/richtext-lexical/rsc#LexicalDiffComponent')
// iterate just through args.resolvedFeatureMap.values() // iterate just through args.resolvedFeatureMap.values()
for (const resolvedFeature of args.resolvedFeatureMap.values()) { for (const resolvedFeature of args.resolvedFeatureMap.values()) {

View File

@@ -4,8 +4,6 @@ import './index.scss'
const baseClass = 'field-diff-label' const baseClass = 'field-diff-label'
const Label: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( export const FieldDiffLabel: React.FC<{ children?: React.ReactNode }> = ({ children }) => (
<div className={baseClass}>{children}</div> <div className={baseClass}>{children}</div>
) )
export default Label

View File

@@ -367,3 +367,4 @@ export { SetDocumentStepNav } from '../../views/Edit/SetDocumentStepNav/index.js
export { SetDocumentTitle } from '../../views/Edit/SetDocumentTitle/index.js' export { SetDocumentTitle } from '../../views/Edit/SetDocumentTitle/index.js'
export { parseSearchParams } from '../../utilities/parseSearchParams.js' export { parseSearchParams } from '../../utilities/parseSearchParams.js'
export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js'

View File

@@ -1,3 +1,6 @@
export { FieldDiffLabel } from '../../elements/FieldDiffLabel/index.js'
export { File } from '../../graphics/File/index.js'
export { CheckIcon } from '../../icons/Check/index.js'
export { copyDataFromLocaleHandler } from '../../utilities/copyDataFromLocale.js' export { copyDataFromLocaleHandler } from '../../utilities/copyDataFromLocale.js'
export { renderFilters, renderTable } from '../../utilities/renderTable.js' export { renderFilters, renderTable } from '../../utilities/renderTable.js'
export { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js' export { resolveFilterOptions } from '../../utilities/resolveFilterOptions.js'

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 type { CollectionConfig } from 'payload'
import { diffCollectionSlug, draftCollectionSlug } from '../slugs.js' import { diffCollectionSlug, draftCollectionSlug } from '../../slugs.js'
export const Diff: CollectionConfig = { export const Diff: CollectionConfig = {
slug: diffCollectionSlug, 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 AutosavePosts from './collections/Autosave.js'
import AutosaveWithValidate from './collections/AutosaveWithValidate.js' import AutosaveWithValidate from './collections/AutosaveWithValidate.js'
import CustomIDs from './collections/CustomIDs.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 DisablePublish from './collections/DisablePublish.js'
import DraftPosts from './collections/Drafts.js' import DraftPosts from './collections/Drafts.js'
import DraftWithMax from './collections/DraftsWithMax.js' import DraftWithMax from './collections/DraftsWithMax.js'
@@ -14,6 +14,7 @@ import DraftsWithValidate from './collections/DraftsWithValidate.js'
import LocalizedPosts from './collections/Localized.js' import LocalizedPosts from './collections/Localized.js'
import { Media } from './collections/Media.js' import { Media } from './collections/Media.js'
import Posts from './collections/Posts.js' import Posts from './collections/Posts.js'
import { TextCollection } from './collections/Text.js'
import VersionPosts from './collections/Versions.js' import VersionPosts from './collections/Versions.js'
import AutosaveGlobal from './globals/Autosave.js' import AutosaveGlobal from './globals/Autosave.js'
import DisablePublishGlobal from './globals/DisablePublish.js' import DisablePublishGlobal from './globals/DisablePublish.js'
@@ -42,6 +43,7 @@ export default buildConfigWithDefaults({
VersionPosts, VersionPosts,
CustomIDs, CustomIDs,
Diff, Diff,
TextCollection,
Media, Media,
], ],
globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal, LocalizedGlobal], globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal, LocalizedGlobal],

View File

@@ -1384,12 +1384,17 @@ describe('Versions', () => {
const richtext = page.locator('[data-field-path="richtext"]') const richtext = page.locator('[data-field-path="richtext"]')
await expect(richtext.locator('tr').nth(16).locator('td').nth(1)).toHaveText( const oldDiff = richtext.locator('.lexical-diff__diff-old')
'"text": "richtext",', const newDiff = richtext.locator('.lexical-diff__diff-new')
)
await expect(richtext.locator('tr').nth(16).locator('td').nth(3)).toHaveText( const oldHTML =
'"text": "richtext2",', `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 () => { test('correctly renders diff for richtext fields with custom Diff component', async () => {

View File

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

View File

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

View File

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