diff --git a/packages/root-cms/package.json b/packages/root-cms/package.json index 73ab582a..fa824f69 100644 --- a/packages/root-cms/package.json +++ b/packages/root-cms/package.json @@ -67,6 +67,15 @@ "@genkit-ai/ai": "0.5.2", "@genkit-ai/core": "0.5.2", "@genkit-ai/vertexai": "0.5.2", + "@lexical/code": "0.31.2", + "@lexical/html": "0.31.2", + "@lexical/link": "0.31.2", + "@lexical/list": "0.31.2", + "@lexical/markdown": "0.31.2", + "@lexical/react": "0.31.2", + "@lexical/rich-text": "0.31.2", + "@lexical/selection": "0.31.2", + "@lexical/utils": "0.31.2", "body-parser": "1.20.2", "commander": "11.0.0", "csv-parse": "5.5.2", @@ -75,8 +84,10 @@ "fnv-plus": "1.3.1", "jsonwebtoken": "9.0.2", "kleur": "4.1.5", + "lexical": "0.31.2", "sirv": "2.0.3", - "tiny-glob": "0.2.9" + "tiny-glob": "0.2.9", + "yjs": "13.6.27" }, "//": "NOTE(stevenle): due to compat issues with mantine and preact, mantine is pinned to v4.2.12", "devDependencies": { diff --git a/packages/root-cms/shared/objects.ts b/packages/root-cms/shared/objects.ts new file mode 100644 index 00000000..0329d2da --- /dev/null +++ b/packages/root-cms/shared/objects.ts @@ -0,0 +1,33 @@ +export function isObject(data: any): boolean { + return typeof data === 'object' && !Array.isArray(data) && data !== null; +} + +/** + * Recursively sets data to an object using dot notation, e.g. + * ``` + * setDeepKey({}, 'foo.bar', 'value'); + * // => {foo: {bar: 'value'}} + * ``` + */ +export function setDeepKey(data: any, deepKey: string, value: any) { + if (deepKey.includes('.')) { + const [head, tail] = splitKey(deepKey); + data[head] ??= {}; + setDeepKey(data[head], tail, value); + } else { + const key = deepKey; + if (typeof value === 'undefined') { + delete data[key]; + } else { + data[key] = value; + } + } + return data; +} + +function splitKey(key: string) { + const index = key.indexOf('.'); + const head = key.substring(0, index); + const tail = key.substring(index + 1); + return [head, tail] as const; +} diff --git a/packages/root-cms/shared/richtext.ts b/packages/root-cms/shared/richtext.ts new file mode 100644 index 00000000..126c4094 --- /dev/null +++ b/packages/root-cms/shared/richtext.ts @@ -0,0 +1,66 @@ +import {isObject} from './objects.js'; + +export type RichTextBlock = RichTextParagraphBlock | RichTextHeadingBlock | RichTextListBlock | RichTextImageBlock | RichTextHtmlBlock | RichTextCustomBlock; + +export interface RichTextParagraphBlock { + type: 'paragraph'; + data?: { + text?: string; + }; +} + +export interface RichTextHeadingBlock { + type: 'heading'; + data?: { + level?: number; + text?: string; + }; +} + +export interface RichTextListItem { + content?: string; + itemsType?: 'orderedList' | 'unorderedList'; + items?: RichTextListItem[]; +} + +export interface RichTextListBlock { + type: 'orderedList' | 'unorderedList'; + data?: { + style?: 'ordered' | 'unordered'; + items?: RichTextListItem[]; + }; +} + +export interface RichTextImageBlock { + type: 'image'; + data?: { + file?: { + url: string; + width: string | number; + height: string | number; + alt: string; + }; + }; +} + +export interface RichTextHtmlBlock { + type: 'html'; + data?: { + html?: string; + }; +} + +export interface RichTextCustomBlock { + type: TypeName; + data?: DataType; +} + +export interface RichTextData { + blocks: RichTextBlock[]; + time: number; + version: string; +} + +export function testValidRichTextData(data: RichTextData) { + return isObject(data) && Array.isArray(data.blocks) && data.blocks.length > 0; +} diff --git a/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx b/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx index 44dc6180..255c8e53 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx @@ -1,23 +1,18 @@ import {useEffect, useState} from 'preact/hooks'; import * as schema from '../../../../core/schema.js'; -import {deepEqual} from '../../../utils/objects.js'; import { - RichTextData, RichTextEditor, } from '../../RichTextEditor/RichTextEditor.js'; import {FieldProps} from './FieldProps.js'; +import {RichTextData} from '../../../../shared/richtext.js'; export function RichTextField(props: FieldProps) { const field = props.field as schema.RichTextField; - const [value, setValue] = useState({ - blocks: [{type: 'paragraph', data: {}}], - }); + const [value, setValue] = useState(null); - function onChange(newValue: RichTextData) { - setValue((currentValue) => { - if ( - !deepEqual({blocks: currentValue?.blocks}, {blocks: newValue?.blocks}) - ) { + function onChange(newValue: RichTextData | null) { + setValue((oldValue) => { + if (oldValue?.time !== newValue?.time) { props.draft.updateKey(props.deepKey, newValue); } return newValue; diff --git a/packages/root-cms/ui/components/FieldEditor/FieldEditor.css b/packages/root-cms/ui/components/FieldEditor/FieldEditor.css new file mode 100644 index 00000000..e7f9fee2 --- /dev/null +++ b/packages/root-cms/ui/components/FieldEditor/FieldEditor.css @@ -0,0 +1,18 @@ +.FieldEditor__FieldHeader { + position: relative; + padding-right: 30px; + margin-bottom: 8px; +} + +.FieldEditor__FieldHeader__label { + font-size: 12px; + font-weight: 600; + display: flex; + gap: 8px; +} + +.FieldEditor__FieldHeader__help { + font-size: 12px; + font-weight: 400; + margin-top: 2px; +} diff --git a/packages/root-cms/ui/components/FieldEditor/FieldEditor.tsx b/packages/root-cms/ui/components/FieldEditor/FieldEditor.tsx new file mode 100644 index 00000000..51c7d129 --- /dev/null +++ b/packages/root-cms/ui/components/FieldEditor/FieldEditor.tsx @@ -0,0 +1,77 @@ +import * as schema from '../../../core/schema.js'; +import {joinClassNames} from '../../utils/classes.js'; +import {StringField} from './fields/StringField.js'; +import {FieldEditorProvider} from './hooks/useFieldEditor.js'; +import './FieldEditor.css'; +import {FieldProps} from './fields/Field.js'; + +export interface FieldEditorProps { + className?: string; + schema: schema.Schema; + onChange?: (value: ValueType) => void; + value?: ValueType; +} + +/** + * Component that renders an editor for fields from a schema.ts object. + */ +export function FieldEditor(props: FieldEditorProps) { + const fields = (props.schema.fields || []).filter((field) => !!field.id); + return ( + +
+ {fields.map((field) => ( + + ))} +
+
+ ); +} + +function Field(props: FieldProps) { + const field = props.field; + if (!field.id) { + console.warn('missing id for field:'); + console.warn(field); + return null; + } + + const showFieldHeader = !props.hideHeader; + + return ( +
+ {showFieldHeader && ( + + )} +
+ {field.type === 'string' ? ( + + ) : ( +
+ Unknown field type: {field.type} +
+ )} +
+
+ ); +} + +function FieldHeader(props: FieldProps & {className?: string}) { + const field = props.field; + return ( +
+ {field.deprecated ? ( +
+ DEPRECATED: {field.label} +
+ ) : ( +
+ {field.label} +
+ )} + {field.help && ( +
{field.help}
+ )} +
+ ); +} diff --git a/packages/root-cms/ui/components/FieldEditor/fields/Field.tsx b/packages/root-cms/ui/components/FieldEditor/fields/Field.tsx new file mode 100644 index 00000000..234735f2 --- /dev/null +++ b/packages/root-cms/ui/components/FieldEditor/fields/Field.tsx @@ -0,0 +1,7 @@ +import * as schema from '../../../../core/schema.js'; + +export interface FieldProps { + deepKey: string; + field: schema.Field; + hideHeader?: boolean; +} diff --git a/packages/root-cms/ui/components/FieldEditor/fields/StringField.css b/packages/root-cms/ui/components/FieldEditor/fields/StringField.css new file mode 100644 index 00000000..e69de29b diff --git a/packages/root-cms/ui/components/FieldEditor/fields/StringField.tsx b/packages/root-cms/ui/components/FieldEditor/fields/StringField.tsx new file mode 100644 index 00000000..98a7612b --- /dev/null +++ b/packages/root-cms/ui/components/FieldEditor/fields/StringField.tsx @@ -0,0 +1,53 @@ +import {TextInput, Textarea} from '@mantine/core'; +import {ChangeEvent} from 'preact/compat'; +import {useEffect, useState} from 'preact/hooks'; +import * as schema from '../../../../core/schema.js'; +import {FieldProps} from './Field.js'; +import {useFieldEditor} from '../hooks/useFieldEditor.js'; + +export function StringField(props: FieldProps) { + const field = props.field as schema.StringField; + const [value, setValue] = useState(''); + const fieldEditor = useFieldEditor(); + + function onChange(newValue: string) { + setValue(newValue); + fieldEditor.set(props.deepKey, newValue); + } + + useEffect(() => { + const unsubscribe = fieldEditor.subscribe( + props.deepKey, + (newValue: string) => { + setValue(newValue); + } + ); + return unsubscribe; + }, []); + + if (field.variant === 'textarea') { + return ( +