Benutzerdefinierte Payload-Lexical-Blöcke in der Version-Diff-Ansicht rendern

Dieser Artikel zeigt, wie sich aussagekräftige Versionsdiffs für benutzerdefinierte Lexical-Blöcke in Payload CMS rendern lassen — durch Registrierung einer eigenen DiffComponent, die auf automatisch generierten Field-Convertern basiert.

Porträt eines jungen Mannes mit kurzer brauner Frisur und blauem Hemd vor einer grünen, bewachsenen Landschaft.

Geschrieben von

Jens Becker

Veröffentlicht am

9. Juli 2026

Zuletzt aktualisiert am

7. September 2026

Tags

Payload CMS

Enthält ein Lexical-Rich-Text-Feld benutzerdefinierte Blöcke, zeigt Payloads Version-Diff-Ansicht diese nur als Slug — my-block-slug statt der geänderten Feldwerte:

Screenshot eines digitalen Vergleichs von Textversionen mit einem hervorgehobenen Unterschied im linken Abschnitt, der in rotem Hintergrund und blauer Markierung angezeigt wird.

Das macht den Versionsvergleich für jeden Block mit mehr als einem relevanten Feld unbrauchbar. Die Lösung ist eine benutzerdefinierte DiffComponent: eine React Server Component, die Payload beim Vergleich zweier Versionen eines Rich-Text-Felds rendert.

Der Ansatz besteht aus drei Teilen, die in den folgenden Abschnitten der Reihe nach behandelt werden. Zunächst wird eine benutzerdefinierte DiffComponent registriert, damit Payload unseren Code für das Feld aufruft. Dann werden im Inneren dieser Component beide Versionen des Inhalts in HTML konvertiert und die zwei Strings an Payloads eigenes Diff-Utility übergeben. Schließlich wird dieses HTML automatisch aus der Field-Config jedes Blocks generiert, sodass neue Blöcke keinen zusätzlichen Aufwand erfordern.

Das Ergebnis sieht so aus:

Vergleichsansicht eines Zwei-Spalten-Textblocks mit markierten Unterschieden; linke Seite zeigt eine frühere Version des Texts mit durchgestrichenen und roten Markierungen, rechte Seite die aktuelle Version mit hervorgehobenen Änderungen in Blau.

Eine eigene DiffComponent

