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; +}