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
13 changes: 12 additions & 1 deletion packages/root-cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
33 changes: 33 additions & 0 deletions packages/root-cms/shared/objects.ts
Original file line number Diff line number Diff line change
@@ -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;
}
66 changes: 66 additions & 0 deletions packages/root-cms/shared/richtext.ts
Original file line number Diff line number Diff line change
@@ -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<TypeName = string, DataType = any> {
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;
}
15 changes: 5 additions & 10 deletions packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx
Original file line number Diff line number Diff line change
@@ -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<RichTextData>({
blocks: [{type: 'paragraph', data: {}}],
});
const [value, setValue] = useState<RichTextData | null>(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;
Expand Down
18 changes: 18 additions & 0 deletions packages/root-cms/ui/components/FieldEditor/FieldEditor.css
Original file line number Diff line number Diff line change
@@ -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;
}
77 changes: 77 additions & 0 deletions packages/root-cms/ui/components/FieldEditor/FieldEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<ValueType = any> {
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 (
<FieldEditorProvider value={props.value} onChange={props.onChange}>
<div className={joinClassNames(props.className, 'FieldEditor')}>
{fields.map((field) => (
<Field field={field} deepKey={field.id!} />
))}
</div>
</FieldEditorProvider>
);
}

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 (
<div className="FieldEditor__Field" data-deepkey={props.deepKey}>
{showFieldHeader && (
<FieldHeader {...props} />
)}
<div className="FieldEditor__Field__input">
{field.type === 'string' ? (
<StringField {...props} />
) : (
<div className="FieldEditor__Field__unknown">
Unknown field type: {field.type}
</div>
)}
</div>
</div>
);
}

function FieldHeader(props: FieldProps & {className?: string}) {
const field = props.field;
return (
<div className={joinClassNames(props.className, 'FieldEditor__FieldHeader')}>
{field.deprecated ? (
<div className="FieldEditor__FieldHeader__label">
DEPRECATED: {field.label}
</div>
) : (
<div className="FieldEditor__FieldHeader__label">
<span>{field.label}</span>
</div>
)}
{field.help && (
<div className="FieldEditor__FieldHeader__help">{field.help}</div>
)}
</div>
);
}
7 changes: 7 additions & 0 deletions packages/root-cms/ui/components/FieldEditor/fields/Field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as schema from '../../../../core/schema.js';

export interface FieldProps {
deepKey: string;
field: schema.Field;
hideHeader?: boolean;
}
Empty file.
53 changes: 53 additions & 0 deletions packages/root-cms/ui/components/FieldEditor/fields/StringField.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Textarea
size="xs"
radius={0}
autosize
minRows={2}
maxRows={field.maxRows || 12}
value={value}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.currentTarget.value);
}}
/>
);
}
return (
<TextInput
size="xs"
radius={0}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value);
}}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {ComponentChildren, createContext} from 'preact';
import {useContext, useMemo} from 'preact/hooks';
import {setDeepKey} from '../../../../shared/objects.js';

export class FieldEditorController {
value: any;
onChange?: (newValue: any) => void;

constructor(value?: any, onChange?: (newValue: any) => void) {
this.value = value || {};
this.onChange = onChange;
}

/**
* Subscribes to changes on a deepKey.
*/
subscribe(deepKey: string, cb: (newValue: any) => void) {
}

/**
* Sets a value to a deepKey.
*/
set(deepKey: string, value: any) {
setDeepKey(this.value, deepKey, value);
if (this.onChange) {
this.onChange(this.value);
}
}
}

const CONTEXT = createContext<FieldEditorController | null>(null);

export interface FieldEditorProviderProps {
value?: any;
onChange?: (newValue: any) => void;
children?: ComponentChildren;
}

export function FieldEditorProvider(props: FieldEditorProviderProps) {
const fieldEditor = useMemo(() => {
return new FieldEditorController(props.value, props.onChange);
}, []);
return (
<CONTEXT.Provider value={fieldEditor}>
{props.children}
</CONTEXT.Provider>
);
}

export function useFieldEditor(): FieldEditorController {
const fieldEditor = useContext(CONTEXT);
if (!fieldEditor) {
throw new Error('useFieldEditor() should be used within a <FieldEditorProvider>');
}
return fieldEditor;
}
Loading