Payload löst Admin-Komponenten über eine statische Import-Map auf, nicht über direkte Imports. Es wird keine Component-Instanz übergeben — stattdessen ein Pfad-String (wie /shared/lexical/LexicalBlocksDiffComponent#LexicalBlocksDiffComponent), den Payload in einer generierten importMap.js nachschlägt. Es gibt zwei Probleme: dem Editor mitzuteilen, welche Diff-Komponente zu verwenden ist, und sicherzustellen, dass diese Komponente in der Import-Map registriert ist, damit Payload sie zur Laufzeit finden kann.

Die DiffComponent des Editors überschreiben

Wir haben lexicalEditor() in einem kleinen Helper lexicalEditorWithBlockDiff gekapselt, der beides an einer Stelle erledigt:

ts
/**
 * Wraps `lexicalEditor()` and replaces the default `DiffComponent` with a
 * project-provided one, registering it in the generated import map.
 */
export const lexicalEditorWithBlockDiff = (
  args: LexicalEditorArgs | undefined,
  { diffComponentPath }: Options,
) => {
  const base = lexicalEditor(args)
  return async (ctx: Parameters<ReturnType<typeof lexicalEditor>>[0]) => {
    const result = await base(ctx)
    const baseGenerateImportMap = result.generateImportMap
    return {
      ...result,
      DiffComponent: diffComponentPath,
      generateImportMap: (
        importMapArgs: Parameters<NonNullable<typeof baseGenerateImportMap>>[0],
      ) => {
        baseGenerateImportMap?.(importMapArgs)
        importMapArgs.addToImportMap(diffComponentPath)
      },
    }
  }
}

Zwei Dinge passieren hier:

  • DiffComponent — mit unserem eigenen Pfad-String überschrieben.
  • generateImportMap — erweitert, sodass unsere Komponente neben Payloads Defaults in die Import-Map aufgenommen wird. Ohne das kann Payload den Pfad nicht auflösen und die Diff-Ansicht bricht.

Der Wrapper ist für alles andere transparent, sodass er lexicalEditor(...) überall ersetzen kann: im Root-Editor in payload.config.ts und in jedem verschachtelten Rich-Text-Feld innerhalb eines Blocks, der einen eigenen Editor definiert.

In der Config ist er ein Drop-in-Ersatz für lexicalEditor(). diffComponentPath ist derselbe statische Import-Map-Pfad, den der Wrapper registriert:

ts
editor: lexicalEditorWithBlockDiff(
  {
    features: ({ defaultFeatures }) => [
      ...defaultFeatures,
      BlocksFeature({ blocks: [/* your blocks */] }),
    ],
  },
  { diffComponentPath: '/shared/lexical/LexicalBlocksDiffComponent#LexicalBlocksDiffComponent' },
)

Der Pfad verweist auf die Diff-Komponente, die die eigentliche Arbeit erledigt — wir bauen sie im nächsten Abschnitt. Nach der Einbindung muss die Import-Map neu generiert werden (payload generate:importmap), damit Payload den Pfad auflösen kann.

Blöcke als HTML rendern und das Ergebnis vergleichen

Payload liefert getHTMLDiffComponents mit, ein Utility, das fromHTML- und toHTML-Strings entgegennimmt und highlighted Before/After-React-Trees erzeugt. Der Ansatz: Jede Version des Lexical-State wird in HTML konvertiert, dann übernimmt Payload das Diff — keine eigene Diff-Logik nötig.

createLexicalBlocksDiffComponent gibt eine RichTextFieldDiffServerComponent zurück, die:

  1. Mit getPayloadPopulateFn eine Populate-Funktion erstellt, damit Relationships und Uploads innerhalb von Blöcken zu echten Daten aufgelöst werden können.
  2. Die Vorher- (comparisonValue) und Nachher-Version (versionValue) des Lexical-State mit convertLexicalToHTMLAsync in HTML konvertiert und dabei eine Converter-Map verwendet, die benutzerdefinierte Blöcke rendern kann.
  3. Beide Strings an getHTMLDiffComponents übergibt.
  4. Das Ergebnis in Payloads FieldDiffContainer einbettet, damit es nativ aussieht.

Payload ruft die Component mit dem Request (req) und den zwei zu vergleichenden Feldwerten auf. converters ist die im nächsten Abschnitt zusammengestellte Block-Map — ein Converter pro registriertem Block, plus optionale Overrides. Der Kern ist kurz:

ts
// Resolves related documents (relationships, uploads) up to one level deep.
const populate = await getPayloadPopulateFn({ currentDepth: 0, depth: 1, req })

const fromHTML = await convertLexicalToHTMLAsync({ converters, data: valueFrom, populate }) // valueFrom = comparisonValue
const toHTML   = await convertLexicalToHTMLAsync({ converters, data: valueTo,   populate }) // valueTo   = versionValue

const { From, To } = getHTMLDiffComponents({
  fromHTML: fromHTML?.length ? fromHTML : '<p></p>',
  toHTML:   toHTML?.length   ? toHTML   : '<p></p>',
})

populate an convertLexicalToHTMLAsync zu übergeben ermöglicht es den Convertern, Relationships und Uploads innerhalb von Blöcken aufzulösen, sodass im Diff "Author: Jane Doe" oder ein echtes Bild-Thumbnail statt einer rohen Dokument-ID erscheint. depth: 1 hält die Population flach: genug, um einen Titel oder ein Thumbnail zu rendern, ohne den gesamten Relationship-Graphen zu laden.

createLexicalBlocksDiffComponent ist eine Factory. Die Datei, auf die der Import-Map-Pfad verweist, tut nichts anderes als sie aufzurufen und das Ergebnis zu exportieren — das ist die Komponente, die Payload für die Diff-Ansicht lädt:

ts
// LexicalBlocksDiffComponent.tsx — the file the import-map path resolves to
export const LexicalBlocksDiffComponent = createLexicalBlocksDiffComponent({
  overrides: blockOverrides, // optional; see below
})

Automatisch generierte Converter

Für jeden Block von Hand einen HTML-Converter zu schreiben, ist nur eine andere Variante des ursprünglichen Problems. Stattdessen geht die Komponente die Payload-Field-Config jedes Blocks durch und generiert automatisch einen Converter.

Zur Renderzeit liest sie req.payload.config.blocks und baut aus den Field-Definitionen einen Converter für jeden registrierten Block:

ts
// allBlocks = req.payload.config.blocks; blocksMap is a slug → config lookup
const autoConverters: BlockDiffConvertersMap = {}
for (const block of allBlocks) {
  autoConverters[block.slug] = createAutoBlockConverter(block, blocksMap, { locale })
}
const blocks = { ...autoConverters, ...overrides }

// this is the `converters` passed to convertLexicalToHTMLAsync above
const converters = ({ defaultConverters }) => ({
  ...defaultConverters,
  blocks: { ...defaultConverters.blocks, ...blocks },
})

createAutoBlockConverter geht die Felder des Blocks rekursiv durch und rendert jeden mit einem typgerechten Helper — Scalar-Felder werden zu Label-Wert-Zeilen, Relationships und Uploads werden zu Titel oder Thumbnail aufgelöst, und verschachteltes richText wird durch dieselbe Converter-Map verarbeitet, sodass Blöcke in Blöcken einfach funktionieren. Strukturelle Wrapper (Rows, Tabs) werden geflacht; versteckte und rein UI-seitige Felder werden übersprungen.

Das Ergebnis: Ein neuer Block im Lexical-Editor erscheint automatisch mit einem lesbaren, feldweisen Layout im Versionsdiff — ohne zusätzlichen Code.

Escape Hatches für benutzerdefinierte Layouts

Die automatische Generierung deckt den Standardfall ab, aber manche Blöcke benötigen ein Layout, das der Walker nicht ableiten kann — Side-by-Side-Spalten, benutzerdefinierte Gruppierungen und Ähnliches. Ein overrides-Map, der über die automatisch generierten Converter gelegt wird, ermöglicht genau das:

ts
const TwoColumnRichTextOverride: BlockDiffConverter<TwoColumnRichTextBlock> = async (args) => {
  const { firstContent, secondContent } = args.node.fields
  const [first, second] = await Promise.all([
    richText(args, firstContent),
    richText(args, secondContent),
  ])
  return blockContainer(
    'Two-Column Text',
    `<div style="${styles.columns}">
       <div style="${styles.column}">${first}</div>
       <div style="${styles.column}">${second}</div>
     </div>`,
  )
}

export const blockOverrides = defineBlockConverters({
  twoColumnRichText: TwoColumnRichTextOverride,
})

Ein kleines Kit von Hilfsfunktionen — blockContainer, field, richText, uploadArray und eine styles-Map, die auf Payloads Admin-CSS-Variablen wie --theme-elevation-* verweist — hält Overrides kurz und lässt sie nahtlos ins Light- und Dark-Theme des Admin-UI einfügen.

Der vollständige Code ist unter github.com/jhb-dev/payload-lexical-block-diff verfügbar.

Fazit

Eine benutzerdefinierte DiffComponent einzubinden und sie mit automatisch generierten Field-Convertern zu verbinden, verwandelt Payloads Versionsdiff von einer Liste aus Block-Slugs in einen feldweisen Vergleich, der tatsächlich nützlich ist. Neue Blöcke erhalten automatisch lesbare Diffs; die wenigen, die ein benutzerdefiniertes Layout benötigen, können genau das überschreiben. Die vollständige Implementierung ist im verlinkten Repository zu finden.