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  ## After  
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export type HTMLConvertersAsync<
|
|||||||
: SerializedInlineBlockNode
|
: SerializedInlineBlockNode
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
unknown?: HTMLConverterAsync<SerializedLexicalNode>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HTMLConvertersFunctionAsync<
|
export type HTMLConvertersFunctionAsync<
|
||||||
|
|||||||
@@ -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`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export type HTMLConverters<
|
|||||||
: SerializedInlineBlockNode
|
: SerializedInlineBlockNode
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
unknown?: HTMLConverter<SerializedLexicalNode>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HTMLConvertersFunction<
|
export type HTMLConvertersFunction<
|
||||||
|
|||||||
@@ -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`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export type JSXConverters<
|
|||||||
: SerializedInlineBlockNode
|
: SerializedInlineBlockNode
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
unknown?: JSXConverter<SerializedLexicalNode>
|
||||||
}
|
}
|
||||||
export type SerializedLexicalNodeWithParent = {
|
export type SerializedLexicalNodeWithParent = {
|
||||||
parent?: SerializedLexicalNode
|
parent?: SerializedLexicalNode
|
||||||
|
|||||||
35
packages/richtext-lexical/src/field/Diff/colors.scss
Normal file
35
packages/richtext-lexical/src/field/Diff/colors.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
packages/richtext-lexical/src/field/Diff/converters/link.ts
Normal file
59
packages/richtext-lexical/src/field/Diff/converters/link.ts
Normal 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>`
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} </span>
|
||||||
|
)}
|
||||||
|
<span>{nodeType}</span>
|
||||||
|
<div className={`${baseClass}__meta`}>
|
||||||
|
<br />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render to HTML
|
||||||
|
const html = ReactDOMServer.renderToString(JSX)
|
||||||
|
|
||||||
|
return html
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
-
|
||||||
|
{uploadDoc?.width}x{uploadDoc?.height}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
{uploadDoc?.mimeType && (
|
||||||
|
<React.Fragment>
|
||||||
|
-
|
||||||
|
{uploadDoc?.mimeType}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render to HTML
|
||||||
|
const html = ReactDOMServer.renderToString(JSX)
|
||||||
|
|
||||||
|
return html
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/richtext-lexical/src/field/Diff/htmlDiff/LICENSE.MD
Normal file
21
packages/richtext-lexical/src/field/Diff/htmlDiff/LICENSE.MD
Normal 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.
|
||||||
90
packages/richtext-lexical/src/field/Diff/htmlDiff/index.scss
Normal file
90
packages/richtext-lexical/src/field/Diff/htmlDiff/index.scss
Normal 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: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
659
packages/richtext-lexical/src/field/Diff/htmlDiff/index.ts
Normal file
659
packages/richtext-lexical/src/field/Diff/htmlDiff/index.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
95
packages/richtext-lexical/src/field/Diff/index.scss
Normal file
95
packages/richtext-lexical/src/field/Diff/index.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
packages/richtext-lexical/src/field/Diff/index.tsx
Normal file
74
packages/richtext-lexical/src/field/Diff/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
628
test/versions/collections/Diff/generateLexicalData.ts
Normal file
628
test/versions/collections/Diff/generateLexicalData.ts
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
17
test/versions/collections/Text.ts
Normal file
17
test/versions/collections/Text.ts
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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".
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user