Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/root-cms/ui/components/FieldHistory/FieldHistory.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
100 changes: 89 additions & 11 deletions packages/root-cms/ui/components/FieldHistory/FieldHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand All @@ -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,
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -115,17 +122,13 @@ export function FieldHistory(props: FieldHistoryProps) {
{i === 0 && (
<span className="FieldHistory__entry__badge">Current</span>
)}
{translatable && entry.value && (
{translatable && entry.valueKey && (
<span className="FieldHistory__entry__translationsLink">
<TranslationsLink value={entry.value} />
<TranslationsLink value={entry.valueKey} />
</span>
)}
</div>
<div className="FieldHistory__entry__value">
{entry.value || (
<span className="FieldHistory__entry__empty">(empty)</span>
)}
</div>
<FieldValueDisplay rawValue={entry.rawValue} />
</div>
))}
</div>
Expand Down Expand Up @@ -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 (
<div className="FieldHistory__entry__value FieldHistory__entry__value--richtext">
<LexicalReadOnly
className="FieldHistory__entry__lexical"
value={rawValue as RichTextData}
/>
</div>
);
}
const formatted = formatFieldValue(rawValue);
return (
<div className="FieldHistory__entry__value">
{formatted || <span className="FieldHistory__entry__empty">(empty)</span>}
</div>
);
}

/** 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) {
Expand All @@ -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<string, unknown>;
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;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import './LexicalEditor.css';

import {AutoLinkNode, LinkNode} from '@lexical/link';
import {ListItemNode, ListNode} from '@lexical/list';
import {
InitialConfigType,
LexicalComposer,
Expand All @@ -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';
Expand All @@ -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 {
Expand All @@ -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';
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<LexicalNode>[] = [
AutoLinkNode,
HeadingNode,
QuoteNode,
LinkNode,
ListNode,
ListItemNode,
HorizontalRuleNode,
TableNode,
TableCellNode,
TableRowNode,
BlockComponentNode,
InlineComponentNode,
SpecialCharacterNode,
];
Original file line number Diff line number Diff line change
@@ -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 (
<LexicalComposer initialConfig={READ_ONLY_CONFIG}>
<LoadValuePlugin value={props.value} />
<RichTextPlugin
contentEditable={<ContentEditable className={props.className} />}
ErrorBoundary={LexicalErrorBoundary}
/>
</LexicalComposer>
);
}

/** 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;
}
Loading