|
| 1 | +# Lexical Block Diff |
| 2 | + |
| 3 | +Generic utilities for rendering custom Payload Lexical blocks in the admin |
| 4 | +**version diff view** via HTML converters, instead of showing only the block |
| 5 | +name/slug. |
| 6 | + |
| 7 | +## Get started |
| 8 | + |
| 9 | +Every block in `req.payload.config.blocks` gets a converter auto-generated |
| 10 | +from its field config. You only write converters for blocks that need a |
| 11 | +bespoke layout. |
| 12 | + |
| 13 | +### 1. Instantiate the diff component |
| 14 | + |
| 15 | +`cms/src/shared/lexical/LexicalBlocksDiffComponent.tsx` |
| 16 | + |
| 17 | +```tsx |
| 18 | +import { createLexicalBlocksDiffComponent } from './plugin' |
| 19 | + |
| 20 | +export const LexicalBlocksDiffComponent = createLexicalBlocksDiffComponent() |
| 21 | + |
| 22 | +export const DIFF_COMPONENT_PATH = |
| 23 | + '/shared/lexical/LexicalBlocksDiffComponent#LexicalBlocksDiffComponent' |
| 24 | +``` |
| 25 | + |
| 26 | +The component must live in the project so Payload's import map can resolve |
| 27 | +it by static path — see the TODO below. |
| 28 | + |
| 29 | +### 2. Wire the editor wrapper |
| 30 | + |
| 31 | +Use it for every `lexicalEditor(...)` call that should render blocks in the |
| 32 | +diff view — both the root editor in `payload.config.ts` and any nested rich |
| 33 | +text fields inside blocks that define their own editor: |
| 34 | + |
| 35 | +```ts |
| 36 | +import { DIFF_COMPONENT_PATH } from './shared/lexical/LexicalBlocksDiffComponent' |
| 37 | +import { lexicalEditorWithBlockDiff } from './shared/lexical/plugin' |
| 38 | + |
| 39 | +editor: lexicalEditorWithBlockDiff( |
| 40 | + { features: ({ defaultFeatures }) => [...defaultFeatures, BlocksFeature({ blocks: [...] })] }, |
| 41 | + { diffComponentPath: DIFF_COMPONENT_PATH }, |
| 42 | +) |
| 43 | +``` |
| 44 | + |
| 45 | +### 3. Regenerate the import map |
| 46 | + |
| 47 | +```bash |
| 48 | +pnpm --filter cms generate:importmap |
| 49 | +``` |
| 50 | + |
| 51 | +### 4. (Optional) Override specific blocks |
| 52 | + |
| 53 | +When the auto layout isn't good enough — e.g. a block with side-by-side |
| 54 | +columns or a custom visual grouping — supply an `override`: |
| 55 | + |
| 56 | +```tsx |
| 57 | +// blockDiffConverters.ts |
| 58 | +import type { TwoColumnBlock } from '../../payload-types' |
| 59 | +import { |
| 60 | + type BlockDiffConverter, |
| 61 | + blockContainer, |
| 62 | + defineBlockConverters, |
| 63 | + richText, |
| 64 | + styles, |
| 65 | +} from './plugin' |
| 66 | + |
| 67 | +const TwoColumnOverride: BlockDiffConverter<TwoColumnBlock> = async (args) => { |
| 68 | + const [left, right] = await Promise.all([ |
| 69 | + richText(args, args.node.fields.left), |
| 70 | + richText(args, args.node.fields.right), |
| 71 | + ]) |
| 72 | + return blockContainer( |
| 73 | + 'Two-Column', |
| 74 | + `<div style="${styles.columns}"><div style="${styles.column}">${left}</div><div style="${styles.column}">${right}</div></div>`, |
| 75 | + ) |
| 76 | +} |
| 77 | + |
| 78 | +export const blockOverrides = defineBlockConverters({ |
| 79 | + twoColumn: TwoColumnOverride, |
| 80 | +}) |
| 81 | +``` |
| 82 | + |
| 83 | +```tsx |
| 84 | +// LexicalBlocksDiffComponent.tsx |
| 85 | +import { blockOverrides } from './blockDiffConverters' |
| 86 | +import { createLexicalBlocksDiffComponent } from './plugin' |
| 87 | + |
| 88 | +export const LexicalBlocksDiffComponent = createLexicalBlocksDiffComponent({ |
| 89 | + overrides: blockOverrides, |
| 90 | +}) |
| 91 | +``` |
| 92 | + |
| 93 | +## Auto-walker field support |
| 94 | + |
| 95 | +| Payload field type | Rendered as | |
| 96 | +| ----------------------------------------------------- | ---------------------------------------------------------------- | |
| 97 | +| `text`, `textarea`, `email`, `number`, `code`, `date` | `field(label, value)` with italic `(empty)` fallback. | |
| 98 | +| `checkbox` | `Yes` / `No`. | |
| 99 | +| `select`, `radio` | Option label (localized), joined by `, ` if `hasMany`. | |
| 100 | +| `point` | `lat, lng` formatted to 5 decimal places. | |
| 101 | +| `upload` | Payload-native `upload-diff` markup with thumbnail + filename. | |
| 102 | +| `relationship` | `title` / `name` / `slug` / `id` of the populated doc. | |
| 103 | +| `richText` | Recursively converted HTML (nested blocks resolve via same map). | |
| 104 | +| `array` | Numbered list, each row rendered recursively. | |
| 105 | +| `group` | Indented block with a left border. | |
| 106 | +| `blocks` | Each item rendered via its top-level block config. | |
| 107 | +| `row`, `collapsible`, `tabs` | Structural — children flattened into parent. | |
| 108 | +| `ui`, `join`, `admin.hidden`, `admin.disabled` | Skipped. | |
| 109 | +| `json` | `JSON.stringify(value)`. | |
| 110 | + |
| 111 | +Field labels use the block/field's configured `label`, resolved for the |
| 112 | +current admin locale. Missing labels fall back to a humanised field name. |
| 113 | + |
| 114 | +## API reference |
| 115 | + |
| 116 | +### Helpers |
| 117 | + |
| 118 | +| Helper | Purpose | |
| 119 | +| ---------------------------------------------- | --------------------------------------------------------------------------------- | |
| 120 | +| `blockContainer(blockType, content)` | Outer frame with the block label. | |
| 121 | +| `field(label, value)` | `Label: value` row, with italic `(empty)` fallback. | |
| 122 | +| `coordinates(label, [lng, lat])` | Formats a Payload `point` field. | |
| 123 | +| `createUploadArrayHelper({ mediaCollection })` | Returns an `uploadArray(label, items, populate)` that renders thumbnails. | |
| 124 | +| `richText(args, data)` | Recursively converts nested Lexical rich text, forwarding outer `converters`. | |
| 125 | +| `styles` | Inline-style string map (uses Payload admin CSS vars like `--theme-elevation-*`). | |
| 126 | +| `defineBlockConverters(map)` | Identity helper that widens per-block types to `BlockDiffConvertersMap`. | |
| 127 | + |
| 128 | +### Types |
| 129 | + |
| 130 | +- `BlockDiffConverter<B>` — typed converter for a specific block. Use this on |
| 131 | + per-block converter declarations. |
| 132 | +- `BlockDiffConvertersMap` — map passed to `createLexicalBlocksDiffComponent`. |
| 133 | +- `UploadDoc`, `PopulateFn`, `LexicalEditorState` — structural helpers. |
| 134 | + |
| 135 | +### Nested blocks |
| 136 | + |
| 137 | +Block converters receive `args.converters` — forward it so nested blocks |
| 138 | +inside nested rich text resolve via the same map. The `richText` helper does |
| 139 | +this automatically: |
| 140 | + |
| 141 | +```ts |
| 142 | +const Converter: BlockDiffConverter<TwoColumn> = async (args) => { |
| 143 | + const left = await richText(args, args.node.fields.left) |
| 144 | + const right = await richText(args, args.node.fields.right) |
| 145 | + // ... |
| 146 | +} |
| 147 | +``` |
| 148 | + |
| 149 | +## What's in here |
| 150 | + |
| 151 | +| File | Purpose | |
| 152 | +| -------------------------------------- | -------------------------------------------------------------------------------------------------- | |
| 153 | +| `createLexicalBlocksDiffComponent.tsx` | Factory returning a `RichTextFieldDiffServerComponent` that merges auto + project overrides. | |
| 154 | +| `autoBlockConverter.ts` | Walks a Payload `Block`'s field config and renders each field with the matching helper. | |
| 155 | +| `lexicalEditorWithBlockDiff.ts` | Wraps `lexicalEditor()`, overrides `DiffComponent`, registers it in the generated import map. | |
| 156 | +| `helpers.ts` | HTML rendering (`field`, `coordinates`, `uploadArray`, `richText`, ...) + `defineBlockConverters`. | |
| 157 | +| `types.ts` | `BlockDiffConverter`, `BlockDiffConvertersMap`, `LexicalEditorState`, `UploadDoc`, `PopulateFn`. | |
| 158 | + |
| 159 | +## TODO |
| 160 | + |
| 161 | +- [ ] Extract this folder into a standalone package and publish on npm, e.g. |
| 162 | + `@jhb.software/payload-lexical-block-diff`. |
| 163 | +- [ ] Reuse it across the other websites in `jhb-websites/` (website, |
| 164 | + houseofu, silke-liederbach-website, freund-immo, blumengreif, |
| 165 | + bewusstseinsakademie, martinasaur, le-chauffeur) — each currently shows |
| 166 | + only block names in the version diff view. |
| 167 | +- [ ] Before extracting, verify nothing in this folder imports from the host |
| 168 | + project (payload-types, config, collections). Only the consumer's glue |
| 169 | + files should be project-specific. |
| 170 | +- [ ] Investigate whether the plugin could **own the diff component** itself |
| 171 | + (exporting a prebuilt component at a path like |
| 172 | + `@jhb.software/payload-lexical-block-diff/rsc#LexicalBlocksDiffComponent`) |
| 173 | + so consumers no longer have to create a `LexicalBlocksDiffComponent.tsx` |
| 174 | + file or pass `diffComponentPath` to the wrapper. The open problem is |
| 175 | + getting the project-specific `converters` into that component — Payload's |
| 176 | + import map resolves components via static string paths, so converters |
| 177 | + can't be passed through. Possible approaches, both with trade-offs: - Global registry populated at module load (order-dependent, hacky). - Turn this into a proper Payload plugin with an `init`/config hook that |
| 178 | + injects converters into the component via shared state on |
| 179 | + `req.payload.config` or similar. |
0 commit comments