diff --git a/packages/root-cms/ui/components/FieldHistory/FieldHistory.css b/packages/root-cms/ui/components/FieldHistory/FieldHistory.css
index 0cd84d464..5f0cad73c 100644
--- a/packages/root-cms/ui/components/FieldHistory/FieldHistory.css
+++ b/packages/root-cms/ui/components/FieldHistory/FieldHistory.css
@@ -69,3 +69,18 @@
color: #adb5bd;
font-style: italic;
}
+
+.FieldHistory__entry__value--richtext {
+ background: #f8f9fa;
+ border: 1px solid #dee2e6;
+ border-radius: 4px;
+ padding: 10px;
+ white-space: normal;
+}
+
+.FieldHistory__entry__lexical {
+ font-size: 13px;
+ line-height: 1.5;
+ outline: none;
+ cursor: default;
+}
diff --git a/packages/root-cms/ui/components/FieldHistory/FieldHistory.tsx b/packages/root-cms/ui/components/FieldHistory/FieldHistory.tsx
index 2eaf32486..1b3af5fa7 100644
--- a/packages/root-cms/ui/components/FieldHistory/FieldHistory.tsx
+++ b/packages/root-cms/ui/components/FieldHistory/FieldHistory.tsx
@@ -3,13 +3,18 @@
import {ActionIcon, Loader, Tooltip} from '@mantine/core';
import {IconLanguage} from '@tabler/icons-preact';
import {useEffect, useState} from 'preact/hooks';
+import {RichTextData} from '../../../shared/richtext.js';
import {cmsListVersions, cmsReadDocVersion} from '../../utils/doc.js';
import {sourceHash} from '../../utils/l10n.js';
import {getNestedValue} from '../../utils/objects.js';
+import {LexicalReadOnly} from '../RichTextEditor/lexical/LexicalReadOnly.js';
import './FieldHistory.css';
interface FieldVersion {
- value: string;
+ /** The raw field value (could be string, RichTextData, object, etc.). */
+ rawValue: unknown;
+ /** A string representation used for deduplication. */
+ valueKey: string;
modifiedBy: string;
modifiedAt: Date;
versionId: string;
@@ -55,7 +60,8 @@ export function FieldHistory(props: FieldHistoryProps) {
if (draftDoc) {
const draftValue = getNestedValue(draftDoc, deepKey);
entries.push({
- value: formatFieldValue(draftValue),
+ rawValue: draftValue,
+ valueKey: toValueKey(draftValue),
modifiedBy: draftDoc.sys?.modifiedBy || 'Unknown',
modifiedAt: draftDoc.sys?.modifiedAt?.toDate?.() || new Date(),
versionId: 'draft',
@@ -66,7 +72,8 @@ export function FieldHistory(props: FieldHistoryProps) {
for (const version of versions) {
const value = getNestedValue(version, deepKey);
entries.push({
- value: formatFieldValue(value),
+ rawValue: value,
+ valueKey: toValueKey(value),
modifiedBy: version.sys?.modifiedBy || 'Unknown',
modifiedAt: version.sys?.modifiedAt?.toDate?.() || new Date(),
versionId: version._versionId,
@@ -77,7 +84,7 @@ export function FieldHistory(props: FieldHistoryProps) {
const deduped: FieldVersion[] = [];
for (const entry of entries) {
const prev = deduped[deduped.length - 1];
- if (!prev || prev.value !== entry.value) {
+ if (!prev || prev.valueKey !== entry.valueKey) {
deduped.push(entry);
}
}
@@ -115,17 +122,13 @@ export function FieldHistory(props: FieldHistoryProps) {
{i === 0 && (
Current
)}
- {translatable && entry.value && (
+ {translatable && entry.valueKey && (
-
+
)}
-
- {entry.value || (
- (empty)
- )}
-
+
))}
@@ -161,6 +164,47 @@ function TranslationsLink(props: {value: string}) {
);
}
+/** Renders the value for a history entry. */
+function FieldValueDisplay(props: {rawValue: unknown}) {
+ const {rawValue} = props;
+ if (isRichTextData(rawValue)) {
+ return (
+
+
+
+ );
+ }
+ const formatted = formatFieldValue(rawValue);
+ return (
+
+ {formatted || (empty)}
+
+ );
+}
+
+/** Checks if a value looks like RichTextData. */
+function isRichTextData(value: unknown): boolean {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ Array.isArray((value as any).blocks)
+ );
+}
+
+/** Produces a stable string key for deduplication. */
+function toValueKey(value: unknown): string {
+ if (value === undefined || value === null) {
+ return '';
+ }
+ if (typeof value === 'string') {
+ return value;
+ }
+ return JSON.stringify(value);
+}
+
/** Converts a field value to a display string. */
function formatFieldValue(value: unknown): string {
if (value === undefined || value === null) {
@@ -169,5 +213,39 @@ function formatFieldValue(value: unknown): string {
if (typeof value === 'string') {
return value;
}
+ if (typeof value === 'boolean' || typeof value === 'number') {
+ return String(value);
+ }
+ if (Array.isArray(value)) {
+ return value.map((item) => formatFieldValue(item)).join(', ');
+ }
+ if (isImageValue(value)) {
+ return formatImageValue(value);
+ }
return JSON.stringify(value);
}
+
+/** Checks if a value looks like an image field object. */
+function isImageValue(
+ value: unknown
+): value is {src?: string; alt?: string; url?: string} {
+ if (typeof value !== 'object' || value === null) {
+ return false;
+ }
+ const obj = value as Record;
+ return typeof obj.src === 'string' || typeof obj.url === 'string';
+}
+
+/** Formats an image field value. */
+function formatImageValue(value: {
+ src?: string;
+ alt?: string;
+ url?: string;
+}): string {
+ const src = value.src || value.url || '';
+ const alt = value.alt || '';
+ if (alt) {
+ return `${src}\nalt: ${alt}`;
+ }
+ return src;
+}
diff --git a/packages/root-cms/ui/components/RichTextEditor/lexical/LexicalEditor.tsx b/packages/root-cms/ui/components/RichTextEditor/lexical/LexicalEditor.tsx
index dcb5dc7cb..d3aa7673c 100644
--- a/packages/root-cms/ui/components/RichTextEditor/lexical/LexicalEditor.tsx
+++ b/packages/root-cms/ui/components/RichTextEditor/lexical/LexicalEditor.tsx
@@ -1,7 +1,5 @@
import './LexicalEditor.css';
-import {AutoLinkNode, LinkNode} from '@lexical/link';
-import {ListItemNode, ListNode} from '@lexical/list';
import {
InitialConfigType,
LexicalComposer,
@@ -10,15 +8,12 @@ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
-import {HorizontalRuleNode} from '@lexical/react/LexicalHorizontalRuleNode';
import {HorizontalRulePlugin} from '@lexical/react/LexicalHorizontalRulePlugin';
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {TabIndentationPlugin} from '@lexical/react/LexicalTabIndentationPlugin';
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
-import {HeadingNode, QuoteNode} from '@lexical/rich-text';
-import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
import {$getNodeByKey, $insertNodes, NodeKey} from 'lexical';
import {useMemo, useState} from 'preact/hooks';
import * as schema from '../../../../core/schema.js';
@@ -34,6 +29,7 @@ import {
useSharedHistory,
} from './hooks/useSharedHistory.js';
import {ToolbarProvider} from './hooks/useToolbar.js';
+import {LEXICAL_NODES} from './LexicalNodes.js';
import {LexicalTheme} from './LexicalTheme.js';
import {BlockComponentModal} from './nodes/BlockComponentModal.js';
import {
@@ -47,7 +43,6 @@ import {
$isInlineComponentNode,
InlineComponentNode,
} from './nodes/InlineComponentNode.js';
-import {SpecialCharacterNode} from './nodes/SpecialCharacterNode.js';
import {FloatingLinkEditorPlugin} from './plugins/FloatingLinkEditorPlugin.js';
import {FloatingToolbarPlugin} from './plugins/FloatingToolbarPlugin.js';
import {ImagePastePlugin} from './plugins/ImagePastePlugin.js';
@@ -63,21 +58,7 @@ import {TrailingParagraphPlugin} from './plugins/TrailingParagraphPlugin.js';
const INITIAL_CONFIG: InitialConfigType = {
namespace: 'RootCMS',
theme: LexicalTheme,
- nodes: [
- AutoLinkNode,
- HeadingNode,
- QuoteNode,
- LinkNode,
- ListNode,
- ListItemNode,
- HorizontalRuleNode,
- TableNode,
- TableCellNode,
- TableRowNode,
- BlockComponentNode,
- InlineComponentNode,
- SpecialCharacterNode,
- ],
+ nodes: LEXICAL_NODES,
onError: (err: Error) => {
console.error('[LexicalEditor] error:', err);
throw err;
diff --git a/packages/root-cms/ui/components/RichTextEditor/lexical/LexicalNodes.ts b/packages/root-cms/ui/components/RichTextEditor/lexical/LexicalNodes.ts
new file mode 100644
index 000000000..ff9e40815
--- /dev/null
+++ b/packages/root-cms/ui/components/RichTextEditor/lexical/LexicalNodes.ts
@@ -0,0 +1,27 @@
+/** Shared Lexical node registrations used by both the editor and read-only renderer. */
+
+import {AutoLinkNode, LinkNode} from '@lexical/link';
+import {ListItemNode, ListNode} from '@lexical/list';
+import {HorizontalRuleNode} from '@lexical/react/LexicalHorizontalRuleNode';
+import {HeadingNode, QuoteNode} from '@lexical/rich-text';
+import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
+import {Klass, LexicalNode} from 'lexical';
+import {BlockComponentNode} from './nodes/BlockComponentNode.js';
+import {InlineComponentNode} from './nodes/InlineComponentNode.js';
+import {SpecialCharacterNode} from './nodes/SpecialCharacterNode.js';
+
+export const LEXICAL_NODES: Klass[] = [
+ AutoLinkNode,
+ HeadingNode,
+ QuoteNode,
+ LinkNode,
+ ListNode,
+ ListItemNode,
+ HorizontalRuleNode,
+ TableNode,
+ TableCellNode,
+ TableRowNode,
+ BlockComponentNode,
+ InlineComponentNode,
+ SpecialCharacterNode,
+];
diff --git a/packages/root-cms/ui/components/RichTextEditor/lexical/LexicalReadOnly.tsx b/packages/root-cms/ui/components/RichTextEditor/lexical/LexicalReadOnly.tsx
new file mode 100644
index 000000000..ba03606f5
--- /dev/null
+++ b/packages/root-cms/ui/components/RichTextEditor/lexical/LexicalReadOnly.tsx
@@ -0,0 +1,57 @@
+/**
+ * @fileoverview A lightweight read-only Lexical renderer. Renders RichTextData
+ * using the same Lexical nodes and theme as the full editor, but with no
+ * editing capabilities or toolbar.
+ */
+
+import {
+ InitialConfigType,
+ LexicalComposer,
+} from '@lexical/react/LexicalComposer';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {ContentEditable} from '@lexical/react/LexicalContentEditable';
+import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
+import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
+import {useEffect} from 'preact/hooks';
+import {RichTextData} from '../../../../shared/richtext.js';
+import {LEXICAL_NODES} from './LexicalNodes.js';
+import {LexicalTheme} from './LexicalTheme.js';
+import {convertToLexical} from './utils/convert-to-lexical.js';
+
+const READ_ONLY_CONFIG: InitialConfigType = {
+ namespace: 'RootCMS_ReadOnly',
+ theme: LexicalTheme,
+ editable: false,
+ nodes: LEXICAL_NODES,
+ onError: (err: Error) => {
+ console.error('[LexicalReadOnly] error:', err);
+ },
+};
+
+export interface LexicalReadOnlyProps {
+ value: RichTextData;
+ className?: string;
+}
+
+export function LexicalReadOnly(props: LexicalReadOnlyProps) {
+ return (
+
+
+ }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+
+ );
+}
+
+/** Loads a RichTextData value into the read-only editor. */
+function LoadValuePlugin(props: {value: RichTextData}) {
+ const [editor] = useLexicalComposerContext();
+ useEffect(() => {
+ editor.update(() => {
+ convertToLexical(props.value);
+ });
+ }, [editor, props.value]);
+ return null;
+}