Skip to content

Commit 358b45b

Browse files
committed
feat: replace rte editorjs impl with lexical
1 parent b0023ba commit 358b45b

31 files changed

+3265
-554
lines changed

packages/root-cms/package.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@
6767
"@genkit-ai/ai": "0.5.2",
6868
"@genkit-ai/core": "0.5.2",
6969
"@genkit-ai/vertexai": "0.5.2",
70+
"@lexical/code": "0.31.2",
71+
"@lexical/html": "0.31.2",
72+
"@lexical/link": "0.31.2",
73+
"@lexical/list": "0.31.2",
74+
"@lexical/markdown": "0.31.2",
75+
"@lexical/react": "0.31.2",
76+
"@lexical/rich-text": "0.31.2",
77+
"@lexical/selection": "0.31.2",
78+
"@lexical/utils": "0.31.2",
7079
"body-parser": "1.20.2",
7180
"commander": "11.0.0",
7281
"csv-parse": "5.5.2",
@@ -75,8 +84,10 @@
7584
"fnv-plus": "1.3.1",
7685
"jsonwebtoken": "9.0.2",
7786
"kleur": "4.1.5",
87+
"lexical": "0.31.2",
7888
"sirv": "2.0.3",
79-
"tiny-glob": "0.2.9"
89+
"tiny-glob": "0.2.9",
90+
"yjs": "13.6.27"
8091
},
8192
"//": "NOTE(stevenle): due to compat issues with mantine and preact, mantine is pinned to v4.2.12",
8293
"devDependencies": {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function isObject(data: any): boolean {
2+
return typeof data === 'object' && !Array.isArray(data) && data !== null;
3+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import {isObject} from './objects.js';
2+
3+
export type RichTextBlock = RichTextParagraphBlock | RichTextHeadingBlock | RichTextListBlock | RichTextImageBlock | RichTextHtmlBlock | RichTextCustomBlock;
4+
5+
export interface RichTextParagraphBlock {
6+
type: 'paragraph';
7+
data?: {
8+
text?: string;
9+
};
10+
}
11+
12+
export interface RichTextHeadingBlock {
13+
type: 'heading';
14+
data?: {
15+
level?: number;
16+
text?: string;
17+
};
18+
}
19+
20+
export interface RichTextListItem {
21+
content?: string;
22+
itemsType?: 'orderedList' | 'unorderedList';
23+
items?: RichTextListItem[];
24+
}
25+
26+
export interface RichTextListBlock {
27+
type: 'orderedList' | 'unorderedList';
28+
data?: {
29+
style?: 'ordered' | 'unordered';
30+
items?: RichTextListItem[];
31+
};
32+
}
33+
34+
export interface RichTextImageBlock {
35+
type: 'image';
36+
data?: {
37+
file?: {
38+
url: string;
39+
width: string | number;
40+
height: string | number;
41+
alt: string;
42+
};
43+
};
44+
}
45+
46+
export interface RichTextHtmlBlock {
47+
type: 'html';
48+
data?: {
49+
html?: string;
50+
};
51+
}
52+
53+
export interface RichTextCustomBlock<TypeName = string, DataType = any> {
54+
type: TypeName;
55+
data?: DataType;
56+
}
57+
58+
export interface RichTextData {
59+
[key: string]: any;
60+
blocks: RichTextBlock[];
61+
}
62+
63+
export function testValidRichTextData(data: RichTextData) {
64+
return isObject(data) && Array.isArray(data.blocks) && data.blocks.length > 0;
65+
}

packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,18 @@
11
import {useEffect, useState} from 'preact/hooks';
22
import * as schema from '../../../../core/schema.js';
3-
import {deepEqual} from '../../../utils/objects.js';
43
import {
5-
RichTextData,
64
RichTextEditor,
75
} from '../../RichTextEditor/RichTextEditor.js';
86
import {FieldProps} from './FieldProps.js';
7+
import {RichTextData} from '../../../../shared/richtext.js';
98

109
export function RichTextField(props: FieldProps) {
1110
const field = props.field as schema.RichTextField;
12-
const [value, setValue] = useState<RichTextData>({
13-
blocks: [{type: 'paragraph', data: {}}],
14-
});
11+
const [value, setValue] = useState<RichTextData | null>(null);
1512

16-
function onChange(newValue: RichTextData) {
17-
setValue((currentValue) => {
18-
if (
19-
!deepEqual({blocks: currentValue?.blocks}, {blocks: newValue?.blocks})
20-
) {
21-
props.draft.updateKey(props.deepKey, newValue);
22-
}
23-
return newValue;
24-
});
13+
function onChange(newValue: RichTextData | null) {
14+
props.draft.updateKey(props.deepKey, newValue);
15+
setValue(newValue);
2516
}
2617

2718
useEffect(() => {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
.LexicalEditor__root {
2+
position: relative;
3+
}
4+
5+
.LexicalEditor__toolbar {
6+
position: relative;
7+
display: flex;
8+
gap: 4px;
9+
border: 1px solid #ced4da;
10+
border-bottom: none;
11+
padding: 6px 10px;
12+
overflow: hidden;
13+
}
14+
15+
.LexicalEditor__toolbar__dropdown {
16+
height: 28px;
17+
font-size: 12px;
18+
letter-spacing: 0.5px;
19+
}
20+
21+
.LexicalEditor__toolbar__group {
22+
display: flex;
23+
}
24+
25+
.LexicalEditor__toolbar__group .LexicalEditor__toolbar__actionIcon button {
26+
border-radius: 0;
27+
}
28+
29+
.LexicalEditor__toolbar__group .LexicalEditor__toolbar__actionIcon + .LexicalEditor__toolbar__actionIcon button {
30+
border-left: none;
31+
}
32+
33+
.LexicalEditor__toolbar__group .LexicalEditor__toolbar__actionIcon:first-of-type button {
34+
border-top-left-radius: 4px;
35+
border-bottom-left-radius: 4px;
36+
}
37+
38+
.LexicalEditor__toolbar__group .LexicalEditor__toolbar__actionIcon:last-of-type button {
39+
border-top-right-radius: 4px;
40+
border-bottom-right-radius: 4px;
41+
}
42+
43+
.LexicalEditor__toolbar__actionIcon--active button {
44+
background-color: rgb(231, 245, 255);
45+
color: rgb(28, 126, 214);
46+
}
47+
48+
.LexicalEditor__editor {
49+
border: 1px solid #ced4da;
50+
background: #fff;
51+
padding: 10px 10px;
52+
padding-bottom: 40px;
53+
min-height: 180px;
54+
position: relative;
55+
font-family: var(--font-family-default);
56+
font-size: 12px;
57+
max-height: 380px;
58+
overflow: auto;
59+
}
60+
61+
.LexicalEditor__editor > *:first-child {
62+
margin-top: 0;
63+
}
64+
65+
.LexicalEditor__editor:focus {
66+
outline: none;
67+
border-color: rgb(51, 154, 240);
68+
}
69+
70+
.LexicalEditor__placeholder {
71+
position: absolute;
72+
top: 10px;
73+
left: 10px;
74+
color: #666;
75+
pointer-events: none;
76+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import './LexicalEditor.css';
2+
3+
import {AutoLinkNode, LinkNode} from '@lexical/link';
4+
import {ListItemNode, ListNode} from '@lexical/list';
5+
import {InitialConfigType, LexicalComposer} from '@lexical/react/LexicalComposer';
6+
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
7+
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
8+
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
9+
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
10+
import {HeadingNode} from '@lexical/rich-text';
11+
import {joinClassNames} from '../../utils/classes.js';
12+
import {SharedHistoryProvider, useSharedHistory} from './hooks/useSharedHistory.js';
13+
import {ToolbarProvider} from './hooks/useToolbar.js';
14+
import {ToolbarPlugin} from './plugins/ToolbarPlugin.js';
15+
import {useMemo, useState} from 'preact/hooks';
16+
import {ShortcutsPlugin} from './plugins/ShortcutsPlugin.js';
17+
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
18+
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
19+
import {TabIndentationPlugin} from '@lexical/react/LexicalTabIndentationPlugin';
20+
import {MarkdownTransformPlugin} from './plugins/MarkdownTransformPlugin.js';
21+
import {LexicalTheme} from './LexicalTheme.js';
22+
import {FloatingToolbarPlugin} from './plugins/FloatingToolbarPlugin.js';
23+
import {OnChangePlugin} from './plugins/OnChangePlugin.js';
24+
import {RichTextData} from '../../../shared/richtext.js';
25+
26+
const INITIAL_CONFIG: InitialConfigType = {
27+
namespace: 'RootCMS',
28+
theme: LexicalTheme,
29+
nodes: [
30+
AutoLinkNode,
31+
HeadingNode,
32+
// ImageNode,
33+
LinkNode,
34+
ListNode,
35+
ListItemNode,
36+
// YouTubeNode,
37+
],
38+
onError: (err: Error) => {
39+
console.error('[LexicalEditor] error:', err);
40+
throw err;
41+
},
42+
};
43+
44+
export interface LexicalEditorProps {
45+
className?: string;
46+
placeholder?: string;
47+
value?: RichTextData | null;
48+
onChange?: (value: RichTextData | null) => void;
49+
}
50+
51+
export function LexicalEditor(props: LexicalEditorProps) {
52+
// This component sets up the context providers and shell for lexical, and
53+
// then renders the <Editor> component which can use the shared context states
54+
// to render the rich text editor.
55+
return (
56+
<LexicalComposer initialConfig={INITIAL_CONFIG}>
57+
<SharedHistoryProvider>
58+
<ToolbarProvider>
59+
<div className={joinClassNames(props.className, 'LexicalEditor')}>
60+
<Editor
61+
placeholder={props.placeholder}
62+
value={props.value}
63+
onChange={props.onChange}
64+
/>
65+
</div>
66+
</ToolbarProvider>
67+
</SharedHistoryProvider>
68+
</LexicalComposer>
69+
);
70+
}
71+
72+
interface EditorProps {
73+
placeholder?: string;
74+
value?: RichTextData;
75+
onChange?: (value: RichTextData | null) => void;
76+
}
77+
78+
function Editor(props: EditorProps) {
79+
const {historyState} = useSharedHistory();
80+
const [editor] = useLexicalComposerContext();
81+
const [activeEditor, setActiveEditor] = useState(editor);
82+
const [isLinkEditMode, setIsLinkEditMode] = useState(false);
83+
const [floatingAnchorElem, setFloatingAnchorElem] =
84+
useState<HTMLElement | null>(null);
85+
86+
const onRef = (el: HTMLDivElement) => {
87+
if (el) {
88+
setFloatingAnchorElem(el);
89+
}
90+
};
91+
92+
return (
93+
<>
94+
<ToolbarPlugin
95+
editor={editor}
96+
activeEditor={activeEditor}
97+
setActiveEditor={setActiveEditor}
98+
setIsLinkEditMode={setIsLinkEditMode}
99+
/>
100+
<ShortcutsPlugin
101+
editor={activeEditor}
102+
setIsLinkEditMode={setIsLinkEditMode}
103+
/>
104+
<HistoryPlugin externalHistoryState={historyState} />
105+
<RichTextPlugin
106+
contentEditable={
107+
<div className="LexicalEditor__scroller">
108+
<div className="LexicalEditor__root" ref={onRef}>
109+
<ContentEditable
110+
className="LexicalEditor__editor"
111+
placeholder={<Placeholder placeholder={props.placeholder} />}
112+
/>
113+
</div>
114+
</div>
115+
}
116+
ErrorBoundary={LexicalErrorBoundary}
117+
/>
118+
<ListPlugin />
119+
<TabIndentationPlugin maxIndent={7} />
120+
<MarkdownTransformPlugin />
121+
<FloatingToolbarPlugin
122+
anchorElem={floatingAnchorElem!}
123+
setIsLinkEditMode={setIsLinkEditMode}
124+
/>
125+
<OnChangePlugin value={props.value} onChange={props.onChange} />
126+
</>
127+
);
128+
}
129+
130+
const PLACEHOLDERS = [
131+
'Once upon a placeholder...',
132+
'Start writing something legendary...',
133+
'Words go here. Preferably brilliant ones...',
134+
'Compose like nobody’s watching...',
135+
'Your masterpiece begins here...',
136+
'Add text that makes Hemingway jealous...',
137+
'Here lies your unwritten brilliance...',
138+
];
139+
140+
function getRandPlaceholder() {
141+
const rand = Math.floor(Math.random() * PLACEHOLDERS.length);
142+
const placeholder = PLACEHOLDERS[rand];
143+
return placeholder;
144+
}
145+
146+
interface PlaceholderProps {
147+
placeholder?: string;
148+
}
149+
150+
function Placeholder(props: PlaceholderProps) {
151+
const placeholder = useMemo(() => props.placeholder || getRandPlaceholder(), []);
152+
return (
153+
<div className="LexicalEditor__placeholder">{placeholder}</div>
154+
);
155+
}

0 commit comments

Comments
 (0)