From 974bccd77c8a17de3624beeab3ab263373a65523 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Sat, 24 May 2025 07:10:37 -0700 Subject: [PATCH 1/4] feat: replace rte editorjs impl with lexical --- packages/root-cms/package.json | 13 +- packages/root-cms/shared/objects.ts | 3 + packages/root-cms/shared/richtext.ts | 65 ++ .../DocEditor/fields/RichTextField.tsx | 19 +- .../RichTextEditor/LexicalEditor.css | 76 +++ .../RichTextEditor/LexicalEditor.tsx | 155 +++++ .../RichTextEditor/LexicalTheme.css | 622 ++++++++++++++++++ .../RichTextEditor/LexicalTheme.tsx | 122 ++++ .../RichTextEditor/RichTextEditor.css | 72 -- .../RichTextEditor/RichTextEditor.tsx | 204 +----- .../RichTextEditor/hooks/useSharedHistory.tsx | 28 + .../RichTextEditor/hooks/useToolbar.tsx | 100 +++ .../plugins/AutoEmbedPlugin.tsx | 0 .../plugins/FloatingToolbarPlugin.css | 21 + .../plugins/FloatingToolbarPlugin.tsx | 431 ++++++++++++ .../RichTextEditor/plugins/ImagesPlugin.tsx | 0 .../plugins/MarkdownTransformPlugin.tsx | 16 + .../RichTextEditor/plugins/OnChangePlugin.tsx | 38 ++ .../plugins/ShortcutsPlugin.tsx | 99 +++ .../RichTextEditor/plugins/ToolbarPlugin.tsx | 619 +++++++++++++++++ .../RichTextEditor/tools/Strikethrough.ts | 93 --- .../RichTextEditor/tools/Superscript.ts | 90 --- .../RichTextEditor/tools/Underline.ts | 90 --- .../utils/convert-from-lexical.ts | 124 ++++ .../utils/convert-to-lexical.ts | 150 +++++ .../RichTextEditor/utils/selection.ts | 39 ++ .../RichTextEditor/utils/shortcuts.ts | 103 +++ .../RichTextEditor/utils/toolbar.ts | 124 ++++ .../ui/components/RichTextEditor/utils/url.ts | 27 + packages/root-cms/ui/tsconfig.json | 1 + pnpm-lock.yaml | 275 +++++++- 31 files changed, 3265 insertions(+), 554 deletions(-) create mode 100644 packages/root-cms/shared/objects.ts create mode 100644 packages/root-cms/shared/richtext.ts create mode 100644 packages/root-cms/ui/components/RichTextEditor/LexicalEditor.css create mode 100644 packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css create mode 100644 packages/root-cms/ui/components/RichTextEditor/LexicalTheme.tsx delete mode 100644 packages/root-cms/ui/components/RichTextEditor/RichTextEditor.css create mode 100644 packages/root-cms/ui/components/RichTextEditor/hooks/useSharedHistory.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/hooks/useToolbar.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/plugins/AutoEmbedPlugin.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.css create mode 100644 packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/plugins/ImagesPlugin.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/plugins/MarkdownTransformPlugin.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/plugins/ShortcutsPlugin.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/plugins/ToolbarPlugin.tsx delete mode 100644 packages/root-cms/ui/components/RichTextEditor/tools/Strikethrough.ts delete mode 100644 packages/root-cms/ui/components/RichTextEditor/tools/Superscript.ts delete mode 100644 packages/root-cms/ui/components/RichTextEditor/tools/Underline.ts create mode 100644 packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts create mode 100644 packages/root-cms/ui/components/RichTextEditor/utils/convert-to-lexical.ts create mode 100644 packages/root-cms/ui/components/RichTextEditor/utils/selection.ts create mode 100644 packages/root-cms/ui/components/RichTextEditor/utils/shortcuts.ts create mode 100644 packages/root-cms/ui/components/RichTextEditor/utils/toolbar.ts create mode 100644 packages/root-cms/ui/components/RichTextEditor/utils/url.ts 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..904e64c3 --- /dev/null +++ b/packages/root-cms/shared/objects.ts @@ -0,0 +1,3 @@ +export function isObject(data: any): boolean { + return typeof data === 'object' && !Array.isArray(data) && data !== null; +} diff --git a/packages/root-cms/shared/richtext.ts b/packages/root-cms/shared/richtext.ts new file mode 100644 index 00000000..21ea193e --- /dev/null +++ b/packages/root-cms/shared/richtext.ts @@ -0,0 +1,65 @@ +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 { + [key: string]: any; + blocks: RichTextBlock[]; +} + +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..48d30da0 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx @@ -1,27 +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}) - ) { - props.draft.updateKey(props.deepKey, newValue); - } - return newValue; - }); + function onChange(newValue: RichTextData | null) { + props.draft.updateKey(props.deepKey, newValue); + setValue(newValue); } useEffect(() => { diff --git a/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.css b/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.css new file mode 100644 index 00000000..2b7661ea --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.css @@ -0,0 +1,76 @@ +.LexicalEditor__root { + position: relative; +} + +.LexicalEditor__toolbar { + position: relative; + display: flex; + gap: 4px; + border: 1px solid #ced4da; + border-bottom: none; + padding: 6px 10px; + overflow: hidden; +} + +.LexicalEditor__toolbar__dropdown { + height: 28px; + font-size: 12px; + letter-spacing: 0.5px; +} + +.LexicalEditor__toolbar__group { + display: flex; +} + +.LexicalEditor__toolbar__group .LexicalEditor__toolbar__actionIcon button { + border-radius: 0; +} + +.LexicalEditor__toolbar__group .LexicalEditor__toolbar__actionIcon + .LexicalEditor__toolbar__actionIcon button { + border-left: none; +} + +.LexicalEditor__toolbar__group .LexicalEditor__toolbar__actionIcon:first-of-type button { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; +} + +.LexicalEditor__toolbar__group .LexicalEditor__toolbar__actionIcon:last-of-type button { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} + +.LexicalEditor__toolbar__actionIcon--active button { + background-color: rgb(231, 245, 255); + color: rgb(28, 126, 214); +} + +.LexicalEditor__editor { + border: 1px solid #ced4da; + background: #fff; + padding: 10px 10px; + padding-bottom: 40px; + min-height: 180px; + position: relative; + font-family: var(--font-family-default); + font-size: 12px; + max-height: 380px; + overflow: auto; +} + +.LexicalEditor__editor > *:first-child { + margin-top: 0; +} + +.LexicalEditor__editor:focus { + outline: none; + border-color: rgb(51, 154, 240); +} + +.LexicalEditor__placeholder { + position: absolute; + top: 10px; + left: 10px; + color: #666; + pointer-events: none; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx b/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx new file mode 100644 index 00000000..dd074a59 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx @@ -0,0 +1,155 @@ +import './LexicalEditor.css'; + +import {AutoLinkNode, LinkNode} from '@lexical/link'; +import {ListItemNode, ListNode} from '@lexical/list'; +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 {ListPlugin} from '@lexical/react/LexicalListPlugin'; +import {HeadingNode} from '@lexical/rich-text'; +import {joinClassNames} from '../../utils/classes.js'; +import {SharedHistoryProvider, useSharedHistory} from './hooks/useSharedHistory.js'; +import {ToolbarProvider} from './hooks/useToolbar.js'; +import {ToolbarPlugin} from './plugins/ToolbarPlugin.js'; +import {useMemo, useState} from 'preact/hooks'; +import {ShortcutsPlugin} from './plugins/ShortcutsPlugin.js'; +import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; +import {TabIndentationPlugin} from '@lexical/react/LexicalTabIndentationPlugin'; +import {MarkdownTransformPlugin} from './plugins/MarkdownTransformPlugin.js'; +import {LexicalTheme} from './LexicalTheme.js'; +import {FloatingToolbarPlugin} from './plugins/FloatingToolbarPlugin.js'; +import {OnChangePlugin} from './plugins/OnChangePlugin.js'; +import {RichTextData} from '../../../shared/richtext.js'; + +const INITIAL_CONFIG: InitialConfigType = { + namespace: 'RootCMS', + theme: LexicalTheme, + nodes: [ + AutoLinkNode, + HeadingNode, + // ImageNode, + LinkNode, + ListNode, + ListItemNode, + // YouTubeNode, + ], + onError: (err: Error) => { + console.error('[LexicalEditor] error:', err); + throw err; + }, +}; + +export interface LexicalEditorProps { + className?: string; + placeholder?: string; + value?: RichTextData | null; + onChange?: (value: RichTextData | null) => void; +} + +export function LexicalEditor(props: LexicalEditorProps) { + // This component sets up the context providers and shell for lexical, and + // then renders the component which can use the shared context states + // to render the rich text editor. + return ( + + + +
+ +
+
+
+
+ ); +} + +interface EditorProps { + placeholder?: string; + value?: RichTextData; + onChange?: (value: RichTextData | null) => void; +} + +function Editor(props: EditorProps) { + const {historyState} = useSharedHistory(); + const [editor] = useLexicalComposerContext(); + const [activeEditor, setActiveEditor] = useState(editor); + const [isLinkEditMode, setIsLinkEditMode] = useState(false); + const [floatingAnchorElem, setFloatingAnchorElem] = + useState(null); + + const onRef = (el: HTMLDivElement) => { + if (el) { + setFloatingAnchorElem(el); + } + }; + + return ( + <> + + + + +
+ } + /> +
+ + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + ); +} + +const PLACEHOLDERS = [ + 'Once upon a placeholder...', + 'Start writing something legendary...', + 'Words go here. Preferably brilliant ones...', + 'Compose like nobody’s watching...', + 'Your masterpiece begins here...', + 'Add text that makes Hemingway jealous...', + 'Here lies your unwritten brilliance...', +]; + +function getRandPlaceholder() { + const rand = Math.floor(Math.random() * PLACEHOLDERS.length); + const placeholder = PLACEHOLDERS[rand]; + return placeholder; +} + +interface PlaceholderProps { + placeholder?: string; +} + +function Placeholder(props: PlaceholderProps) { + const placeholder = useMemo(() => props.placeholder || getRandPlaceholder(), []); + return ( +
{placeholder}
+ ); +} diff --git a/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css b/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css new file mode 100644 index 00000000..0c16f886 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css @@ -0,0 +1,622 @@ + +.LexicalTheme__ltr { + text-align: left; +} +.LexicalTheme__rtl { + text-align: right; +} +.LexicalTheme__paragraph { + position: relative; + margin: 0; + margin-top: 8px; +} +.LexicalTheme__paragraph:first-child { + margin-top: 0; +} +.LexicalTheme__quote { + margin: 0; + margin-left: 20px; + margin-bottom: 10px; + font-size: 15px; + color: rgb(101, 103, 107); + border-left-color: rgb(206, 208, 212); + border-left-width: 4px; + border-left-style: solid; + padding-left: 16px; +} +.LexicalTheme__h1 { + font-size: 20px; + font-weight: 600; + margin: 0; +} +.LexicalTheme__h2 { + font-size: 16px; + font-weight: 600; + margin: 0; +} +.LexicalTheme__h3 { + font-size: 14px; + font-weight: 600; + margin: 0; +} +.LexicalTheme__h1, +.LexicalTheme__h2, +.LexicalTheme__h3 { + margin-top: 8px; +} +.LexicalTheme__h1:first-child, +.LexicalTheme__h2:first-child, +.LexicalTheme__h3:first-child { + margin-top: 0; +} +.LexicalTheme__indent { + /* --lexical-indent-base-value: 40px; */ + padding-inline-start: 0 !important; +} +.LexicalTheme__textBold { + font-weight: bold; +} +.LexicalTheme__paragraph mark { + background-color: unset; +} +.LexicalTheme__textHighlight { + background: rgba(255, 212, 0, 0.14); + border-bottom: 2px solid rgba(255, 212, 0, 0.3); +} +.LexicalTheme__textItalic { + font-style: italic; +} +.LexicalTheme__textUnderline { + text-decoration: underline; + text-underline-offset: 2px; +} + +.LexicalTheme__textStrikethrough { + text-decoration: line-through; +} + +.LexicalTheme__textUnderlineStrikethrough { + text-decoration: underline line-through; +} + +.LexicalTheme__tabNode { + position: relative; + text-decoration: none; +} + +.LexicalTheme__tabNode.LexicalTheme__textUnderline::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0.15em; + border-bottom: 0.1em solid currentColor; +} + +.LexicalTheme__tabNode.LexicalTheme__textStrikethrough::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0.69em; + border-top: 0.1em solid currentColor; +} + +.LexicalTheme__tabNode.LexicalTheme__textUnderlineStrikethrough::before, +.LexicalTheme__tabNode.LexicalTheme__textUnderlineStrikethrough::after { + content: ''; + position: absolute; + left: 0; + right: 0; +} + +.LexicalTheme__tabNode.LexicalTheme__textUnderlineStrikethrough::before { + top: 0.69em; + border-top: 0.1em solid currentColor; +} + +.LexicalTheme__tabNode.LexicalTheme__textUnderlineStrikethrough::after { + bottom: 0.05em; + border-bottom: 0.1em solid currentColor; +} + +.LexicalTheme__textSubscript { + font-size: 0.8em; + vertical-align: sub !important; +} +.LexicalTheme__textSuperscript { + font-size: 0.8em; + vertical-align: middle; +} +.LexicalTheme__textCode { + background-color: rgb(240, 242, 245); + padding: 1px 0.25rem; + font-family: Menlo, Consolas, Monaco, monospace; + font-size: 94%; +} +.LexicalTheme__textLowercase { + text-transform: lowercase; +} +.LexicalTheme__textUppercase { + text-transform: uppercase; +} +.LexicalTheme__textCapitalize { + text-transform: capitalize; +} +.LexicalTheme__hashtag { + background-color: rgba(88, 144, 255, 0.15); + border-bottom: 1px solid rgba(88, 144, 255, 0.3); +} +.LexicalTheme__link { + color: rgb(33, 111, 219); + text-decoration: none; +} +.LexicalTheme__link:hover { + text-decoration: underline; + cursor: pointer; +} +.LexicalTheme__blockCursor { + display: block; + pointer-events: none; + position: absolute; +} +.LexicalTheme__blockCursor:after { + content: ''; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid black; + animation: CursorBlink 1.1s steps(2, start) infinite; +} +@keyframes CursorBlink { + to { + visibility: hidden; + } +} +.LexicalTheme__code { + background-color: rgb(240, 242, 245); + font-family: Menlo, Consolas, Monaco, monospace; + display: block; + padding: 8px 8px 8px 52px; + line-height: 1.53; + font-size: 13px; + margin: 0; + margin-top: 8px; + margin-bottom: 8px; + overflow-x: auto; + position: relative; + tab-size: 2; +} +.LexicalTheme__code:before { + content: attr(data-gutter); + position: absolute; + background-color: #eee; + left: 0; + top: 0; + border-right: 1px solid #ccc; + padding: 8px; + color: #777; + white-space: pre-wrap; + text-align: right; + min-width: 25px; +} +.LexicalTheme__tableScrollableWrapper { + overflow-x: auto; + margin: 0px 25px 30px 0px; +} +.LexicalTheme__tableScrollableWrapper > .LexicalTheme__table { + /* Remove the table's vertical margin and put it on the wrapper */ + margin-top: 0; + margin-bottom: 0; +} +.LexicalTheme__tableAlignmentCenter { + margin-left: auto; + margin-right: auto; +} +.LexicalTheme__tableAlignmentRight { + margin-left: auto; +} +.LexicalTheme__table { + border-collapse: collapse; + border-spacing: 0; + overflow-y: scroll; + overflow-x: scroll; + table-layout: fixed; + width: fit-content; + margin-top: 25px; + margin-bottom: 30px; +} +.LexicalTheme__tableScrollableWrapper.LexicalTheme__tableFrozenRow { + /* position:sticky needs overflow:clip or visible + https://github.com/w3c/csswg-drafts/issues/865#issuecomment-350585274 */ + overflow-x: clip; +} +.LexicalTheme__tableFrozenRow tr:nth-of-type(1) > td { + overflow: clip; + background-color: #ffffff; + position: sticky; + z-index: 2; + top: 44px; +} +.LexicalTheme__tableFrozenRow tr:nth-of-type(1) > th { + overflow: clip; + background-color: #f2f3f5; + position: sticky; + z-index: 2; + top: 44px; +} +.LexicalTheme__tableFrozenRow tr:nth-of-type(1) > th:after, +.LexicalTheme__tableFrozenRow tr:nth-of-type(1) > td:after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + border-bottom: 1px solid #bbb; +} +.LexicalTheme__tableFrozenColumn tr > td:first-child { + background-color: #ffffff; + position: sticky; + z-index: 2; + left: 0; +} +.LexicalTheme__tableFrozenColumn tr > th:first-child { + background-color: #f2f3f5; + position: sticky; + z-index: 2; + left: 0; +} +.LexicalTheme__tableFrozenColumn tr > :first-child::after { + content: ''; + position: absolute; + left: 0; + top: 0; + right: 0; + height: 100%; + border-right: 1px solid #bbb; +} +.LexicalTheme__tableRowStriping tr:nth-child(even) { + background-color: #f2f5fb; +} +.LexicalTheme__tableSelection *::selection { + background-color: transparent; +} +.LexicalTheme__tableSelected { + outline: 2px solid rgb(60, 132, 244); +} +.LexicalTheme__tableCell { + border: 1px solid #bbb; + width: 75px; + vertical-align: top; + text-align: start; + padding: 6px 8px; + position: relative; + outline: none; + overflow: auto; +} +/* + A firefox workaround to allow scrolling of overflowing table cell + ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1904159 +*/ +.LexicalTheme__tableCell > * { + overflow: inherit; +} +.LexicalTheme__tableCellResizer { + position: absolute; + right: -4px; + height: 100%; + width: 8px; + cursor: ew-resize; + z-index: 10; + top: 0; +} +.LexicalTheme__tableCellHeader { + background-color: #f2f3f5; + text-align: start; +} +.LexicalTheme__tableCellSelected { + caret-color: transparent; +} +.LexicalTheme__tableCellSelected::after { + position: absolute; + left: 0; + right: 0; + bottom: 0; + top: 0; + background-color: highlight; + mix-blend-mode: multiply; + content: ''; + pointer-events: none; +} +.LexicalTheme__tableAddColumns { + position: absolute; + background-color: #eee; + height: 100%; + animation: table-controls 0.2s ease; + border: 0; + cursor: pointer; +} +.LexicalTheme__tableAddColumns:after { + /* background-image: url(../images/icons/plus.svg); */ + background-size: contain; + background-position: center; + background-repeat: no-repeat; + display: block; + content: ' '; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.4; +} +.LexicalTheme__tableAddColumns:hover, +.LexicalTheme__tableAddRows:hover { + background-color: #c9dbf0; +} +.LexicalTheme__tableAddRows { + position: absolute; + width: calc(100% - 25px); + background-color: #eee; + animation: table-controls 0.2s ease; + border: 0; + cursor: pointer; +} +.LexicalTheme__tableAddRows:after { + /* background-image: url(../images/icons/plus.svg); */ + background-size: contain; + background-position: center; + background-repeat: no-repeat; + display: block; + content: ' '; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.4; +} +@keyframes table-controls { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +.LexicalTheme__tableCellResizeRuler { + display: block; + position: absolute; + width: 1px; + background-color: rgb(60, 132, 244); + height: 100%; + top: 0; +} +.LexicalTheme__tableCellActionButtonContainer { + display: block; + right: 5px; + top: 6px; + position: absolute; + z-index: 4; + width: 20px; + height: 20px; +} +.LexicalTheme__tableCellActionButton { + background-color: #eee; + display: block; + border: 0; + border-radius: 20px; + width: 20px; + height: 20px; + color: #222; + cursor: pointer; +} +.LexicalTheme__tableCellActionButton:hover { + background-color: #ddd; +} +.LexicalTheme__characterLimit { + display: inline; + background-color: #ffbbbb !important; +} +.LexicalTheme__ol1 { + padding: 0; + margin: 0; + list-style-position: outside; +} +.LexicalTheme__ol2 { + padding: 0; + margin: 0; + list-style-type: upper-alpha; + list-style-position: outside; +} +.LexicalTheme__ol3 { + padding: 0; + margin: 0; + list-style-type: lower-alpha; + list-style-position: outside; +} +.LexicalTheme__ol4 { + padding: 0; + margin: 0; + list-style-type: upper-roman; + list-style-position: outside; +} +.LexicalTheme__ol5 { + padding: 0; + margin: 0; + list-style-type: lower-roman; + list-style-position: outside; +} +.LexicalTheme__ul { + padding: 0; + margin: 0; + list-style-position: outside; +} +.LexicalTheme__ol, +.LexicalTheme__ul { + margin-top: 8px; +} +.LexicalTheme__listItem .LexicalTheme__ol , +.LexicalTheme__listItem .LexicalTheme__ul { + margin-top: 0; +} +.LexicalTheme__listItem { + margin: 4px 20px; +} +.LexicalTheme__listItem::marker { + color: var(--listitem-marker-color); + background-color: var(--listitem-marker-background-color); + font-family: var(--listitem-marker-font-family); + font-size: var(--listitem-marker-font-size); +} +.LexicalTheme__listItemChecked, +.LexicalTheme__listItemUnchecked { + position: relative; + margin-left: 8px; + margin-right: 8px; + padding-left: 24px; + padding-right: 24px; + list-style-type: none; + outline: none; +} +.LexicalTheme__listItemChecked { + text-decoration: line-through; +} +.LexicalTheme__listItemUnchecked:before, +.LexicalTheme__listItemChecked:before { + content: ''; + width: 16px; + height: 16px; + top: 2px; + left: 0; + cursor: pointer; + display: block; + background-size: cover; + position: absolute; +} +.LexicalTheme__listItemUnchecked[dir='rtl']:before, +.LexicalTheme__listItemChecked[dir='rtl']:before { + left: auto; + right: 0; +} +.LexicalTheme__listItemUnchecked:focus:before, +.LexicalTheme__listItemChecked:focus:before { + box-shadow: 0 0 0 2px #a6cdfe; + border-radius: 2px; +} +.LexicalTheme__listItemUnchecked:before { + border: 1px solid #999; + border-radius: 2px; +} +.LexicalTheme__listItemChecked:before { + border: 1px solid rgb(61, 135, 245); + border-radius: 2px; + background-color: #3d87f5; + background-repeat: no-repeat; +} +.LexicalTheme__listItemChecked:after { + content: ''; + cursor: pointer; + border-color: #fff; + border-style: solid; + position: absolute; + display: block; + top: 6px; + width: 3px; + left: 7px; + right: 7px; + height: 6px; + transform: rotate(45deg); + border-width: 0 2px 2px 0; +} +.LexicalTheme__nestedListItem { + list-style-type: none; +} +.LexicalTheme__nestedListItem:before, +.LexicalTheme__nestedListItem:after { + display: none; +} +.LexicalTheme__tokenComment { + color: slategray; +} +.LexicalTheme__tokenPunctuation { + color: #999; +} +.LexicalTheme__tokenProperty { + color: #905; +} +.LexicalTheme__tokenSelector { + color: #690; +} +.LexicalTheme__tokenOperator { + color: #9a6e3a; +} +.LexicalTheme__tokenAttr { + color: #07a; +} +.LexicalTheme__tokenVariable { + color: #e90; +} +.LexicalTheme__tokenFunction { + color: #dd4a68; +} +.LexicalTheme__mark { + background: rgba(255, 212, 0, 0.14); + border-bottom: 2px solid rgba(255, 212, 0, 0.3); + padding-bottom: 2px; +} +.LexicalTheme__markOverlap { + background: rgba(255, 212, 0, 0.3); + border-bottom: 2px solid rgba(255, 212, 0, 0.7); +} +.LexicalTheme__mark.selected { + background: rgba(255, 212, 0, 0.5); + border-bottom: 2px solid rgba(255, 212, 0, 1); +} +.LexicalTheme__markOverlap.selected { + background: rgba(255, 212, 0, 0.7); + border-bottom: 2px solid rgba(255, 212, 0, 0.7); +} +.LexicalTheme__embedBlock { + user-select: none; +} +.LexicalTheme__embedBlockFocus { + outline: 2px solid rgb(60, 132, 244); +} +.LexicalTheme__layoutContainer { + display: grid; + gap: 10px; + margin: 10px 0; +} +.LexicalTheme__layoutItem { + border: 1px dashed #ddd; + padding: 8px 16px; + min-width: 0; + max-width: 100%; +} +.LexicalTheme__autocomplete { + color: #ccc; +} +.LexicalTheme__hr { + padding: 2px 2px; + border: none; + margin: 1em 0; + cursor: pointer; +} +.LexicalTheme__hr:after { + content: ''; + display: block; + height: 2px; + background-color: #ccc; + line-height: 2px; +} +.LexicalTheme__hr.LexicalTheme__hrSelected { + outline: 2px solid rgb(60, 132, 244); + user-select: none; +} + +.LexicalTheme__specialText { + background-color: yellow; + font-weight: bold; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.tsx b/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.tsx new file mode 100644 index 00000000..bc65c75d --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.tsx @@ -0,0 +1,122 @@ +import './LexicalTheme.css'; +import {EditorThemeClasses} from 'lexical'; + +export const LexicalTheme: EditorThemeClasses = { + autocomplete: 'LexicalTheme__autocomplete', + blockCursor: 'LexicalTheme__blockCursor', + characterLimit: 'LexicalTheme__characterLimit', + code: 'LexicalTheme__code', + codeHighlight: { + atrule: 'LexicalTheme__tokenAttr', + attr: 'LexicalTheme__tokenAttr', + boolean: 'LexicalTheme__tokenProperty', + builtin: 'LexicalTheme__tokenSelector', + cdata: 'LexicalTheme__tokenComment', + char: 'LexicalTheme__tokenSelector', + class: 'LexicalTheme__tokenFunction', + 'class-name': 'LexicalTheme__tokenFunction', + comment: 'LexicalTheme__tokenComment', + constant: 'LexicalTheme__tokenProperty', + deleted: 'LexicalTheme__tokenProperty', + doctype: 'LexicalTheme__tokenComment', + entity: 'LexicalTheme__tokenOperator', + function: 'LexicalTheme__tokenFunction', + important: 'LexicalTheme__tokenVariable', + inserted: 'LexicalTheme__tokenSelector', + keyword: 'LexicalTheme__tokenAttr', + namespace: 'LexicalTheme__tokenVariable', + number: 'LexicalTheme__tokenProperty', + operator: 'LexicalTheme__tokenOperator', + prolog: 'LexicalTheme__tokenComment', + property: 'LexicalTheme__tokenProperty', + punctuation: 'LexicalTheme__tokenPunctuation', + regex: 'LexicalTheme__tokenVariable', + selector: 'LexicalTheme__tokenSelector', + string: 'LexicalTheme__tokenSelector', + symbol: 'LexicalTheme__tokenProperty', + tag: 'LexicalTheme__tokenProperty', + url: 'LexicalTheme__tokenOperator', + variable: 'LexicalTheme__tokenVariable', + }, + embedBlock: { + base: 'LexicalTheme__embedBlock', + focus: 'LexicalTheme__embedBlockFocus', + }, + hashtag: 'LexicalTheme__hashtag', + heading: { + h1: 'LexicalTheme__h1', + h2: 'LexicalTheme__h2', + h3: 'LexicalTheme__h3', + h4: 'LexicalTheme__h4', + h5: 'LexicalTheme__h5', + h6: 'LexicalTheme__h6', + }, + hr: 'LexicalTheme__hr', + hrSelected: 'LexicalTheme__hrSelected', + image: 'editor-image', + indent: 'LexicalTheme__indent', + inlineImage: 'inline-editor-image', + layoutContainer: 'LexicalTheme__layoutContainer', + layoutItem: 'LexicalTheme__layoutItem', + link: 'LexicalTheme__link', + list: { + checklist: 'LexicalTheme__checklist', + listitem: 'LexicalTheme__listItem', + listitemChecked: 'LexicalTheme__listItemChecked', + listitemUnchecked: 'LexicalTheme__listItemUnchecked', + nested: { + listitem: 'LexicalTheme__nestedListItem', + }, + ol: 'LexicalTheme__ol', + olDepth: [ + 'LexicalTheme__ol1', + 'LexicalTheme__ol2', + 'LexicalTheme__ol3', + 'LexicalTheme__ol4', + 'LexicalTheme__ol5', + ], + ul: 'LexicalTheme__ul', + }, + ltr: 'LexicalTheme__ltr', + mark: 'LexicalTheme__mark', + markOverlap: 'LexicalTheme__markOverlap', + paragraph: 'LexicalTheme__paragraph', + quote: 'LexicalTheme__quote', + rtl: 'LexicalTheme__rtl', + specialText: 'LexicalTheme__specialText', + tab: 'LexicalTheme__tabNode', + table: 'LexicalTheme__table', + tableAddColumns: 'LexicalTheme__tableAddColumns', + tableAddRows: 'LexicalTheme__tableAddRows', + tableAlignment: { + center: 'LexicalTheme__tableAlignmentCenter', + right: 'LexicalTheme__tableAlignmentRight', + }, + tableCell: 'LexicalTheme__tableCell', + tableCellActionButton: 'LexicalTheme__tableCellActionButton', + tableCellActionButtonContainer: + 'LexicalTheme__tableCellActionButtonContainer', + tableCellHeader: 'LexicalTheme__tableCellHeader', + tableCellResizer: 'LexicalTheme__tableCellResizer', + tableCellSelected: 'LexicalTheme__tableCellSelected', + tableFrozenColumn: 'LexicalTheme__tableFrozenColumn', + tableFrozenRow: 'LexicalTheme__tableFrozenRow', + tableRowStriping: 'LexicalTheme__tableRowStriping', + tableScrollableWrapper: 'LexicalTheme__tableScrollableWrapper', + tableSelected: 'LexicalTheme__tableSelected', + tableSelection: 'LexicalTheme__tableSelection', + text: { + bold: 'LexicalTheme__textBold', + capitalize: 'LexicalTheme__textCapitalize', + code: 'LexicalTheme__textCode', + highlight: 'LexicalTheme__textHighlight', + italic: 'LexicalTheme__textItalic', + lowercase: 'LexicalTheme__textLowercase', + strikethrough: 'LexicalTheme__textStrikethrough', + subscript: 'LexicalTheme__textSubscript', + superscript: 'LexicalTheme__textSuperscript', + underline: 'LexicalTheme__textUnderline', + underlineStrikethrough: 'LexicalTheme__textUnderlineStrikethrough', + uppercase: 'LexicalTheme__textUppercase', + }, +}; diff --git a/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.css b/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.css deleted file mode 100644 index 9b88af99..00000000 --- a/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.css +++ /dev/null @@ -1,72 +0,0 @@ -.RichTextEditor { - border: 1px solid #ced4da; - background: #fff; - padding: 4px 10px; -} - -.RichTextEditor .codex-editor__redactor { - padding-bottom: 150px !important; -} - -.RichTextEditor .ce-inline-toolbar { - min-width: 200px; -} - -.RichTextEditor .ce-header { - margin-top: 20px; -} - -.RichTextEditor .ce-block:first-of-type .ce-header { - margin-top: 0; -} - -.RichTextEditor .cdx-list__item { - padding-top: 0; - padding-bottom: 0; -} - -.RichTextEditor .image-tool__image { - margin-bottom: 0; -} - -.RichTextEditor .image-tool__image-picture { - background: #f5f5f5; - border: 1px solid #dedede; - aspect-ratio: 16/9; - width: 100%; - padding: 10px; - object-fit: contain; - object-position: center; -} - -.RichTextEditor .image-tool__image::after { - content: 'Image alt text'; - display: block; - margin-top: 6px; - font-weight: 500; -} - -.RichTextEditor .image-tool__caption { - font-family: inherit; - border: 1px solid #ced4da; - padding: 8px 10px; - margin-top: 4px; -} - -.RichTextEditor .ce-rawtool::before { - content: 'WARNING: Use raw HTML with caution.'; - display: block; - color: red; - font-weight: 500; - margin-bottom: 4px; - font-size: 10px; - font-style: italic; -} - -.RichTextEditor .ce-inline-tool[data-tool="strikethrough"] svg { - padding: 3px; -} - -.RichTextEditor .ce-inline-tool[data-tool="superscript"] svg { - padding: 2px; -} diff --git a/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx b/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx index 7a91bc72..5dc9ab9b 100644 --- a/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx +++ b/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx @@ -1,199 +1,27 @@ -import EditorJS from '@editorjs/editorjs'; -import Header from '@editorjs/header'; -import ImageTool from '@editorjs/image'; -// import List from '@editorjs/list'; -import NestedList from '@editorjs/nested-list'; -import RawHtmlTool from '@editorjs/raw'; -// import Table from '@editorjs/table'; -import {useEffect, useRef, useState} from 'preact/hooks'; -import {joinClassNames} from '../../utils/classes.js'; -import './RichTextEditor.css'; -import {uploadFileToGCS} from '../../utils/gcs.js'; -import {isObject} from '../../utils/objects.js'; -import Strikethrough from './tools/Strikethrough.js'; -import Superscript from './tools/Superscript.js'; -import Underline from './tools/Underline.js'; +import {RichTextData} from '../../../shared/richtext.js'; +import {LexicalEditor} from './LexicalEditor.js'; export interface RichTextEditorProps { className?: string; placeholder?: string; - value?: any; - onChange?: (data: any) => void; + value?: RichTextData | null; + onChange?: (value: RichTextData | null) => void; } -export type RichTextData = { - [key: string]: any; - blocks: any[]; - time?: number; -}; - +/** + * Rich text editor component. + * + * This is a wrapper around lexical's rich text editor. The previous impl of + * this component used editorjs, and so this wrapper preserves backwards + * compatibility by converting between lexical's data type and editorjs's. + */ export function RichTextEditor(props: RichTextEditorProps) { - const editorRef = useRef(null); - const [editor, setEditor] = useState(null); - const [currentValue, setCurrentValue] = useState({ - blocks: [{type: 'paragraph', data: {}}], - }); - - const placeholder = props.placeholder || 'Start typing...'; - - useEffect(() => { - if (!editor) { - return; - } - const newValue = props.value; - if (currentValue?.time !== newValue?.time) { - const currentTime = currentValue?.time || 0; - const newValueTime = newValue?.time || 0; - if (newValueTime > currentTime && validateRichTextData(newValue)) { - const blocks = newValue?.blocks || []; - if (blocks.length > 0) { - editor.render(newValue); - } else { - editor.render({ - ...newValue, - blocks: [{type: 'paragraph', data: {text: ''}}], - }); - } - setCurrentValue(newValue); - } - } - }, [editor, props.value]); - - useEffect(() => { - const holder = editorRef.current!; - // TODO(stevenle): fix type issues. - const EditorJSClass = EditorJS as any; - const editor = new EditorJSClass({ - holder: holder, - placeholder: placeholder, - inlineToolbar: [ - 'bold', - 'italic', - 'underline', - 'strikethrough', - 'superscript', - 'link', - ], - tools: { - heading: { - class: Header, - config: { - placeholder: 'Enter a header', - levels: [2, 3, 4, 5], - defaultLevel: 2, - }, - }, - strikethrough: { - class: Strikethrough, - }, - superscript: { - class: Superscript, - }, - underline: { - class: Underline, - }, - image: { - class: ImageTool, - config: { - uploader: gcsUploader(), - captionPlaceholder: 'Alt text', - }, - }, - unorderedList: { - class: NestedList, - inlineToolbar: true, - config: { - defaultStyle: 'unordered', - }, - toolbox: { - name: 'unorderedList', - title: 'Bulleted List', - icon: '', - }, - }, - orderedList: { - class: NestedList, - inlineToolbar: true, - config: { - defaultStyle: 'ordered', - }, - toolbox: { - name: 'orderedList', - title: 'Numbered List', - icon: '', - }, - }, - html: { - class: RawHtmlTool, - toolbox: { - name: 'HTML', - }, - }, - // TODO(stevenle): issue with Table because firestore doesn't support - // nested arrays. - // table: Table, - }, - onReady: () => { - setEditor(editor); - }, - onChange: () => { - editor - .save() - .then((richTextData: RichTextData) => { - setCurrentValue(richTextData); - if (props.onChange) { - props.onChange(richTextData); - } - }) - .catch((err: any) => { - console.error('richtext error: ', err); - }); - }, - }); - return () => { - // Ensure `.destroy()` exists. - // https://github.com/blinkk/rootjs/issues/525 - if (editor && typeof editor.destroy === 'function') { - editor.destroy(); - } - }; - }, []); - return ( -
); } - -export function validateRichTextData(data: RichTextData) { - return isObject(data) && Array.isArray(data.blocks) && data.blocks.length > 0; -} - -function gcsUploader() { - return { - uploadByFile: async (file: File) => { - try { - const imageMeta = await uploadFileToGCS(file); - let imageUrl = imageMeta.src; - if (isGciUrl(imageUrl)) { - imageUrl = `${imageUrl}=s0-e365`; - } - console.log(imageMeta); - return {success: 1, file: {...imageMeta, url: imageUrl}}; - } catch (err) { - console.error(err); - return {success: 0, error: err}; - } - }, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - uploadByUrl: async (url: string) => { - return {success: 0, error: 'upload by url not currently supported'}; - }, - }; -} - -function isGciUrl(url: string) { - return url.startsWith('https://lh3.googleusercontent.com/'); -} diff --git a/packages/root-cms/ui/components/RichTextEditor/hooks/useSharedHistory.tsx b/packages/root-cms/ui/components/RichTextEditor/hooks/useSharedHistory.tsx new file mode 100644 index 00000000..0435c6b2 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/hooks/useSharedHistory.tsx @@ -0,0 +1,28 @@ +import {HistoryState, createEmptyHistoryState} from '@lexical/react/LexicalHistoryPlugin'; +import {ComponentChildren, createContext} from 'preact'; +import {useContext, useMemo} from 'preact/hooks'; + +type SharedHistory = { + historyState?: HistoryState; +}; + +const Context: React.Context = createContext({}); + +interface SharedHistoryProviderProps { + children?: ComponentChildren; +} + +export function SharedHistoryProvider(props: SharedHistoryProviderProps) { + const sharedHistory = useMemo(() => { + return {historyState: createEmptyHistoryState()}; + }, []) + return ( + + {props.children} + + ); +} + +export function useSharedHistory(): SharedHistory { + return useContext(Context); +} diff --git a/packages/root-cms/ui/components/RichTextEditor/hooks/useToolbar.tsx b/packages/root-cms/ui/components/RichTextEditor/hooks/useToolbar.tsx new file mode 100644 index 00000000..f483ed39 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/hooks/useToolbar.tsx @@ -0,0 +1,100 @@ +import {ElementFormatType} from 'lexical'; +import {ComponentChildren, createContext} from 'preact'; +import { + useCallback, + useContext, + useMemo, + useState, +} from 'preact/hooks'; + +const ROOT_TYPES = { + root: 'Root', +}; + +export const TOOLBAR_BLOCK_LABELS = { + paragraph: 'Normal', + h1: 'Heading 1', + h2: 'Heading 2', + h3: 'Heading 3', + h4: 'Heading 4', + h5: 'Heading 5', + h6: 'Heading 6', + bullet: 'Bulleted List', + number: 'Numbered List', +}; + +export type ToolbarBlockType = keyof typeof TOOLBAR_BLOCK_LABELS; + +const INITIAL_TOOLBAR_STATE = { + blockType: 'paragraph' as ToolbarBlockType, + canRedo: false, + canUndo: false, + codeLanguage: '', + elementFormat: 'left' as ElementFormatType, + isBold: false, + isCode: false, + isHighlight: false, + isImageCaption: false, + isItalic: false, + isLink: false, + isRTL: false, + isStrikethrough: false, + isSubscript: false, + isSuperscript: false, + isUnderline: false, + isLowercase: false, + isUppercase: false, + isCapitalize: false, + rootType: 'root' as keyof typeof ROOT_TYPES, +}; + +type ToolbarState = typeof INITIAL_TOOLBAR_STATE; +type ToolbarStateKey = keyof ToolbarState; +type ToolbarStateValue = ToolbarState[Key]; + +interface ContextShape { + toolbarState: ToolbarState; + updateToolbarState( + key: Key, + value: ToolbarStateValue, + ): void; +} + +const Context = createContext(undefined); + +interface ToolbarProviderProps { + children?: ComponentChildren; +} + +export function ToolbarProvider(props: ToolbarProviderProps) { + const [toolbarState, setToolbarState] = useState(INITIAL_TOOLBAR_STATE); + + const updateToolbarState = useCallback( + (key: Key, value: ToolbarStateValue) => { + setToolbarState((prev) => ({ + ...prev, + [key]: value, + })); + }, + [], + ); + + const contextValue = useMemo(() => { + return { + toolbarState, + updateToolbarState, + }; + }, [toolbarState, updateToolbarState]); + + return ( + {props.children} + ); +}; + +export function useToolbar() { + const contextValue = useContext(Context); + if (contextValue === undefined) { + throw new Error('useToolbar must be used within a ToolbarProvider'); + } + return contextValue; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/AutoEmbedPlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/AutoEmbedPlugin.tsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.css b/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.css new file mode 100644 index 00000000..92f811ed --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.css @@ -0,0 +1,21 @@ +.LexicalEditor__floatingToolbar { + display: flex; + gap: 2px; + background: #fff; + padding: 4px; + vertical-align: middle; + position: absolute; + top: 0; + left: 0; + z-index: 10; + opacity: 0; + border: 1px solid rgb(233, 236, 239); + box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 3px, rgba(0, 0, 0, 0.05) 0px 20px 25px -5px, rgba(0, 0, 0, 0.04) 0px 10px 10px -5px; + border-radius: 8px; + transition: opacity 0.5s; + will-change: transform; +} + +.LexicalEditor__floatingToolbar .LexicalEditor__toolbar__actionIcon--active button { + border: 1px solid rgb(206, 212, 218) +} diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.tsx new file mode 100644 index 00000000..e7e02814 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.tsx @@ -0,0 +1,431 @@ +import './FloatingToolbarPlugin.css'; + +import {$isCodeHighlightNode} from '@lexical/code'; +import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {mergeRegister} from '@lexical/utils'; +import { + $getSelection, + $isParagraphNode, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_LOW, + FORMAT_TEXT_COMMAND, + getDOMSelection, + LexicalEditor, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import {Dispatch, useCallback, useEffect, useRef, useState} from 'preact/hooks'; +import {createPortal} from 'preact/compat'; +import {getDOMRangeRect, getSelectedNode} from '../utils/selection.js'; +import {ToolbarActionIcon} from './ToolbarPlugin.js'; +import {SHORTCUTS} from '../utils/shortcuts.js'; +import {IconBold, IconItalic, IconLink, IconStrikethrough, IconSuperscript, IconUnderline} from '@tabler/icons-preact'; + +const VERTICAL_GAP = 12; +const HORIZONTAL_OFFSET = 5; + +interface FloatingToolbarProps { + editor: LexicalEditor; + anchorElem: HTMLElement; + isBold: boolean; + isItalic: boolean; + isLink: boolean; + isStrikethrough: boolean; + isSuperscript: boolean; + isUnderline: boolean; + setIsLinkEditMode: Dispatch; +} + +function FloatingToolbar(props: FloatingToolbarProps) { + const { + editor, + anchorElem, + isLink, + isBold, + isItalic, + isUnderline, + isStrikethrough, + isSuperscript, + setIsLinkEditMode, + } = props; + const popupCharStylesEditorRef = useRef(null); + + const insertLink = useCallback(() => { + if (!isLink) { + setIsLinkEditMode(true); + editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://'); + } else { + setIsLinkEditMode(false); + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + } + }, [editor, isLink, setIsLinkEditMode]); + + function mouseMoveListener(e: MouseEvent) { + if ( + popupCharStylesEditorRef?.current && + (e.buttons === 1 || e.buttons === 3) + ) { + if (popupCharStylesEditorRef.current.style.pointerEvents !== 'none') { + const x = e.clientX; + const y = e.clientY; + const elementUnderMouse = document.elementFromPoint(x, y); + + if (!popupCharStylesEditorRef.current.contains(elementUnderMouse)) { + // Mouse is not over the target element => not a normal click, but probably a drag + popupCharStylesEditorRef.current.style.pointerEvents = 'none'; + } + } + } + } + + function mouseUpListener(e: MouseEvent) { + if (popupCharStylesEditorRef?.current) { + if (popupCharStylesEditorRef.current.style.pointerEvents !== 'auto') { + popupCharStylesEditorRef.current.style.pointerEvents = 'auto'; + } + } + } + + useEffect(() => { + if (popupCharStylesEditorRef?.current) { + document.addEventListener('mousemove', mouseMoveListener); + document.addEventListener('mouseup', mouseUpListener); + + return () => { + document.removeEventListener('mousemove', mouseMoveListener); + document.removeEventListener('mouseup', mouseUpListener); + }; + } + }, [popupCharStylesEditorRef]); + + const $updateTextFormatFloatingToolbar = useCallback(() => { + const selection = $getSelection(); + + const popupCharStylesEditorElem = popupCharStylesEditorRef.current; + const nativeSelection = getDOMSelection(editor._window); + + if (popupCharStylesEditorElem === null) { + return; + } + + const rootElement = editor.getRootElement(); + if ( + selection !== null && + nativeSelection !== null && + !nativeSelection.isCollapsed && + rootElement !== null && + rootElement.contains(nativeSelection.anchorNode) + ) { + const rangeRect = getDOMRangeRect(nativeSelection, rootElement); + + setFloatingElemPosition( + rangeRect, + popupCharStylesEditorElem, + anchorElem, + isLink, + ); + } + }, [editor, anchorElem, isLink]); + + useEffect(() => { + const scrollerElem = anchorElem.parentElement; + + const update = () => { + editor.getEditorState().read(() => { + $updateTextFormatFloatingToolbar(); + }); + }; + + window.addEventListener('resize', update); + if (scrollerElem) { + scrollerElem.addEventListener('scroll', update); + } + + return () => { + window.removeEventListener('resize', update); + if (scrollerElem) { + scrollerElem.removeEventListener('scroll', update); + } + }; + }, [editor, $updateTextFormatFloatingToolbar, anchorElem]); + + useEffect(() => { + editor.getEditorState().read(() => { + $updateTextFormatFloatingToolbar(); + }); + return mergeRegister( + editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + $updateTextFormatFloatingToolbar(); + }); + }), + + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateTextFormatFloatingToolbar(); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor, $updateTextFormatFloatingToolbar]); + + return ( +
+ {editor.isEditable() && ( + <> + { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'); + }} + > + + + { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'); + }} + > + + + { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline'); + }} + > + + + { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); + }} + > + + + { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript'); + }} + > + + + + + + + )} +
+ ); +} + +function useFloatingTextFormatToolbar( + editor: LexicalEditor, + anchorElem: HTMLElement, + setIsLinkEditMode: Dispatch, +) { + const [isText, setIsText] = useState(false); + const [isLink, setIsLink] = useState(false); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isSuperscript, setIsSuperscript] = useState(false); + + const updatePopup = useCallback(() => { + editor.getEditorState().read(() => { + // Should not to pop up the floating toolbar when using IME input. + if (editor.isComposing()) { + return; + } + const selection = $getSelection(); + const nativeSelection = getDOMSelection(editor._window); + const rootElement = editor.getRootElement(); + + if ( + nativeSelection !== null && + (!$isRangeSelection(selection) || + rootElement === null || + !rootElement.contains(nativeSelection.anchorNode)) + ) { + setIsText(false); + return; + } + + if (!$isRangeSelection(selection)) { + return; + } + + const node = getSelectedNode(selection); + + // Update text format + setIsBold(selection.hasFormat('bold')); + setIsItalic(selection.hasFormat('italic')); + setIsUnderline(selection.hasFormat('underline')); + setIsStrikethrough(selection.hasFormat('strikethrough')); + setIsSuperscript(selection.hasFormat('superscript')); + + // Update links + const parent = node.getParent(); + if ($isLinkNode(parent) || $isLinkNode(node)) { + setIsLink(true); + } else { + setIsLink(false); + } + + if ( + !$isCodeHighlightNode(selection.anchor.getNode()) && + selection.getTextContent() !== '' + ) { + setIsText($isTextNode(node) || $isParagraphNode(node)); + } else { + setIsText(false); + } + + const rawTextContent = selection.getTextContent().replace(/\n/g, ''); + if (!selection.isCollapsed() && rawTextContent === '') { + setIsText(false); + return; + } + }); + }, [editor]); + + useEffect(() => { + document.addEventListener('selectionchange', updatePopup); + return () => { + document.removeEventListener('selectionchange', updatePopup); + }; + }, [updatePopup]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(() => { + updatePopup(); + }), + editor.registerRootListener(() => { + if (editor.getRootElement() === null) { + setIsText(false); + } + }), + ); + }, [editor, updatePopup]); + + if (!isText) { + return null; + } + + return createPortal( + , + anchorElem, + ); +} + +interface FloatingToolbarPluginProps { + anchorElem?: HTMLElement; + setIsLinkEditMode: Dispatch; +} + +export function FloatingToolbarPlugin(props: FloatingToolbarPluginProps) { + const { + anchorElem = document.body, + setIsLinkEditMode, + } = props; + const [editor] = useLexicalComposerContext(); + return useFloatingTextFormatToolbar(editor, anchorElem, setIsLinkEditMode); +} + +export function setFloatingElemPosition( + targetRect: DOMRect | null, + floatingElem: HTMLElement, + anchorElem: HTMLElement, + isLink: boolean = false, + verticalGap: number = VERTICAL_GAP, + horizontalOffset: number = HORIZONTAL_OFFSET, +): void { + const scrollerElem = anchorElem.parentElement; + + if (targetRect === null || !scrollerElem) { + floatingElem.style.opacity = '0'; + floatingElem.style.transform = 'translate(-10000px, -10000px)'; + return; + } + + const floatingElemRect = floatingElem.getBoundingClientRect(); + const anchorElementRect = anchorElem.getBoundingClientRect(); + const editorScrollerRect = scrollerElem.getBoundingClientRect(); + + let top = targetRect.top - floatingElemRect.height - verticalGap; + let left = targetRect.left - horizontalOffset; + + // Check if text is end-aligned + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const textNode = range.startContainer; + if (textNode.nodeType === Node.ELEMENT_NODE || textNode.parentElement) { + const textElement = + textNode.nodeType === Node.ELEMENT_NODE + ? (textNode as Element) + : (textNode.parentElement as Element); + const textAlign = window.getComputedStyle(textElement).textAlign; + + if (textAlign === 'right' || textAlign === 'end') { + // For end-aligned text, position the toolbar relative to the text end + left = targetRect.right - floatingElemRect.width + horizontalOffset; + } + } + } + + if (top < editorScrollerRect.top) { + // adjusted height for link element if the element is at top + top += + floatingElemRect.height + + targetRect.height + + verticalGap * (isLink ? 9 : 2); + } + + if (left + floatingElemRect.width > editorScrollerRect.right) { + left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset; + } + + if (left < editorScrollerRect.left) { + left = editorScrollerRect.left + horizontalOffset; + } + + top -= anchorElementRect.top; + left -= anchorElementRect.left; + + floatingElem.style.opacity = '1'; + floatingElem.style.transform = `translate(${left}px, ${top}px)`; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/ImagesPlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/ImagesPlugin.tsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/MarkdownTransformPlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/MarkdownTransformPlugin.tsx new file mode 100644 index 00000000..96aca930 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/MarkdownTransformPlugin.tsx @@ -0,0 +1,16 @@ +import { + HEADING, + UNORDERED_LIST, + ORDERED_LIST, +} from '@lexical/markdown'; +import {MarkdownShortcutPlugin} from '@lexical/react/LexicalMarkdownShortcutPlugin'; + +export function MarkdownTransformPlugin() { + return ( + + ); +} diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx new file mode 100644 index 00000000..96b629c1 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx @@ -0,0 +1,38 @@ +import {useEffect} from 'preact/hooks'; +import {RichTextData} from '../../../../shared/richtext.js'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {convertToRichTextData} from '../utils/convert-from-lexical.js'; +import {convertToLexical} from '../utils/convert-to-lexical.js'; + +export interface OnChangePluginProps { + value?: RichTextData | null; + onChange?: (data: RichTextData | null) => void; +} + +export function OnChangePlugin(props: OnChangePluginProps) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + console.log('rte value change upstream:', props.value); + editor.update(() => { + convertToLexical(props.value); + }); + }, [editor, props.value]); + + useEffect(() => { + return editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + const data = toRichTextData(); + if (props.onChange) { + props.onChange(data); + } + }); + }); + }, [editor, props.onChange]); + + return null; +} + +function toRichTextData(): RichTextData | null { + return convertToRichTextData(); +} diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/ShortcutsPlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/ShortcutsPlugin.tsx new file mode 100644 index 00000000..d0d4d413 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/ShortcutsPlugin.tsx @@ -0,0 +1,99 @@ +import {TOGGLE_LINK_COMMAND} from '@lexical/link'; +import {HeadingTagType} from '@lexical/rich-text'; +import { + COMMAND_PRIORITY_NORMAL, + FORMAT_TEXT_COMMAND, + INDENT_CONTENT_COMMAND, + isModifierMatch, + KEY_DOWN_COMMAND, + LexicalEditor, + OUTDENT_CONTENT_COMMAND, +} from 'lexical'; +import {Dispatch, useEffect} from 'preact/hooks'; + +import {useToolbar} from '../hooks/useToolbar.js'; +import {sanitizeUrl} from '../utils/url.js'; +import { + clearFormatting, + formatBulletList, + formatHeading, + formatNumberedList, + formatParagraph, +} from '../utils/toolbar.js'; +import { + isClearFormatting, + isFormatBulletList, + isFormatHeading, + isFormatNumberedList, + isFormatParagraph, + isIndent, + isInsertLink, + isOutdent, + isStrikeThrough, + isSubscript, + isSuperscript, +} from '../utils/shortcuts.js'; + +export interface ShortcutsPluginProps { + editor: LexicalEditor; + setIsLinkEditMode: Dispatch; +} + +export function ShortcutsPlugin(props: ShortcutsPluginProps): null { + const {editor, setIsLinkEditMode} = props; + const {toolbarState} = useToolbar(); + + useEffect(() => { + const keyboardShortcutsHandler = (event: KeyboardEvent) => { + // At least one modifier must be set. + if (isModifierMatch(event, {})) { + return false; + } + if (isFormatParagraph(event)) { + formatParagraph(editor); + } else if (isFormatHeading(event)) { + const {code} = event; + const headingSize = `h${code[code.length - 1]}` as HeadingTagType; + formatHeading(editor, toolbarState.blockType, headingSize); + } else if (isFormatBulletList(event)) { + formatBulletList(editor, toolbarState.blockType); + } else if (isFormatNumberedList(event)) { + formatNumberedList(editor, toolbarState.blockType); + } else if (isStrikeThrough(event)) { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); + } else if (isIndent(event)) { + editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined); + } else if (isOutdent(event)) { + editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); + } else if (isSubscript(event)) { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript'); + } else if (isSuperscript(event)) { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript'); + } else if (isClearFormatting(event)) { + clearFormatting(editor); + } else if (isInsertLink(event)) { + const url = toolbarState.isLink ? null : sanitizeUrl('https://'); + setIsLinkEditMode(!toolbarState.isLink); + editor.dispatchCommand(TOGGLE_LINK_COMMAND, url); + } else { + // No match for any of the event handlers. + return false; + } + event.preventDefault(); + return true; + }; + + return editor.registerCommand( + KEY_DOWN_COMMAND, + keyboardShortcutsHandler, + COMMAND_PRIORITY_NORMAL, + ); + }, [ + editor, + toolbarState.isLink, + toolbarState.blockType, + setIsLinkEditMode, + ]); + + return null; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/ToolbarPlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/ToolbarPlugin.tsx new file mode 100644 index 00000000..e3194760 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/ToolbarPlugin.tsx @@ -0,0 +1,619 @@ +import {ActionIcon, ActionIconVariant, Button, Menu, Tooltip} from '@mantine/core'; +import { + IconList, + IconListNumbers, + IconH1, + IconH2, + IconH3, + IconAlignJustified as IconParagraph, + IconBold, + IconItalic, + IconUnderline, + IconLink, + IconSuperscript, + IconChevronDown, + IconPhoto, + IconBrandYoutube, + IconMovie, + IconCode, + IconStrikethrough, +} from '@tabler/icons-preact'; +import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; +import {$isListNode, ListNode} from '@lexical/list'; +import {INSERT_EMBED_COMMAND} from '@lexical/react/LexicalAutoEmbedPlugin'; +import {$isHeadingNode} from '@lexical/rich-text'; +import { + $isParentElementRTL, +} from '@lexical/selection'; +import { + $findMatchingParent, + $getNearestNodeOfType, + $isEditorIsNestedEditor, + mergeRegister, +} from '@lexical/utils'; +import { + $getSelection, + $isElementNode, + $isRangeSelection, + $isRootOrShadowRoot, + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_CRITICAL, + FORMAT_TEXT_COMMAND, + LexicalEditor, + NodeKey, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import {Dispatch, useCallback, useEffect, useState} from 'preact/compat'; + +// import useModal from '../../hooks/useModal'; +// import DropDown, {DropDownItem} from '../../ui/DropDown'; +import {getSelectedNode} from '../utils/selection.js'; +import {sanitizeUrl} from '../utils/url.js'; +// import {EmbedConfigs} from './AutoEmbedPlugin.js'; +// import { +// InsertImageDialog, +// } from './ImagesPlugin.js'; +import { + clearFormatting, + formatBulletList, + formatHeading, + formatNumberedList, + formatParagraph, +} from '../utils/toolbar.js'; +import {SHORTCUTS} from '../utils/shortcuts.js'; +import {TOOLBAR_BLOCK_LABELS, ToolbarBlockType, useToolbar} from '../hooks/useToolbar.js'; +import {joinClassNames} from '../../../utils/classes.js'; +import {ComponentChildren} from 'preact'; + +const rootTypeToRootName = { + root: 'Root', + table: 'Table', +}; + +function dropDownActiveClass(active: boolean) { + return active ? 'active dropdown-item-active' : ''; +} + +interface BlockTypeIconProps { + blockType: ToolbarBlockType; +} + +function BlockTypeIcon(props: BlockTypeIconProps) { + const {blockType} = props; + if (blockType === 'paragraph') { + return ; + } + if (blockType === 'h1') { + return ; + } + if (blockType === 'h2') { + return ; + } + if (blockType === 'h3') { + return ; + } + if (blockType === 'number') { + return ; + } + if (blockType === 'bullet') { + return ; + } + return null; +} + +interface BlockFormatDropDownProps { + blockType: ToolbarBlockType; + rootType: keyof typeof rootTypeToRootName; + editor: LexicalEditor; + disabled?: boolean; +} + +function BlockFormatDropDown(props: BlockFormatDropDownProps) { + const {editor, blockType, rootType, disabled = false} = props; + return ( + } + rightIcon={} + > + {TOOLBAR_BLOCK_LABELS[blockType]} + + } + > + } + className={dropDownActiveClass(blockType === 'h1')} + onClick={() => formatHeading(editor, blockType, 'h1')}> + Heading 1 + + } + className={dropDownActiveClass(blockType === 'h2')} + onClick={() => formatHeading(editor, blockType, 'h2')}> + Heading 2 + + } + className={dropDownActiveClass(blockType === 'h3')} + onClick={() => formatHeading(editor, blockType, 'h3')}> + Heading 3 + + } + className={dropDownActiveClass(blockType === 'paragraph')} + onClick={() => formatParagraph(editor)} + > + Normal + + } + className={dropDownActiveClass(blockType === 'bullet')} + onClick={() => formatBulletList(editor, blockType)}> + Bullet List + + } + className={dropDownActiveClass(blockType === 'number')} + onClick={() => formatNumberedList(editor, blockType)}> + Numbered List + + + ); +} + +function InsertDropdown() { + return ( + } + > + Embed + + } + > + }> + HTML Code + + }> + Image + + }> + Video (.mp4) + + }> + YouTube + + + ); +} + +function Divider() { + return
; +} + +interface ToolbarPluginProps { + editor: LexicalEditor; + activeEditor: LexicalEditor; + setActiveEditor: Dispatch; + setIsLinkEditMode: Dispatch; +} + +export function ToolbarPlugin(props: ToolbarPluginProps) { + const { + editor, + activeEditor, + setActiveEditor, + setIsLinkEditMode, + } = props; + const [selectedElementKey, setSelectedElementKey] = useState( + null, + ); + // const [modal, showModal] = useModal(); + const [isEditable, setIsEditable] = useState(() => editor.isEditable()); + const {toolbarState, updateToolbarState} = useToolbar(); + + const $updateToolbar = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + if (activeEditor !== editor && $isEditorIsNestedEditor(activeEditor)) { + const rootElement = activeEditor.getRootElement(); + updateToolbarState( + 'isImageCaption', + !!rootElement?.parentElement?.classList.contains( + 'image-caption-container', + ), + ); + } else { + updateToolbarState('isImageCaption', false); + } + + const anchorNode = selection.anchor.getNode(); + let element = + anchorNode.getKey() === 'root' + ? anchorNode + : $findMatchingParent(anchorNode, (e) => { + const parent = e.getParent(); + return parent !== null && $isRootOrShadowRoot(parent); + }); + + if (element === null) { + element = anchorNode.getTopLevelElementOrThrow(); + } + + const elementKey = element.getKey(); + const elementDOM = activeEditor.getElementByKey(elementKey); + + updateToolbarState('isRTL', $isParentElementRTL(selection)); + + // Update links + const node = getSelectedNode(selection); + const parent = node.getParent(); + const isLink = $isLinkNode(parent) || $isLinkNode(node); + updateToolbarState('isLink', isLink); + updateToolbarState('rootType', 'root'); + + if (elementDOM !== null) { + setSelectedElementKey(elementKey); + if ($isListNode(element)) { + const parentList = $getNearestNodeOfType( + anchorNode, + ListNode, + ); + const type = parentList + ? parentList.getListType() + : element.getListType(); + + updateToolbarState('blockType', type as ToolbarBlockType); + } else { + const type = $isHeadingNode(element) + ? element.getTag() + : element.getType(); + if (type in TOOLBAR_BLOCK_LABELS) { + updateToolbarState( + 'blockType', + type as ToolbarBlockType, + ); + } + } + } + // Handle buttons + let matchingParent; + if ($isLinkNode(parent)) { + // If node is a link, we need to fetch the parent paragraph node to set format + matchingParent = $findMatchingParent( + node, + (parentNode) => $isElementNode(parentNode) && !parentNode.isInline(), + ); + } + + // If matchingParent is a valid node, pass it's format type + updateToolbarState( + 'elementFormat', + $isElementNode(matchingParent) + ? matchingParent.getFormatType() + : $isElementNode(node) + ? node.getFormatType() + : parent?.getFormatType() || 'left', + ); + } + if ($isRangeSelection(selection)) { + // Update text format + updateToolbarState('isBold', selection.hasFormat('bold')); + updateToolbarState('isItalic', selection.hasFormat('italic')); + updateToolbarState('isUnderline', selection.hasFormat('underline')); + updateToolbarState( + 'isStrikethrough', + selection.hasFormat('strikethrough'), + ); + updateToolbarState('isSuperscript', selection.hasFormat('superscript')); + } + }, [activeEditor, editor, updateToolbarState]); + + useEffect(() => { + return editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, newEditor) => { + setActiveEditor(newEditor); + $updateToolbar(); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor, $updateToolbar, setActiveEditor]); + + useEffect(() => { + activeEditor.getEditorState().read(() => { + $updateToolbar(); + }); + }, [activeEditor, $updateToolbar]); + + useEffect(() => { + return mergeRegister( + editor.registerEditableListener((editable) => { + setIsEditable(editable); + }), + activeEditor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + $updateToolbar(); + }); + }), + activeEditor.registerCommand( + CAN_UNDO_COMMAND, + (payload) => { + updateToolbarState('canUndo', payload); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + activeEditor.registerCommand( + CAN_REDO_COMMAND, + (payload) => { + updateToolbarState('canRedo', payload); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + ); + }, [$updateToolbar, activeEditor, editor, updateToolbarState]); + + const insertLink = useCallback(() => { + if (!toolbarState.isLink) { + setIsLinkEditMode(true); + activeEditor.dispatchCommand( + TOGGLE_LINK_COMMAND, + sanitizeUrl('https://'), + ); + } else { + setIsLinkEditMode(false); + activeEditor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + } + }, [activeEditor, setIsLinkEditMode, toolbarState.isLink]); + + const canViewerSeeInsertDropdown = !toolbarState.isImageCaption; + + return ( +
+ {toolbarState.blockType in TOOLBAR_BLOCK_LABELS && + activeEditor === editor && ( + <> + + + + )} + <> +
+ { + activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'); + }} + > + + + { + activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'); + }} + > + + + { + activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline'); + }} + > + + + { + activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); + }} + > + + + { + activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript'); + }} + > + + + + + +
+ + + + + + {/* */} + {/* */} + {/* */} + {/* + { + activeEditor.dispatchCommand( + FORMAT_TEXT_COMMAND, + 'strikethrough', + ); + }} + className={ + 'item wide ' + dropDownActiveClass(toolbarState.isStrikethrough) + } + title="Strikethrough" + aria-label="Format text with a strikethrough"> +
+ + Strikethrough +
+ {SHORTCUTS.STRIKETHROUGH} +
+ { + activeEditor.dispatchCommand( + FORMAT_TEXT_COMMAND, + 'superscript', + ); + }} + className={ + 'item wide ' + dropDownActiveClass(toolbarState.isSuperscript) + } + title="Superscript" + aria-label="Format text with a superscript"> +
+ + Superscript +
+ {SHORTCUTS.SUPERSCRIPT} +
+ clearFormatting(activeEditor)} + className="item wide" + title="Clear text formatting" + aria-label="Clear all text formatting"> +
+ + Clear Formatting +
+ {SHORTCUTS.CLEAR_FORMATTING} +
+
*/} + {/* {canViewerSeeInsertDropdown && ( + <> + + + { + showModal('Insert Image', (onClose) => ( + + )); + }} + className="item"> + + Image + + {EmbedConfigs.map((embedConfig) => ( + { + activeEditor.dispatchCommand( + INSERT_EMBED_COMMAND, + embedConfig.type, + ); + }} + className="item"> + {embedConfig.icon} + {embedConfig.contentName} + + ))} + + + )} */} + + {/* {modal} */} +
+ ); +} + +export interface ToolbarActionIconProps { + variant?: ActionIconVariant; + active?: boolean; + tooltip?: string; + onClick?: () => void; + children?: ComponentChildren; +} + +export function ToolbarActionIcon(props: ToolbarActionIconProps) { + return ( + + + {props.children} + + + ); +} diff --git a/packages/root-cms/ui/components/RichTextEditor/tools/Strikethrough.ts b/packages/root-cms/ui/components/RichTextEditor/tools/Strikethrough.ts deleted file mode 100644 index 6a520757..00000000 --- a/packages/root-cms/ui/components/RichTextEditor/tools/Strikethrough.ts +++ /dev/null @@ -1,93 +0,0 @@ -const TAG = 'S'; - -export class Strikethrough { - private api: any; - private button: HTMLButtonElement; - private iconClasses: Record; - - static get CSS() { - return ''; - } - - static get shortcut() { - return 'CMD+SHIFT+X'; - } - - constructor(options: any) { - this.api = options.api; - this.button = document.createElement('button'); - this.iconClasses = { - base: this.api.styles.inlineToolButton, - active: this.api.styles.inlineToolButtonActive, - }; - } - - static get isInline() { - return true; - } - - render() { - // Avoid a race condition where the component is destroyed before the - // render() function is called. - // https://github.com/blinkk/rootjs/issues/482 - if (!this.button) { - return null; - } - this.button.type = 'button'; - this.button.classList.add(this.iconClasses.base); - this.button.innerHTML = this.toolboxIcon; - return this.button; - } - - surround(range: Range) { - if (!range) { - return; - } - - const termWrapper = this.api.selection.findParentTag( - TAG, - Strikethrough.CSS - ); - - if (termWrapper) { - this.unwrap(termWrapper); - } else { - this.wrap(range); - } - } - - wrap(range: Range) { - const el = document.createElement(TAG); - el.appendChild(range.extractContents()); - range.insertNode(el); - this.api.selection.expandToTag(el); - } - - unwrap(termWrapper: HTMLElement) { - this.api.selection.expandToTag(termWrapper); - const sel = window.getSelection()!; - const range = sel.getRangeAt(0); - const unwrappedContent = range.extractContents(); - termWrapper.parentNode!.removeChild(termWrapper); - range.insertNode(unwrappedContent); - sel.removeAllRanges(); - sel.addRange(range); - } - - checkState() { - const termTag = this.api.selection.findParentTag(TAG, Strikethrough.CSS); - this.button.classList.toggle(this.iconClasses.active, !!termTag); - } - - get toolboxIcon() { - return ''; - } - - static get sanitize() { - return { - s: {}, - }; - } -} - -export default Strikethrough; diff --git a/packages/root-cms/ui/components/RichTextEditor/tools/Superscript.ts b/packages/root-cms/ui/components/RichTextEditor/tools/Superscript.ts deleted file mode 100644 index b0bc93fe..00000000 --- a/packages/root-cms/ui/components/RichTextEditor/tools/Superscript.ts +++ /dev/null @@ -1,90 +0,0 @@ -const TAG = 'SUP'; - -export class Superscript { - private api: any; - private button: HTMLButtonElement; - private iconClasses: Record; - - static get CSS() { - return ''; - } - - static get shortcut() { - return 'CMD+.'; - } - - constructor(options: any) { - this.api = options.api; - this.button = document.createElement('button'); - this.iconClasses = { - base: this.api.styles.inlineToolButton, - active: this.api.styles.inlineToolButtonActive, - }; - } - - static get isInline() { - return true; - } - - render() { - // Avoid a race condition where the component is destroyed before the - // render() function is called. - // https://github.com/blinkk/rootjs/issues/482 - if (!this.button) { - return null; - } - this.button.type = 'button'; - this.button.classList.add(this.iconClasses.base); - this.button.innerHTML = this.toolboxIcon; - return this.button; - } - - surround(range: Range) { - if (!range) { - return; - } - - const termWrapper = this.api.selection.findParentTag(TAG, Superscript.CSS); - - if (termWrapper) { - this.unwrap(termWrapper); - } else { - this.wrap(range); - } - } - - wrap(range: Range) { - const supElement = document.createElement(TAG); - supElement.appendChild(range.extractContents()); - range.insertNode(supElement); - this.api.selection.expandToTag(supElement); - } - - unwrap(termWrapper: HTMLElement) { - this.api.selection.expandToTag(termWrapper); - const sel = window.getSelection()!; - const range = sel.getRangeAt(0); - const unwrappedContent = range.extractContents(); - termWrapper.parentNode!.removeChild(termWrapper); - range.insertNode(unwrappedContent); - sel.removeAllRanges(); - sel.addRange(range); - } - - checkState() { - const termTag = this.api.selection.findParentTag(TAG, Superscript.CSS); - this.button.classList.toggle(this.iconClasses.active, !!termTag); - } - - get toolboxIcon() { - return ''; - } - - static get sanitize() { - return { - sup: {}, - }; - } -} - -export default Superscript; diff --git a/packages/root-cms/ui/components/RichTextEditor/tools/Underline.ts b/packages/root-cms/ui/components/RichTextEditor/tools/Underline.ts deleted file mode 100644 index f2daaa3a..00000000 --- a/packages/root-cms/ui/components/RichTextEditor/tools/Underline.ts +++ /dev/null @@ -1,90 +0,0 @@ -const TAG = 'U'; - -export class Underline { - private api: any; - private button: HTMLButtonElement; - private iconClasses: Record; - - static get CSS() { - return ''; - } - - static get shortcut() { - return 'CMD+U'; - } - - constructor(options: any) { - this.api = options.api; - this.button = document.createElement('button'); - this.iconClasses = { - base: this.api.styles.inlineToolButton, - active: this.api.styles.inlineToolButtonActive, - }; - } - - static get isInline() { - return true; - } - - render() { - // Avoid a race condition where the component is destroyed before the - // render() function is called. - // https://github.com/blinkk/rootjs/issues/482 - if (!this.button) { - return null; - } - this.button.type = 'button'; - this.button.classList.add(this.iconClasses.base); - this.button.innerHTML = this.toolboxIcon; - return this.button; - } - - surround(range: Range) { - if (!range) { - return; - } - - const termWrapper = this.api.selection.findParentTag(TAG, Underline.CSS); - - if (termWrapper) { - this.unwrap(termWrapper); - } else { - this.wrap(range); - } - } - - wrap(range: Range) { - const el = document.createElement(TAG); - el.appendChild(range.extractContents()); - range.insertNode(el); - this.api.selection.expandToTag(el); - } - - unwrap(termWrapper: HTMLElement) { - this.api.selection.expandToTag(termWrapper); - const sel = window.getSelection()!; - const range = sel.getRangeAt(0); - const unwrappedContent = range.extractContents(); - termWrapper.parentNode!.removeChild(termWrapper); - range.insertNode(unwrappedContent); - sel.removeAllRanges(); - sel.addRange(range); - } - - checkState() { - const termTag = this.api.selection.findParentTag(TAG, Underline.CSS); - this.button.classList.toggle(this.iconClasses.active, !!termTag); - } - - get toolboxIcon() { - return ''; - } - - static get sanitize() { - return { - u: {}, - }; - } -} - -export default Underline; diff --git a/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts b/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts new file mode 100644 index 00000000..132dcd51 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts @@ -0,0 +1,124 @@ +import {RichTextBlock, RichTextData, RichTextHeadingBlock, RichTextListBlock, RichTextListItem, RichTextParagraphBlock} from '../../../../shared/richtext.js'; +import { + $getRoot, + $isParagraphNode, + $isTextNode, + LexicalNode, + ElementNode, + $isLineBreakNode, +} from 'lexical'; +import {$isHeadingNode} from '@lexical/rich-text'; +import {$isListItemNode, $isListNode, ListItemNode, ListNode} from '@lexical/list'; + +function extractTextNode(node: ElementNode) { + const texts = node.getChildren().map(extractTextChild); + return texts.join(''); +} + +function extractTextChild(node: LexicalNode) { + if ($isLineBreakNode(node)) { + return '
'; + } + if (!$isTextNode(node)) { + return ''; + } + let text = node.getTextContent(); + if (!text) { + return ''; + } + const formatTags = { + s: node.hasFormat('strikethrough'), + u: node.hasFormat('underline'), + i: node.hasFormat('italic'), + b: node.hasFormat('bold'), + sup: node.hasFormat('superscript'), + }; + Object.entries(formatTags).forEach(([tag, enabled]) => { + if (enabled) { + text = `<${tag}>${text}`; + } + }); + return text; +} + +function extractListItems(node: ListNode): RichTextListItem[] { + const items: RichTextListItem[] = []; + node.getChildren().forEach((child) => { + if ($isListItemNode(child)) { + items.push(extractListItem(child)); + } + }); + return items; +} + +function extractListItem(node: ListItemNode): RichTextListItem { + // Handle list item with nested lists. + const firstChild = node.getFirstChild(); + if (firstChild && $isListNode(firstChild)) { + const tag = firstChild.getTag(); + return { + itemsType: tag === 'ol' ? 'orderedList' : 'unorderedList', + items: extractListItems(firstChild), + }; + } + + // Handle list item with text content. + return {content: extractTextNode(node)}; +} + +/** + * Converts from lexical to rich text data. + * NOTE: this function must be called within a `editor.read()` callback. + */ +export function convertToRichTextData(): RichTextData | null { + const blocks: RichTextBlock[] = []; + + const root = $getRoot(); + const children = root.getChildren(); + + children.forEach((node) => { + if ($isParagraphNode(node)) { + const block: RichTextParagraphBlock = { + type: 'paragraph', + data: {text: extractTextNode(node)}, + }; + blocks.push(block); + } else if ($isHeadingNode(node)) { + const level = node.getTag().slice(1); + const block: RichTextHeadingBlock = { + type: 'heading', + data: { + text: extractTextNode(node), + level: parseInt(level), + }, + }; + blocks.push(block); + } else if ($isListNode(node)) { + const tag = node.getTag(); + const block: RichTextListBlock = { + type: tag === 'ol' ? 'orderedList' : 'unorderedList', + data: { + style: tag === 'ol' ? 'ordered' : 'unordered', + items: extractListItems(node), + }, + }; + blocks.push(block); + } + }); + + // If the last block is an empty paragraph, remove it. + const lastBlock = blocks.length > 0 && blocks.at(-1); + if (lastBlock && lastBlock.type === 'paragraph' && !lastBlock.data?.text) { + blocks.pop(); + } + + if (blocks.length === 0) { + return null; + } + + return { + time: Date.now(), + blocks, + version: 'lexical-0.31.2', + }; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/utils/convert-to-lexical.ts b/packages/root-cms/ui/components/RichTextEditor/utils/convert-to-lexical.ts new file mode 100644 index 00000000..28d5327a --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/utils/convert-to-lexical.ts @@ -0,0 +1,150 @@ +import {$applyNodeReplacement, $createParagraphNode, $createTextNode, $getRoot} from 'lexical'; +import {RichTextData, RichTextListItem, RichTextListItemNestedList, RichTextListItemText} from '../../../../shared/richtext.js'; +import {$createHeadingNode} from '@lexical/rich-text'; +import {$createLinkNode} from '@lexical/link'; +import {$createListItemNode, $createListNode, ListItemNode} from '@lexical/list'; + +/** + * Converts from lexical to rich text data and writes the output directly to + * the current editor. + * NOTE: this function must be called within an `editor.update()` callback. + */ +export function convertToLexical(data?: RichTextData | null) { + const root = $getRoot(); + root.clear(); + + const blocks = data?.blocks || []; + for (const block of blocks) { + if (block.type === 'paragraph') { + const paragraphNode = $createParagraphNode(); + if (block.data.text) { + const children = createNodesFromHTML(block.data.text); + paragraphNode.append(...children); + } + root.append(paragraphNode); + } else if (block.type === 'heading') { + const headingNode = $createHeadingNode(); + if (block.data.text) { + const children = createNodesFromHTML(block.data.text); + headingNode.append(...children); + } + root.append(headingNode); + } else if (block.type === 'orderedList' || block.type === 'unorderedList') { + const style = block.data.style === 'ordered' ? 'number' : 'bullet'; + const listNode = $createListNode(style); + for (const item of block.data.items) { + listNode.append(...createListItemNodes(item, style)); + } + root.append(listNode); + } + } +} + +function createNodesFromHTML(htmlString: string) { + const template = document.createElement('template'); + template.innerHTML = htmlString; + const fragment = template.content; + + const nodes: any[] = []; + + function parseNode(domNode: Node): any { + if (domNode.nodeType === Node.TEXT_NODE) { + return $createTextNode(domNode.textContent || ''); + } + + if (domNode.nodeType === Node.ELEMENT_NODE) { + const el = domNode as HTMLElement; + const children = Array.from(el.childNodes).map(parseNode).filter(Boolean); + + switch (el.tagName.toLowerCase()) { + case 'b': + case 'strong': + return children.map(child => { + child.setFormat('bold'); + return child; + }); + case 'i': + case 'em': + return children.map(child => { + child.setFormat('italic'); + return child; + }); + case 'u': + return children.map(child => { + child.setFormat('underline'); + return child; + }); + case 'sup': + return children.map(child => { + child.setFormat('superscript'); + return child; + }); + case 'a': + return [ + $applyNodeReplacement( + $createLinkNode(el.getAttribute('href') || '').append(...children) + ), + ]; + default: + return children; + } + } + + return null; + } + + fragment.childNodes.forEach(node => { + const parsed = parseNode(node); + if (Array.isArray(parsed)) { + nodes.push(...parsed); + } else if (parsed) { + nodes.push(parsed); + } + }); + + return nodes; +} + +function createListItemNodes(listItem: RichTextListItem, parentStyle: 'number' | 'bullet') { + const nodes: ListItemNode[] = []; + if (listItem.content) { + const itemNode = createListItemTextNode(listItem, parentStyle); + nodes.push(itemNode); + } + if (listItem.items && listItem.items.length > 0) { + const itemNode = createListItemNestedListNode(listItem, parentStyle); + nodes.push(itemNode); + } + return nodes; +} + +function createListItemTextNode(listItem: RichTextListItem, parentStyle: 'number' | 'bullet') { + const listItemNode = $createListItemNode(); + + if (listItem.content) { + const children = createNodesFromHTML(listItem.content); + listItemNode.append(...children); + } + + return listItemNode; +} + +function createListItemNestedListNode(listItem: RichTextListItem, parentStyle: 'number' | 'bullet') { + const listItemNode = $createListItemNode(); + + if (listItem.items && listItem.items.length > 0) { + let style: 'number' | 'bullet' = parentStyle; + if (listItem.itemsType === 'orderedList') { + style = 'number'; + } else if (listItem.itemsType === 'unorderedList') { + style = 'bullet'; + } + const nestedListNode = $createListNode(style); + for (const item of listItem.items) { + nestedListNode.append(...createListItemNodes(item, style)); + } + listItemNode.append(nestedListNode); + } + + return listItemNode; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/utils/selection.ts b/packages/root-cms/ui/components/RichTextEditor/utils/selection.ts new file mode 100644 index 00000000..acae7c9d --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/utils/selection.ts @@ -0,0 +1,39 @@ +import {$isAtNodeEnd} from '@lexical/selection'; +import {ElementNode, RangeSelection, TextNode} from 'lexical'; + +export function getSelectedNode( + selection: RangeSelection, +): TextNode | ElementNode { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) { + return anchorNode; + } + if (selection.isBackward()) { + return $isAtNodeEnd(focus) ? anchorNode : focusNode; + } + return $isAtNodeEnd(anchor) ? anchorNode : focusNode; +} + +export function getDOMRangeRect( + nativeSelection: Selection, + rootElement: HTMLElement, +): DOMRect { + const domRange = nativeSelection.getRangeAt(0); + + let rect; + + if (nativeSelection.anchorNode === rootElement) { + let inner = rootElement; + while (inner.firstElementChild != null) { + inner = inner.firstElementChild as HTMLElement; + } + rect = inner.getBoundingClientRect(); + } else { + rect = domRange.getBoundingClientRect(); + } + + return rect; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/utils/shortcuts.ts b/packages/root-cms/ui/components/RichTextEditor/utils/shortcuts.ts new file mode 100644 index 00000000..d04d006b --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/utils/shortcuts.ts @@ -0,0 +1,103 @@ +import {IS_APPLE} from '@lexical/utils'; +import {isModifierMatch} from 'lexical'; + +export const SHORTCUTS = Object.freeze({ + // (Ctrl|⌘) + (Alt|Option) + shortcuts + NORMAL: IS_APPLE ? '⌘+Opt+0' : 'Ctrl+Alt+0', + HEADING1: IS_APPLE ? '⌘+Opt+1' : 'Ctrl+Alt+1', + HEADING2: IS_APPLE ? '⌘+Opt+2' : 'Ctrl+Alt+2', + HEADING3: IS_APPLE ? '⌘+Opt+3' : 'Ctrl+Alt+3', + NUMBERED_LIST: IS_APPLE ? '⌘+Shift+7' : 'Ctrl+Shift+7', + BULLET_LIST: IS_APPLE ? '⌘+Shift+8' : 'Ctrl+Shift+8', + + // (Ctrl|⌘) + Shift + shortcuts + STRIKETHROUGH: IS_APPLE ? '⌘+Shift+X' : 'Ctrl+Shift+X', + + // (Ctrl|⌘) + shortcuts + SUBSCRIPT: IS_APPLE ? '⌘+,' : 'Ctrl+,', + SUPERSCRIPT: IS_APPLE ? '⌘+.' : 'Ctrl+.', + INDENT: IS_APPLE ? '⌘+]' : 'Ctrl+]', + OUTDENT: IS_APPLE ? '⌘+[' : 'Ctrl+[', + CLEAR_FORMATTING: IS_APPLE ? '⌘+\\' : 'Ctrl+\\', + REDO: IS_APPLE ? '⌘+Shift+Z' : 'Ctrl+Y', + UNDO: IS_APPLE ? '⌘+Z' : 'Ctrl+Z', + BOLD: IS_APPLE ? '⌘+B' : 'Ctrl+B', + ITALIC: IS_APPLE ? '⌘+I' : 'Ctrl+I', + UNDERLINE: IS_APPLE ? '⌘+U' : 'Ctrl+U', + INSERT_LINK: IS_APPLE ? '⌘+K' : 'Ctrl+K', +}); + +const CONTROL_OR_META = {ctrlKey: !IS_APPLE, metaKey: IS_APPLE}; + +export function isFormatParagraph(event: KeyboardEvent): boolean { + const {code} = event; + + return ( + (code === 'Numpad0' || code === 'Digit0') && + isModifierMatch(event, {...CONTROL_OR_META, altKey: true}) + ); +} + +export function isFormatHeading(event: KeyboardEvent): boolean { + const {code} = event; + const keyNumber = code[code.length - 1]; + + return ( + ['1', '2', '3'].includes(keyNumber) && + isModifierMatch(event, {...CONTROL_OR_META, altKey: true}) + ); +} + +export function isFormatNumberedList(event: KeyboardEvent): boolean { + const {code} = event; + return ( + (code === 'Numpad7' || code === 'Digit7') && + isModifierMatch(event, {...CONTROL_OR_META, shiftKey: true}) + ); +} + +export function isFormatBulletList(event: KeyboardEvent): boolean { + const {code} = event; + return ( + (code === 'Numpad8' || code === 'Digit8') && + isModifierMatch(event, {...CONTROL_OR_META, shiftKey: true}) + ); +} + +export function isStrikeThrough(event: KeyboardEvent): boolean { + const {code} = event; + return ( + code === 'KeyX' && + isModifierMatch(event, {...CONTROL_OR_META, shiftKey: true}) + ); +} + +export function isIndent(event: KeyboardEvent): boolean { + const {code} = event; + return code === 'BracketRight' && isModifierMatch(event, CONTROL_OR_META); +} + +export function isOutdent(event: KeyboardEvent): boolean { + const {code} = event; + return code === 'BracketLeft' && isModifierMatch(event, CONTROL_OR_META); +} + +export function isSubscript(event: KeyboardEvent): boolean { + const {code} = event; + return code === 'Comma' && isModifierMatch(event, CONTROL_OR_META); +} + +export function isSuperscript(event: KeyboardEvent): boolean { + const {code} = event; + return code === 'Period' && isModifierMatch(event, CONTROL_OR_META); +} + +export function isClearFormatting(event: KeyboardEvent): boolean { + const {code} = event; + return code === 'Backslash' && isModifierMatch(event, CONTROL_OR_META); +} + +export function isInsertLink(event: KeyboardEvent): boolean { + const {code} = event; + return code === 'KeyK' && isModifierMatch(event, CONTROL_OR_META); +} diff --git a/packages/root-cms/ui/components/RichTextEditor/utils/toolbar.ts b/packages/root-cms/ui/components/RichTextEditor/utils/toolbar.ts new file mode 100644 index 00000000..abbb1215 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/utils/toolbar.ts @@ -0,0 +1,124 @@ +import {$createCodeNode} from '@lexical/code'; +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, +} from '@lexical/list'; +import {$isDecoratorBlockNode} from '@lexical/react/LexicalDecoratorBlockNode'; +import { + $createHeadingNode, + $isHeadingNode, + $isQuoteNode, + HeadingTagType, +} from '@lexical/rich-text'; +import {$setBlocksType} from '@lexical/selection'; +import {$getNearestBlockElementAncestorOrThrow} from '@lexical/utils'; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + $isTextNode, + LexicalEditor, +} from 'lexical'; + +export const formatParagraph = (editor: LexicalEditor) => { + editor.update(() => { + const selection = $getSelection(); + $setBlocksType(selection, () => $createParagraphNode()); + }); +}; + +export const formatHeading = ( + editor: LexicalEditor, + blockType: string, + headingSize: HeadingTagType, +) => { + if (blockType !== headingSize) { + editor.update(() => { + const selection = $getSelection(); + $setBlocksType(selection, () => $createHeadingNode(headingSize)); + }); + } +}; + +export const formatBulletList = (editor: LexicalEditor, blockType: string) => { + if (blockType !== 'bullet') { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + } else { + formatParagraph(editor); + } +}; + +export const formatNumberedList = ( + editor: LexicalEditor, + blockType: string, +) => { + console.log('formatNumberedList'); + if (blockType !== 'number') { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + } else { + formatParagraph(editor); + } +}; + +export const clearFormatting = (editor: LexicalEditor) => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + const focus = selection.focus; + const nodes = selection.getNodes(); + const extractedNodes = selection.extract(); + + if (anchor.key === focus.key && anchor.offset === focus.offset) { + return; + } + + nodes.forEach((node, idx) => { + // We split the first and last node by the selection + // So that we don't format unselected text inside those nodes + if ($isTextNode(node)) { + // Use a separate variable to ensure TS does not lose the refinement + let textNode = node; + if (idx === 0 && anchor.offset !== 0) { + textNode = textNode.splitText(anchor.offset)[1] || textNode; + } + if (idx === nodes.length - 1) { + textNode = textNode.splitText(focus.offset)[0] || textNode; + } + + // If the selected text has one format applied + // selecting a portion of the text, could + // clear the format to the wrong portion of the text. + // + // The cleared text is based on the length of the selected text. + // + // We need this in case the selected text only has one format. + const extractedTextNode = extractedNodes[0]; + if (nodes.length === 1 && $isTextNode(extractedTextNode)) { + textNode = extractedTextNode; + } + + if (textNode.__style !== '') { + textNode.setStyle(''); + } + if (textNode.__format !== 0) { + textNode.setFormat(0); + } + const nearestBlockElement = + $getNearestBlockElementAncestorOrThrow(textNode); + if (nearestBlockElement.__format !== 0) { + nearestBlockElement.setFormat(''); + } + if (nearestBlockElement.__indent !== 0) { + nearestBlockElement.setIndent(0); + } + node = textNode; + } else if ($isHeadingNode(node) || $isQuoteNode(node)) { + node.replace($createParagraphNode(), true); + } else if ($isDecoratorBlockNode(node)) { + node.setFormat(''); + } + }); + } + }); +}; diff --git a/packages/root-cms/ui/components/RichTextEditor/utils/url.ts b/packages/root-cms/ui/components/RichTextEditor/utils/url.ts new file mode 100644 index 00000000..beebfffc --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/utils/url.ts @@ -0,0 +1,27 @@ +const SUPPORTED_URL_PROTOCOLS = new Set([ + 'http:', + 'https:', + 'mailto:', + 'sms:', + 'tel:', +]); + +export function sanitizeUrl(url: string): string { + try { + const parsedUrl = new URL(url); + if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) { + return 'about:blank'; + } + } catch { + return url; + } + return url; +} + +const urlRegExp = new RegExp( + /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/, +); + +export function validateUrl(url: string): boolean { + return url === 'https://' || urlRegExp.test(url); +} diff --git a/packages/root-cms/ui/tsconfig.json b/packages/root-cms/ui/tsconfig.json index ca5d1374..ffd38471 100644 --- a/packages/root-cms/ui/tsconfig.json +++ b/packages/root-cms/ui/tsconfig.json @@ -34,5 +34,6 @@ "*.tsx", "**/*.tsx", "../shared/*.ts", + "../shared/*.tsx" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab983c5a..00627f3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -355,6 +355,33 @@ importers: '@genkit-ai/vertexai': specifier: 0.5.2 version: 0.5.2 + '@lexical/code': + specifier: 0.31.2 + version: 0.31.2 + '@lexical/html': + specifier: 0.31.2 + version: 0.31.2 + '@lexical/link': + specifier: 0.31.2 + version: 0.31.2 + '@lexical/list': + specifier: 0.31.2 + version: 0.31.2 + '@lexical/markdown': + specifier: 0.31.2 + version: 0.31.2 + '@lexical/react': + specifier: 0.31.2 + version: 0.31.2(@preact/compat@17.1.2)(@preact/compat@17.1.2)(yjs@13.6.27) + '@lexical/rich-text': + specifier: 0.31.2 + version: 0.31.2 + '@lexical/selection': + specifier: 0.31.2 + version: 0.31.2 + '@lexical/utils': + specifier: 0.31.2 + version: 0.31.2 body-parser: specifier: 1.20.2 version: 1.20.2 @@ -379,12 +406,18 @@ importers: kleur: specifier: 4.1.5 version: 4.1.5 + lexical: + specifier: 0.31.2 + version: 0.31.2 sirv: specifier: 2.0.3 version: 2.0.3 tiny-glob: specifier: 0.2.9 version: 0.2.9 + yjs: + specifier: 13.6.27 + version: 13.6.27 devDependencies: '@babel/core': specifier: 7.17.9 @@ -798,7 +831,6 @@ packages: engines: {node: '>=6.9.0'} dependencies: regenerator-runtime: 0.13.11 - dev: true /@babel/template@7.16.7: resolution: {integrity: sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==} @@ -2486,6 +2518,201 @@ packages: dependencies: lodash: 4.17.21 + /@lexical/clipboard@0.31.2: + resolution: {integrity: sha512-cedna5jXfNzbmGl0nLOiLoQoE42i3NfCU6wtugO/auWTLbv1/gD9g0egLg2lAUod+BApaCgdTU4tV2bqww2GWQ==} + dependencies: + '@lexical/html': 0.31.2 + '@lexical/list': 0.31.2 + '@lexical/selection': 0.31.2 + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/code@0.31.2: + resolution: {integrity: sha512-pix3n43zJzzkOgiUV0aTkWL4syItiHrP7YkdenkXgauVMAqZq3IvmK1hSQYKeDsVw/3N8mU26oRp0g5kPDV1rA==} + dependencies: + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + prismjs: 1.30.0 + dev: false + + /@lexical/devtools-core@0.31.2(@preact/compat@17.1.2)(@preact/compat@17.1.2): + resolution: {integrity: sha512-Ae5jJzeo6+3DVEWj9n6h+Vz8R4eL+Z7egGHOAwSa3E65PVp8WNlAoM+Z0rNNpvmbFglWD7d9pSPHcK/G5B7Bfw==} + peerDependencies: + react: '>=17.x' + react-dom: '>=17.x' + dependencies: + '@lexical/html': 0.31.2 + '@lexical/link': 0.31.2 + '@lexical/mark': 0.31.2 + '@lexical/table': 0.31.2 + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + react: /@preact/compat@17.1.2(preact@10.19.3) + react-dom: /@preact/compat@17.1.2(preact@10.19.3) + dev: false + + /@lexical/dragon@0.31.2: + resolution: {integrity: sha512-exFqbyyLyfZmmKQgjB4sJOYHV5DQYIPw0orvxpGMeaVCOgfP0RvB21QLHyazmGlnVNcahscHYieZmLJdpwHYMQ==} + dependencies: + lexical: 0.31.2 + dev: false + + /@lexical/hashtag@0.31.2: + resolution: {integrity: sha512-/sa8bsCq2CKmdpvN/l2B8K/sJrsmd4pAafFG4JWB7JXjPxJyDe80WbSaQfBPdrhvqdLY8l05GeWM17SsEV1sMw==} + dependencies: + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/history@0.31.2: + resolution: {integrity: sha512-q3Ykv3oi711XmuFdg7LuppLImyedgpLD5KekXLXRVyOGOTvvaNfb2YQfRjFBalxl3umj2QHYR22Zkamx8zfFXw==} + dependencies: + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/html@0.31.2: + resolution: {integrity: sha512-92Oi9daBDNCaionS9ENnXkC+jzzz5WdFTHxTkPqcGaJZE/jF0rAe6LQo6356O/yr9dYK/KIsE8V79AJHqQKJhg==} + dependencies: + '@lexical/selection': 0.31.2 + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/link@0.31.2: + resolution: {integrity: sha512-sLtpW+cuFdVq1V6vlEFUEC1BRHyHBxEdCrxwTP7T0CreJDKrUrBD2oBafb/4AiOPhM7CHAwW88pbuY5KvWOovw==} + dependencies: + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/list@0.31.2: + resolution: {integrity: sha512-NbdpxSttk/MmYCSovsxiQwS2h3h2U/Kg3WwNfW9vgpvdZE+qmg6jqpzUFVe51/i+GF2WU0uRPG0J5StMGhtYlg==} + dependencies: + '@lexical/selection': 0.31.2 + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/mark@0.31.2: + resolution: {integrity: sha512-orzsqWaejgdUhSHXRFogF0HNzRWKf2HD7DgiGxKF7LrjVLl4j14+zA8UWX9d4qNwIbRPdfQkYfeOSPwbYx3iRw==} + dependencies: + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/markdown@0.31.2: + resolution: {integrity: sha512-2gWo7HiMONTor3whJo1237LZz769eVVRT8kGUgBq/p5QyFsb131NUYSxCperKdDaO43k+4ZQspPHyQJjoyOxHg==} + dependencies: + '@lexical/code': 0.31.2 + '@lexical/link': 0.31.2 + '@lexical/list': 0.31.2 + '@lexical/rich-text': 0.31.2 + '@lexical/text': 0.31.2 + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/offset@0.31.2: + resolution: {integrity: sha512-f8bNvys2jNgnjQWoUd6us7KBXdB7AhKqgWqso7nGeoB1CWGTkd88Qxm8A9uX/muPK0cb6fed4X7wN2xuRDWpyQ==} + dependencies: + lexical: 0.31.2 + dev: false + + /@lexical/overflow@0.31.2: + resolution: {integrity: sha512-l5jbPglBX8CiXZ7F+okTcfdOwfAgf1JIa8BJhkQrtRP/fEiY3NnCehBYfp/Iyd9uBZDWlVlp1+UsqFFtEeT3Yg==} + dependencies: + lexical: 0.31.2 + dev: false + + /@lexical/plain-text@0.31.2: + resolution: {integrity: sha512-CMKuWPUCyDZ8ET10YZM7RxDyIB2UiHJ7mEwqEKl86OS9XOhWtp0qUQUonyeAy4f9yfuMTA7fQ2iLlEmAaeVu+g==} + dependencies: + '@lexical/clipboard': 0.31.2 + '@lexical/selection': 0.31.2 + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/react@0.31.2(@preact/compat@17.1.2)(@preact/compat@17.1.2)(yjs@13.6.27): + resolution: {integrity: sha512-A/Ub9Ub5vx9EE0WEhVeE3t/SMVecnaPGeN37Q5LMPXiGgQCsMKEYtjHKvAjjAXSk8YD1lGTd/k4LOjyl8Y9ZyA==} + peerDependencies: + react: '>=17.x' + react-dom: '>=17.x' + dependencies: + '@lexical/devtools-core': 0.31.2(@preact/compat@17.1.2)(@preact/compat@17.1.2) + '@lexical/dragon': 0.31.2 + '@lexical/hashtag': 0.31.2 + '@lexical/history': 0.31.2 + '@lexical/link': 0.31.2 + '@lexical/list': 0.31.2 + '@lexical/mark': 0.31.2 + '@lexical/markdown': 0.31.2 + '@lexical/overflow': 0.31.2 + '@lexical/plain-text': 0.31.2 + '@lexical/rich-text': 0.31.2 + '@lexical/table': 0.31.2 + '@lexical/text': 0.31.2 + '@lexical/utils': 0.31.2 + '@lexical/yjs': 0.31.2(yjs@13.6.27) + lexical: 0.31.2 + react: /@preact/compat@17.1.2(preact@10.19.3) + react-dom: /@preact/compat@17.1.2(preact@10.19.3) + react-error-boundary: 3.1.4(@preact/compat@17.1.2) + transitivePeerDependencies: + - yjs + dev: false + + /@lexical/rich-text@0.31.2: + resolution: {integrity: sha512-iBkqY0CvTKbwk3T+a4wtI6/OjhM/CdxXvha6b88Gb9d5tYj5k7cT5SznYF1sXPmwtqpXg9TWkBN2Puqv3LPvsg==} + dependencies: + '@lexical/clipboard': 0.31.2 + '@lexical/selection': 0.31.2 + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/selection@0.31.2: + resolution: {integrity: sha512-8hwNgAVWob09y8/Z2QKknw0z6vFcfjsQfHkyDwXwXUeTcJFjlAokrXrc65PRuufI4TaFDQHnXQpP5GcOYROE6Q==} + dependencies: + lexical: 0.31.2 + dev: false + + /@lexical/table@0.31.2: + resolution: {integrity: sha512-S4W6DdkDCYP32qL6QD4jyksEatyfapRBc4sqoRove83fQEWnFhJ1iBheiG3hSm3YDbDmLclZxcE2CuSIZ6bd0Q==} + dependencies: + '@lexical/clipboard': 0.31.2 + '@lexical/utils': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/text@0.31.2: + resolution: {integrity: sha512-+rGRAqE4uKdJf25e4stqDImhBh5RgVmxMJf00OyQdwq1P2M+/xF3NddqRpoACHyMyHhuR1wOSONSnYbMqMtc6w==} + dependencies: + lexical: 0.31.2 + dev: false + + /@lexical/utils@0.31.2: + resolution: {integrity: sha512-vtPzZuJt134cz+oiHAPSIHT3hGt/9Gtug/Cf8OftqJeg3/7P6dKiTdAvAql3q4QxHlCAup4Ed87Ec8PYsICGGg==} + dependencies: + '@lexical/list': 0.31.2 + '@lexical/selection': 0.31.2 + '@lexical/table': 0.31.2 + lexical: 0.31.2 + dev: false + + /@lexical/yjs@0.31.2(yjs@13.6.27): + resolution: {integrity: sha512-H1OV+NFGTmHqr+0BFkxo4bDBsdw1WmSwCzIoMVU1uA4levH7tLay/xBV/67ghh+5InSwGR6gcJeFdbL86URuOw==} + peerDependencies: + yjs: '>=13.5.22' + dependencies: + '@lexical/offset': 0.31.2 + '@lexical/selection': 0.31.2 + lexical: 0.31.2 + yjs: 13.6.27 + dev: false + /@mantine/core@4.2.12(@babel/core@7.17.9)(@mantine/hooks@4.2.12)(@preact/compat@17.1.2)(@preact/compat@17.1.2): resolution: {integrity: sha512-PZcVUvcSZiZmLR1moKBJFdFIh6a4C+TE2ao91kzTAlH5Qb8t/V3ONbfPk3swHoYr7OSLJQM8vZ7UD5sFDiq0/g==} peerDependencies: @@ -2610,8 +2837,8 @@ packages: '@types/gapi.client.discovery-v1': 0.0.4 dev: true - /@maxim_mazurok/gapi.client.sheets-v4@0.0.20250509: - resolution: {integrity: sha512-4BzFb79MybDVbI/NFxHPvXe2wogAg0k0ejTXHBVlQSfKBgTDlKt0NXNeaNrCjJOaPcCqMGPFBsUNKVUyXRx72w==} + /@maxim_mazurok/gapi.client.sheets-v4@0.0.20250521: + resolution: {integrity: sha512-ZbLbC7A2tTOOsJkqaCyGPSLaPZkiuQMkQ+GzcI8fdj6zGiDHW2D60YAQ+FRG6117+Yt9IcPfj2V9N1lgoF2agg==} dependencies: '@types/gapi.client': 1.0.8 '@types/gapi.client.discovery-v1': 0.0.4 @@ -3057,7 +3284,6 @@ packages: preact: '*' dependencies: preact: 10.19.3 - dev: true /@protobufjs/aspromise@1.1.2: resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -3473,7 +3699,7 @@ packages: /@types/gapi.client.sheets-v4@0.0.4: resolution: {integrity: sha512-6kTJ7aDMAElfdQV1XzVJmZWjgbibpa84DMuKuaN8Cwqci/dkglPyHXKvsGrRugmuYvgFYr35AQqwz6j3q8R0dw==} dependencies: - '@maxim_mazurok/gapi.client.sheets-v4': 0.0.20250509 + '@maxim_mazurok/gapi.client.sheets-v4': 0.0.20250521 dev: true /@types/gapi.client@1.0.8: @@ -8348,6 +8574,10 @@ packages: - encoding dev: true + /isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + dev: false + /isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} dev: true @@ -8706,6 +8936,18 @@ packages: prelude-ls: 1.2.1 type-check: 0.4.0 + /lexical@0.31.2: + resolution: {integrity: sha512-1GRqzl/QAdtzqOoYXVfMFOkyiSQKIIPcazsgIs+WvsLL8UIcwP16WWeKUtamJgyvS84GN/tuMJsd1Iv+Qzw9WA==} + dev: false + + /lib0@0.2.108: + resolution: {integrity: sha512-+3eK/B0SqYoZiQu9fNk4VEc6EX8cb0Li96tPGKgugzoGj/OdRdREtuTLvUW+mtinoB2mFiJjSqOJBIaMkAGhxQ==} + engines: {node: '>=16'} + hasBin: true + dependencies: + isomorphic.js: 0.2.5 + dev: false + /libsodium-wrappers@0.7.13: resolution: {integrity: sha512-kasvDsEi/r1fMzKouIDv7B8I6vNmknXwGiYodErGuESoFTohGSKZplFtVxZqHaoQ217AynyIFgnOVRitpHs0Qw==} dependencies: @@ -10677,6 +10919,11 @@ packages: /pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + /prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + dev: false + /proc-log@3.0.0: resolution: {integrity: sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -10963,6 +11210,16 @@ packages: dev: true optional: true + /react-error-boundary@3.1.4(@preact/compat@17.1.2): + resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.20.13 + react: /@preact/compat@17.1.2(preact@10.19.3) + dev: false + /react-fast-compare@3.2.0: resolution: {integrity: sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==} dev: true @@ -11134,7 +11391,6 @@ packages: /regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} - dev: true /regexp.prototype.flags@1.4.3: resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==} @@ -13497,6 +13753,13 @@ packages: y18n: 5.0.8 yargs-parser: 21.1.1 + /yjs@13.6.27: + resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + dependencies: + lib0: 0.2.108 + dev: false + /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} From 85fb2239dfea289ab9b9dc5dea8b6a4c50cb2450 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Sun, 25 May 2025 21:54:24 -0700 Subject: [PATCH 2/4] chore: wire up lexical editor to db --- packages/root-cms/shared/richtext.ts | 3 +- .../DocEditor/fields/RichTextField.tsx | 8 +++- .../RichTextEditor/LexicalEditor.tsx | 2 +- .../RichTextEditor/LexicalTheme.css | 12 ++---- .../RichTextEditor/RichTextEditor.tsx | 2 +- .../plugins/FloatingToolbarPlugin.css | 4 ++ .../RichTextEditor/plugins/OnChangePlugin.tsx | 42 ++++++++++++------- .../utils/convert-from-lexical.ts | 17 ++++++-- .../utils/convert-to-lexical.ts | 30 ++++++++----- 9 files changed, 78 insertions(+), 42 deletions(-) diff --git a/packages/root-cms/shared/richtext.ts b/packages/root-cms/shared/richtext.ts index 21ea193e..126c4094 100644 --- a/packages/root-cms/shared/richtext.ts +++ b/packages/root-cms/shared/richtext.ts @@ -56,8 +56,9 @@ export interface RichTextCustomBlock { } export interface RichTextData { - [key: string]: any; blocks: RichTextBlock[]; + time: number; + version: string; } export function testValidRichTextData(data: RichTextData) { diff --git a/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx b/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx index 48d30da0..255c8e53 100644 --- a/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx +++ b/packages/root-cms/ui/components/DocEditor/fields/RichTextField.tsx @@ -11,8 +11,12 @@ export function RichTextField(props: FieldProps) { const [value, setValue] = useState(null); function onChange(newValue: RichTextData | null) { - props.draft.updateKey(props.deepKey, newValue); - setValue(newValue); + setValue((oldValue) => { + if (oldValue?.time !== newValue?.time) { + props.draft.updateKey(props.deepKey, newValue); + } + return newValue; + }); } useEffect(() => { diff --git a/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx b/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx index dd074a59..1e57db1d 100644 --- a/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx +++ b/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx @@ -71,7 +71,7 @@ export function LexicalEditor(props: LexicalEditorProps) { interface EditorProps { placeholder?: string; - value?: RichTextData; + value?: RichTextData | null; onChange?: (value: RichTextData | null) => void; } diff --git a/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css b/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css index 0c16f886..e0a37ae8 100644 --- a/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css +++ b/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css @@ -25,24 +25,20 @@ padding-left: 16px; } .LexicalTheme__h1 { - font-size: 20px; - font-weight: 600; - margin: 0; + font-size: 18px; } .LexicalTheme__h2 { font-size: 16px; - font-weight: 600; - margin: 0; } .LexicalTheme__h3 { font-size: 14px; - font-weight: 600; - margin: 0; } .LexicalTheme__h1, .LexicalTheme__h2, .LexicalTheme__h3 { + margin: 0; margin-top: 8px; + font-weight: 500; } .LexicalTheme__h1:first-child, .LexicalTheme__h2:first-child, @@ -462,7 +458,7 @@ margin-top: 0; } .LexicalTheme__listItem { - margin: 4px 20px; + margin: 2px 20px; } .LexicalTheme__listItem::marker { color: var(--listitem-marker-color); diff --git a/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx b/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx index 5dc9ab9b..04a2c3e9 100644 --- a/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx +++ b/packages/root-cms/ui/components/RichTextEditor/RichTextEditor.tsx @@ -21,7 +21,7 @@ export function RichTextEditor(props: RichTextEditorProps) { className={props.className} placeholder={props.placeholder} value={props.value} - // onChange={props.onChange} + onChange={props.onChange} /> ); } diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.css b/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.css index 92f811ed..9cdedea7 100644 --- a/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.css +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingToolbarPlugin.css @@ -16,6 +16,10 @@ will-change: transform; } +.LexicalEditor__floatingToolbar .LexicalEditor__toolbar__actionIcon button { + color: black; +} + .LexicalEditor__floatingToolbar .LexicalEditor__toolbar__actionIcon--active button { border: 1px solid rgb(206, 212, 218) } diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx index 96b629c1..74d52b08 100644 --- a/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx @@ -1,8 +1,10 @@ -import {useEffect} from 'preact/hooks'; +import {useEffect, useState} from 'preact/hooks'; import {RichTextData} from '../../../../shared/richtext.js'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {convertToRichTextData} from '../utils/convert-from-lexical.js'; import {convertToLexical} from '../utils/convert-to-lexical.js'; +import {OnChangePlugin as LexicalOnChangePlugin} from '@lexical/react/LexicalOnChangePlugin'; +import {EditorState} from 'lexical'; export interface OnChangePluginProps { value?: RichTextData | null; @@ -12,25 +14,35 @@ export interface OnChangePluginProps { export function OnChangePlugin(props: OnChangePluginProps) { const [editor] = useLexicalComposerContext(); - useEffect(() => { - console.log('rte value change upstream:', props.value); - editor.update(() => { - convertToLexical(props.value); - }); - }, [editor, props.value]); + const [timeSaved, setTimeSaved] = useState(0); + const [isUpdating, setIsUpdating] = useState(false); useEffect(() => { - return editor.registerUpdateListener(({editorState}) => { - editorState.read(() => { - const data = toRichTextData(); - if (props.onChange) { - props.onChange(data); - } + const time = props.value?.time || 0; + if (timeSaved !== time) { + editor.update(() => { + console.log('lexical update, props.value changed:', props.value); + setIsUpdating(true); + convertToLexical(props.value); }); + } + }, [editor, props.value]); + + function onChange(editorState: EditorState) { + if (isUpdating) { + setIsUpdating(false); + return; + } + editorState.read(() => { + const richTextData = toRichTextData(); + setTimeSaved(richTextData?.time || 0); + if (props.onChange) { + props.onChange(richTextData); + } }); - }, [editor, props.onChange]); + } - return null; + return ; } function toRichTextData(): RichTextData | null { diff --git a/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts b/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts index 132dcd51..3a11460b 100644 --- a/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts +++ b/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts @@ -106,19 +106,30 @@ export function convertToRichTextData(): RichTextData | null { } }); - // If the last block is an empty paragraph, remove it. - const lastBlock = blocks.length > 0 && blocks.at(-1); - if (lastBlock && lastBlock.type === 'paragraph' && !lastBlock.data?.text) { + // If the last block is empty, remove it. + while (testLastBlockIsEmpty(blocks)) { blocks.pop(); } + // Use `null` when the RTE is empty, which allows components to use boolean + // expressions to determine whether to render the RTE field. if (blocks.length === 0) { return null; } + // NOTE(stevenle): The RTE was originally implemented with EditorJS, the data + // format is preserved for backward compatibility. return { time: Date.now(), blocks, version: 'lexical-0.31.2', }; } + +function testLastBlockIsEmpty(blocks: RichTextBlock[]) { + const lastBlock = blocks.length > 0 && blocks.at(-1); + if (lastBlock && lastBlock.type === 'paragraph' && !lastBlock.data?.text) { + return true; + } + return false; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/utils/convert-to-lexical.ts b/packages/root-cms/ui/components/RichTextEditor/utils/convert-to-lexical.ts index 28d5327a..60d00de7 100644 --- a/packages/root-cms/ui/components/RichTextEditor/utils/convert-to-lexical.ts +++ b/packages/root-cms/ui/components/RichTextEditor/utils/convert-to-lexical.ts @@ -1,6 +1,6 @@ import {$applyNodeReplacement, $createParagraphNode, $createTextNode, $getRoot} from 'lexical'; import {RichTextData, RichTextListItem, RichTextListItemNestedList, RichTextListItemText} from '../../../../shared/richtext.js'; -import {$createHeadingNode} from '@lexical/rich-text'; +import {$createHeadingNode, HeadingTagType} from '@lexical/rich-text'; import {$createLinkNode} from '@lexical/link'; import {$createListItemNode, $createListNode, ListItemNode} from '@lexical/list'; @@ -23,7 +23,8 @@ export function convertToLexical(data?: RichTextData | null) { } root.append(paragraphNode); } else if (block.type === 'heading') { - const headingNode = $createHeadingNode(); + const tagName = `h${block.data?.level || 2}` as HeadingTagType; + const headingNode = $createHeadingNode(tagName); if (block.data.text) { const children = createNodesFromHTML(block.data.text); headingNode.append(...children); @@ -54,29 +55,34 @@ function createNodesFromHTML(htmlString: string) { if (domNode.nodeType === Node.ELEMENT_NODE) { const el = domNode as HTMLElement; - const children = Array.from(el.childNodes).map(parseNode).filter(Boolean); + const children = Array.from(el.childNodes).map(parseNode).filter(Boolean).flat(); switch (el.tagName.toLowerCase()) { case 'b': case 'strong': - return children.map(child => { - child.setFormat('bold'); + return children.map((child) => { + child.toggleFormat('bold'); return child; }); case 'i': case 'em': - return children.map(child => { - child.setFormat('italic'); + return children.map((child) => { + child.toggleFormat('italic'); return child; }); case 'u': - return children.map(child => { - child.setFormat('underline'); + return children.map((child) => { + child.toggleFormat('underline'); + return child; + }); + case 's': + return children.map((child) => { + child.toggleFormat('strikethrough'); return child; }); case 'sup': - return children.map(child => { - child.setFormat('superscript'); + return children.map((child) => { + child.toggleFormat('superscript'); return child; }); case 'a': @@ -86,6 +92,8 @@ function createNodesFromHTML(htmlString: string) { ), ]; default: + console.log('unhandled tag: ' + el.tagName); + console.log(children); return children; } } From 252c08912f9273139b26db5ff87e7422b9031068 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Sun, 25 May 2025 23:19:58 -0700 Subject: [PATCH 3/4] chore: add floating link editor --- .../RichTextEditor/LexicalEditor.tsx | 22 +- .../RichTextEditor/LexicalTheme.css | 1 + .../plugins/FloatingLinkEditorPlugin.css | 85 +++ .../plugins/FloatingLinkEditorPlugin.tsx | 506 ++++++++++++++++++ .../RichTextEditor/plugins/OnChangePlugin.tsx | 14 +- .../RichTextEditor/plugins/ToolbarPlugin.tsx | 2 - .../utils/convert-from-lexical.ts | 35 +- 7 files changed, 648 insertions(+), 17 deletions(-) create mode 100644 packages/root-cms/ui/components/RichTextEditor/plugins/FloatingLinkEditorPlugin.css create mode 100644 packages/root-cms/ui/components/RichTextEditor/plugins/FloatingLinkEditorPlugin.tsx diff --git a/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx b/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx index 1e57db1d..b078e314 100644 --- a/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx +++ b/packages/root-cms/ui/components/RichTextEditor/LexicalEditor.tsx @@ -6,6 +6,7 @@ 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 {LinkPlugin} from '@lexical/react/LexicalLinkPlugin'; import {ListPlugin} from '@lexical/react/LexicalListPlugin'; import {HeadingNode} from '@lexical/rich-text'; import {joinClassNames} from '../../utils/classes.js'; @@ -22,6 +23,7 @@ import {LexicalTheme} from './LexicalTheme.js'; import {FloatingToolbarPlugin} from './plugins/FloatingToolbarPlugin.js'; import {OnChangePlugin} from './plugins/OnChangePlugin.js'; import {RichTextData} from '../../../shared/richtext.js'; +import {FloatingLinkEditorPlugin} from './plugins/FloatingLinkEditorPlugin.js'; const INITIAL_CONFIG: InitialConfigType = { namespace: 'RootCMS', @@ -91,6 +93,7 @@ function Editor(props: EditorProps) { return ( <> + + - - + {floatingAnchorElem && ( + <> + + + + )} ); } diff --git a/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css b/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css index e0a37ae8..75cd0b1f 100644 --- a/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css +++ b/packages/root-cms/ui/components/RichTextEditor/LexicalTheme.css @@ -149,6 +149,7 @@ } .LexicalTheme__link:hover { text-decoration: underline; + text-underline-offset: 2px; cursor: pointer; } .LexicalTheme__blockCursor { diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingLinkEditorPlugin.css b/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingLinkEditorPlugin.css new file mode 100644 index 00000000..abf9ce28 --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingLinkEditorPlugin.css @@ -0,0 +1,85 @@ +.LexicalEditor__linkEditor { + display: flex; + gap: 8px; + align-items: center; + position: absolute; + top: 0; + left: 0; + z-index: 10; + max-width: 400px; + width: 100%; + opacity: 0; + background-color: #fff; + padding: 8px; + border: 1px solid rgb(233, 236, 239); + box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 3px, rgba(0, 0, 0, 0.05) 0px 20px 25px -5px, rgba(0, 0, 0, 0.04) 0px 10px 10px -5px; + border-radius: 8px; + transition: opacity 0.5s; + will-change: transform; +} + +.LexicalEditor__link__input, +.LexicalEditor__link__preview { + flex: 1; + display: block; + border: 1px solid #dedede; + position: relative; + padding: 4px 8px; + height: 28px; + display: flex; + align-items: center; + font-family: var(--font-family-default); + font-size: 12px; + line-height: 1; +} + +.LexicalEditor__link__input:focus { + outline: none; + border-color: #339af0; +} + +.LexicalEditor__link__preview { + overflow: auto; + padding-right: 20px; +} + +.LexicalEditor__link__preview a { + color: #216fdb; + text-underline-offset: 2px; +} + +.LexicalEditor__link__preview svg { + position: absolute; + right: 8px; + top: calc(50% - 8px); +} + +.LexicalEditor__link__controls { + display: flex; + gap: 4px; +} + +.LexicalEditor__link-editor .button { + width: 20px; + height: 20px; + display: inline-block; + padding: 6px; + border-radius: 8px; + cursor: pointer; + margin: 0 2px; +} + +.LexicalEditor__link-editor .button.hovered { + width: 20px; + height: 20px; + display: inline-block; + background-color: #eee; +} + +.LexicalEditor__link-editor .button i { + background-size: contain; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: -0.25em; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingLinkEditorPlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingLinkEditorPlugin.tsx new file mode 100644 index 00000000..ca85420b --- /dev/null +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/FloatingLinkEditorPlugin.tsx @@ -0,0 +1,506 @@ +import './FloatingLinkEditorPlugin.css'; + +import { + $createLinkNode, + $isAutoLinkNode, + $isLinkNode, + TOGGLE_LINK_COMMAND, +} from '@lexical/link'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$findMatchingParent, mergeRegister} from '@lexical/utils'; +import { + $getSelection, + $isLineBreakNode, + $isNodeSelection, + $isRangeSelection, + BaseSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + getDOMSelection, + KEY_ESCAPE_COMMAND, + LexicalEditor, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import {Dispatch, useCallback, useEffect, useRef, useState} from 'preact/hooks'; + +import {getSelectedNode} from '../utils/selection.js'; +import {sanitizeUrl} from '../utils/url.js'; +import {createPortal} from 'preact/compat'; +import {ActionIcon, Tooltip} from '@mantine/core'; +import {IconArrowUpRight, IconCheck, IconEdit, IconPencil, IconTrash, IconX} from '@tabler/icons-preact'; +import {ComponentChildren} from 'preact'; + +const VERTICAL_GAP = 10; +const HORIZONTAL_OFFSET = 5; + +interface FloatingLinkEditorProps { + editor: LexicalEditor; + isLink: boolean; + setIsLink: Dispatch; + anchorElem: HTMLElement; + isLinkEditMode: boolean; + setIsLinkEditMode: Dispatch; +} + +function FloatingLinkEditor(props: FloatingLinkEditorProps) { + const { + editor, + isLink, + setIsLink, + anchorElem, + isLinkEditMode, + setIsLinkEditMode, + } = props; + const editorRef = useRef(null); + const inputRef = useRef(null); + const [linkUrl, setLinkUrl] = useState(''); + const [editedLinkUrl, setEditedLinkUrl] = useState('https://'); + const [lastSelection, setLastSelection] = useState( + null, + ); + + const $updateLinkEditor = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const node = getSelectedNode(selection); + const linkParent = $findMatchingParent(node, $isLinkNode); + + if (linkParent) { + setLinkUrl(linkParent.getURL()); + } else if ($isLinkNode(node)) { + setLinkUrl(node.getURL()); + } else { + setLinkUrl(''); + } + if (isLinkEditMode) { + setEditedLinkUrl(linkUrl); + } + } else if ($isNodeSelection(selection)) { + const nodes = selection.getNodes(); + if (nodes.length > 0) { + const node = nodes[0]; + const parent = node.getParent(); + if ($isLinkNode(parent)) { + setLinkUrl(parent.getURL()); + } else if ($isLinkNode(node)) { + setLinkUrl(node.getURL()); + } else { + setLinkUrl(''); + } + if (isLinkEditMode) { + setEditedLinkUrl(linkUrl); + } + } + } + + const editorElem = editorRef.current; + const nativeSelection = getDOMSelection(editor._window); + const activeElement = document.activeElement; + + if (editorElem === null) { + return; + } + + const rootElement = editor.getRootElement(); + + if (selection !== null && rootElement !== null && editor.isEditable()) { + let domRect: DOMRect | undefined; + + if ($isNodeSelection(selection)) { + const nodes = selection.getNodes(); + if (nodes.length > 0) { + const element = editor.getElementByKey(nodes[0].getKey()); + if (element) { + domRect = element.getBoundingClientRect(); + } + } + } else if ( + nativeSelection !== null && + rootElement.contains(nativeSelection.anchorNode) + ) { + domRect = + nativeSelection.focusNode?.parentElement?.getBoundingClientRect(); + } + + if (domRect) { + domRect.y += 40; + setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem); + } + setLastSelection(selection); + } else if (!activeElement || activeElement.className !== 'link-input') { + if (rootElement !== null) { + setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem); + } + setLastSelection(null); + setIsLinkEditMode(false); + setLinkUrl(''); + } + + return true; + }, [anchorElem, editor, setIsLinkEditMode, isLinkEditMode, linkUrl]); + + useEffect(() => { + const scrollerElem = anchorElem.parentElement; + + const update = () => { + editor.getEditorState().read(() => { + $updateLinkEditor(); + }); + }; + + window.addEventListener('resize', update); + + if (scrollerElem) { + scrollerElem.addEventListener('scroll', update); + } + + return () => { + window.removeEventListener('resize', update); + + if (scrollerElem) { + scrollerElem.removeEventListener('scroll', update); + } + }; + }, [anchorElem.parentElement, editor, $updateLinkEditor]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + $updateLinkEditor(); + }); + }), + + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateLinkEditor(); + return true; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + () => { + if (isLink) { + setIsLink(false); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + ); + }, [editor, $updateLinkEditor, setIsLink, isLink]); + + useEffect(() => { + editor.getEditorState().read(() => { + $updateLinkEditor(); + }); + }, [editor, $updateLinkEditor]); + + useEffect(() => { + if (isLinkEditMode && inputRef.current) { + inputRef.current.focus(); + } + }, [isLinkEditMode, isLink]); + + const monitorInputInteraction = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleLinkSubmission(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setIsLinkEditMode(false); + } + }; + + const handleLinkSubmission = () => { + if (lastSelection !== null) { + if (linkUrl !== '') { + editor.update(() => { + editor.dispatchCommand( + TOGGLE_LINK_COMMAND, + sanitizeUrl(editedLinkUrl), + ); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const parent = getSelectedNode(selection).getParent(); + if ($isAutoLinkNode(parent)) { + const linkNode = $createLinkNode(parent.getURL(), { + rel: parent.__rel, + target: parent.__target, + title: parent.__title, + }); + parent.replace(linkNode, true); + } + } + }); + } + setEditedLinkUrl('https://'); + setIsLinkEditMode(false); + } + }; + + if (!isLink) { + return null; + } + + return ( +
+ {isLinkEditMode ? ( + <> + { + const target = event.target as HTMLInputElement; + setEditedLinkUrl(target.value); + }} + onKeyDown={(event) => { + monitorInputInteraction(event); + }} + /> +
+ setIsLinkEditMode(false)} + > + + + handleLinkSubmission()} + > + + +
+ + ) : ( + <> +
+ + {linkUrl} + + {/* */} +
+
+ { + setEditedLinkUrl(linkUrl); + setIsLinkEditMode(true); + }} + > + + + { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + }} + > + + +
+ + )} +
+ ); +} + +interface ToolbarActionIconProps { + tooltip: string; + onClick: () => void; + children: ComponentChildren; +} + +function ToolbarActionIcon(props: ToolbarActionIconProps) { + return ( + + + {props.children} + + + ); +} + +function useFloatingLinkEditorToolbar( + editor: LexicalEditor, + anchorElem: HTMLElement, + isLinkEditMode: boolean, + setIsLinkEditMode: Dispatch, +) { + const [activeEditor, setActiveEditor] = useState(editor); + const [isLink, setIsLink] = useState(false); + + useEffect(() => { + function $updateToolbar() { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const focusNode = getSelectedNode(selection); + const focusLinkNode = $findMatchingParent(focusNode, $isLinkNode); + const focusAutoLinkNode = $findMatchingParent( + focusNode, + $isAutoLinkNode, + ); + if (!(focusLinkNode || focusAutoLinkNode)) { + setIsLink(false); + return; + } + const badNode = selection + .getNodes() + .filter((node) => !$isLineBreakNode(node)) + .find((node) => { + const linkNode = $findMatchingParent(node, $isLinkNode); + const autoLinkNode = $findMatchingParent(node, $isAutoLinkNode); + return ( + (focusLinkNode && !focusLinkNode.is(linkNode)) || + (linkNode && !linkNode.is(focusLinkNode)) || + (focusAutoLinkNode && !focusAutoLinkNode.is(autoLinkNode)) || + (autoLinkNode && + (!autoLinkNode.is(focusAutoLinkNode) || + autoLinkNode.getIsUnlinked())) + ); + }); + if (!badNode) { + setIsLink(true); + } else { + setIsLink(false); + } + } else if ($isNodeSelection(selection)) { + const nodes = selection.getNodes(); + if (nodes.length === 0) { + setIsLink(false); + return; + } + const node = nodes[0]; + const parent = node.getParent(); + if ($isLinkNode(parent) || $isLinkNode(node)) { + setIsLink(true); + } else { + setIsLink(false); + } + } + } + return mergeRegister( + editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + $updateToolbar(); + }); + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, newEditor) => { + $updateToolbar(); + setActiveEditor(newEditor); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + CLICK_COMMAND, + (payload) => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const node = getSelectedNode(selection); + const linkNode = $findMatchingParent(node, $isLinkNode); + if ($isLinkNode(linkNode) && (payload.metaKey || payload.ctrlKey)) { + window.open(linkNode.getURL(), '_blank'); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor]); + + return createPortal( + , + anchorElem, + ); +} + +export interface FloatingLinkEditorPluginProps { + anchorElem?: HTMLElement; + isLinkEditMode: boolean; + setIsLinkEditMode: Dispatch; +} + +export function FloatingLinkEditorPlugin(props: FloatingLinkEditorPluginProps) { + const { + anchorElem = document.body, + isLinkEditMode, + setIsLinkEditMode, + } = props; + const [editor] = useLexicalComposerContext(); + return useFloatingLinkEditorToolbar( + editor, + anchorElem, + isLinkEditMode, + setIsLinkEditMode, + ); +} + +export function setFloatingElemPositionForLinkEditor( + targetRect: DOMRect | null, + floatingElem: HTMLElement, + anchorElem: HTMLElement, + verticalGap: number = VERTICAL_GAP, + horizontalOffset: number = HORIZONTAL_OFFSET, +): void { + const scrollerElem = anchorElem.parentElement; + + if (targetRect === null || !scrollerElem) { + floatingElem.style.opacity = '0'; + floatingElem.style.transform = 'translate(-10000px, -10000px)'; + return; + } + + const floatingElemRect = floatingElem.getBoundingClientRect(); + const anchorElementRect = anchorElem.getBoundingClientRect(); + const editorScrollerRect = scrollerElem.getBoundingClientRect(); + + let top = targetRect.top - verticalGap; + let left = targetRect.left - horizontalOffset; + + if (top < editorScrollerRect.top) { + top += floatingElemRect.height + targetRect.height + verticalGap * 2; + } + + if (left + floatingElemRect.width > editorScrollerRect.right) { + left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset; + } + + top -= anchorElementRect.top; + left -= anchorElementRect.left; + + floatingElem.style.opacity = '1'; + floatingElem.style.transform = `translate(${left}px, ${top}px)`; +} diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx index 74d52b08..a2d03ce2 100644 --- a/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/OnChangePlugin.tsx @@ -5,6 +5,7 @@ import {convertToRichTextData} from '../utils/convert-from-lexical.js'; import {convertToLexical} from '../utils/convert-to-lexical.js'; import {OnChangePlugin as LexicalOnChangePlugin} from '@lexical/react/LexicalOnChangePlugin'; import {EditorState} from 'lexical'; +import {debounce} from '../../../utils/debounce.js'; export interface OnChangePluginProps { value?: RichTextData | null; @@ -28,23 +29,22 @@ export function OnChangePlugin(props: OnChangePluginProps) { } }, [editor, props.value]); - function onChange(editorState: EditorState) { + // The tree conversion from lexical data to rich text data can be expensive, + // so debounce the updates after a short duration. + const onChange = debounce((editorState: EditorState) => { if (isUpdating) { setIsUpdating(false); return; } editorState.read(() => { - const richTextData = toRichTextData(); + const richTextData = convertToRichTextData(); + console.log(richTextData); setTimeSaved(richTextData?.time || 0); if (props.onChange) { props.onChange(richTextData); } }); - } + }, 500); return ; } - -function toRichTextData(): RichTextData | null { - return convertToRichTextData(); -} diff --git a/packages/root-cms/ui/components/RichTextEditor/plugins/ToolbarPlugin.tsx b/packages/root-cms/ui/components/RichTextEditor/plugins/ToolbarPlugin.tsx index e3194760..58c45fd2 100644 --- a/packages/root-cms/ui/components/RichTextEditor/plugins/ToolbarPlugin.tsx +++ b/packages/root-cms/ui/components/RichTextEditor/plugins/ToolbarPlugin.tsx @@ -378,8 +378,6 @@ export function ToolbarPlugin(props: ToolbarPluginProps) { } }, [activeEditor, setIsLinkEditMode, toolbarState.isLink]); - const canViewerSeeInsertDropdown = !toolbarState.isImageCaption; - return (
{toolbarState.blockType in TOOLBAR_BLOCK_LABELS && diff --git a/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts b/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts index 3a11460b..dacc48b4 100644 --- a/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts +++ b/packages/root-cms/ui/components/RichTextEditor/utils/convert-from-lexical.ts @@ -9,17 +9,26 @@ import { } from 'lexical'; import {$isHeadingNode} from '@lexical/rich-text'; import {$isListItemNode, $isListNode, ListItemNode, ListNode} from '@lexical/list'; +import {$isLinkNode} from '@lexical/link'; function extractTextNode(node: ElementNode) { const texts = node.getChildren().map(extractTextChild); return texts.join(''); } -function extractTextChild(node: LexicalNode) { +function extractTextChild(node: LexicalNode): string { if ($isLineBreakNode(node)) { return '
'; } + if ($isLinkNode(node)) { + console.log(node); + console.log('TODO handle link node'); + const href = node.getURL(); + return `${extractTextNode(node)}`; + } if (!$isTextNode(node)) { + console.log('unhandled node'); + console.log(node); return ''; } let text = node.getTextContent(); @@ -33,12 +42,23 @@ function extractTextChild(node: LexicalNode) { b: node.hasFormat('bold'), sup: node.hasFormat('superscript'), }; + return formatTextNode(text, formatTags); +} + +function formatTextNode(text: string, formatTags: Record) { + const segments: string[] = []; Object.entries(formatTags).forEach(([tag, enabled]) => { if (enabled) { - text = `<${tag}>${text}`; + segments.push(`<${tag}>`); } }); - return text; + segments.push(escapeHTML(text)); + Object.entries(formatTags).forEach(([tag, enabled]) => { + if (enabled) { + segments.push(``); + } + }); + return segments.join(''); } function extractListItems(node: ListNode): RichTextListItem[] { @@ -133,3 +153,12 @@ function testLastBlockIsEmpty(blocks: RichTextBlock[]) { } return false; } + +function escapeHTML(html: string) { + return html + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} From 2a69e58b74b3561e3e6c9b3af8cd0aad00528b29 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Tue, 3 Jun 2025 17:24:40 -0700 Subject: [PATCH 4/4] chore: add field editor --- packages/root-cms/shared/objects.ts | 30 ++++++ .../ui/components/FieldEditor/FieldEditor.css | 18 ++++ .../ui/components/FieldEditor/FieldEditor.tsx | 77 +++++++++++++ .../components/FieldEditor/fields/Field.tsx | 7 ++ .../FieldEditor/fields/StringField.css | 0 .../FieldEditor/fields/StringField.tsx | 53 +++++++++ .../FieldEditor/hooks/useFieldEditor.tsx | 56 ++++++++++ .../RichTextEditor/LexicalTheme.css | 1 + .../RichTextEditor/LexicalTheme.tsx | 24 +---- .../components/EmbedModal/EmbedModal.css | 7 ++ .../components/EmbedModal/EmbedModal.tsx | 78 ++++++++++++++ .../RichTextEditor/nodes/HTMLCodeNode.tsx | 102 ++++++++++++++++++ .../RichTextEditor/nodes/ImageNode.tsx | 0 .../RichTextEditor/plugins/ToolbarPlugin.tsx | 30 +++++- packages/root-cms/ui/ui.tsx | 2 + 15 files changed, 462 insertions(+), 23 deletions(-) create mode 100644 packages/root-cms/ui/components/FieldEditor/FieldEditor.css create mode 100644 packages/root-cms/ui/components/FieldEditor/FieldEditor.tsx create mode 100644 packages/root-cms/ui/components/FieldEditor/fields/Field.tsx create mode 100644 packages/root-cms/ui/components/FieldEditor/fields/StringField.css create mode 100644 packages/root-cms/ui/components/FieldEditor/fields/StringField.tsx create mode 100644 packages/root-cms/ui/components/FieldEditor/hooks/useFieldEditor.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/components/EmbedModal/EmbedModal.css create mode 100644 packages/root-cms/ui/components/RichTextEditor/components/EmbedModal/EmbedModal.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/nodes/HTMLCodeNode.tsx create mode 100644 packages/root-cms/ui/components/RichTextEditor/nodes/ImageNode.tsx diff --git a/packages/root-cms/shared/objects.ts b/packages/root-cms/shared/objects.ts index 904e64c3..0329d2da 100644 --- a/packages/root-cms/shared/objects.ts +++ b/packages/root-cms/shared/objects.ts @@ -1,3 +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/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 ( +