refactor: blocks plugins (#1956)

This commit is contained in:
Mo
2022-11-08 13:31:48 -06:00
committed by GitHub
parent bfca244061
commit 70a9dbcea6
73 changed files with 1448 additions and 1049 deletions

View File

@@ -1,5 +1,4 @@
import {FunctionComponent, useCallback, useState} from 'react';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
@@ -20,27 +19,25 @@ import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
import {LinkPlugin} from '@lexical/react/LexicalLinkPlugin';
import {ListPlugin} from '@lexical/react/LexicalListPlugin';
import {EditorState, LexicalEditor} from 'lexical';
import ComponentPickerMenuPlugin from '../Lexical/Plugins/ComponentPickerPlugin';
import BlocksEditorTheme from '../Lexical/Theme/Theme';
import HorizontalRulePlugin from '../Lexical/Plugins/HorizontalRulePlugin';
import TwitterPlugin from '../Lexical/Plugins/TwitterPlugin';
import YouTubePlugin from '../Lexical/Plugins/YouTubePlugin';
import AutoEmbedPlugin from '../Lexical/Plugins/AutoEmbedPlugin';
import CollapsiblePlugin from '../Lexical/Plugins/CollapsiblePlugin';
import {BlockEditorNodes} from '../Lexical/Nodes/AllNodes';
// import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
import DraggableBlockPlugin from '../Lexical/Plugins/DraggableBlockPlugin';
const BlockDragEnabled = false;
type BlocksEditorProps = {
initialValue: string;
onChange: (value: string) => void;
className?: string;
children: React.ReactNode;
};
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
initialValue,
onChange,
className,
children,
}) => {
const handleChange = useCallback(
(editorState: EditorState, _editor: LexicalEditor) => {
@@ -60,56 +57,46 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
};
return (
<LexicalComposer
initialConfig={{
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
onError: (error: Error) => console.error(error),
editorState:
initialValue && initialValue.length > 0 ? initialValue : undefined,
nodes: BlockEditorNodes,
}}>
<>
<RichTextPlugin
contentEditable={
<div id="blocks-editor" className="editor-scroller">
<div className="editor" ref={onRef}>
<ContentEditable
className={`ContentEditable__root ${className}`}
/>
</div>
<>
{children}
<RichTextPlugin
contentEditable={
<div id="blocks-editor" className="editor-scroller">
<div className="editor" ref={onRef}>
<ContentEditable
className={`ContentEditable__root ${className}`}
/>
</div>
}
placeholder=""
ErrorBoundary={LexicalErrorBoundary}
/>
<ListPlugin />
<MarkdownShortcutPlugin
transformers={[
CHECK_LIST,
...ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
]}
/>
<TablePlugin />
<OnChangePlugin onChange={handleChange} />
<HistoryPlugin />
<HorizontalRulePlugin />
<AutoFocusPlugin />
<ComponentPickerMenuPlugin />
<ClearEditorPlugin />
<CheckListPlugin />
<LinkPlugin />
<HashtagPlugin />
<AutoEmbedPlugin />
<TwitterPlugin />
<YouTubePlugin />
<CollapsiblePlugin />
{floatingAnchorElem && (
<>{/* <DraggableBlockPlugin anchorElem={floatingAnchorElem} /> */}</>
)}
</>
</LexicalComposer>
</div>
}
placeholder=""
ErrorBoundary={LexicalErrorBoundary}
/>
<ListPlugin />
<MarkdownShortcutPlugin
transformers={[
CHECK_LIST,
...ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
]}
/>
<TablePlugin />
<OnChangePlugin onChange={handleChange} />
<HistoryPlugin />
<HorizontalRulePlugin />
<AutoFocusPlugin />
<ClearEditorPlugin />
<CheckListPlugin />
<LinkPlugin />
<HashtagPlugin />
<AutoEmbedPlugin />
<TwitterPlugin />
<YouTubePlugin />
<CollapsiblePlugin />
{floatingAnchorElem && BlockDragEnabled && (
<>{<DraggableBlockPlugin anchorElem={floatingAnchorElem} />}</>
)}
</>
);
};

View File

@@ -0,0 +1,29 @@
import {FunctionComponent} from 'react';
import {LexicalComposer} from '@lexical/react/LexicalComposer';
import BlocksEditorTheme from '../Lexical/Theme/Theme';
import {BlockEditorNodes} from '../Lexical/Nodes/AllNodes';
import {Klass, LexicalNode} from 'lexical';
type BlocksEditorComposerProps = {
initialValue: string;
children: React.ReactNode;
nodes: Array<Klass<LexicalNode>>;
};
export const BlocksEditorComposer: FunctionComponent<
BlocksEditorComposerProps
> = ({initialValue, children, nodes}) => {
return (
<LexicalComposer
initialConfig={{
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
onError: (error: Error) => console.error(error),
editorState:
initialValue && initialValue.length > 0 ? initialValue : undefined,
nodes: [...nodes, ...BlockEditorNodes],
}}>
<>{children}</>
</LexicalComposer>
);
};

View File

@@ -0,0 +1,19 @@
const classNames = (...values: (string | boolean | undefined)[]): string => {
return values
.map((value) => (typeof value === 'string' ? value : null))
.join(' ');
};
export const PopoverClassNames = classNames(
'typeahead-popover file-picker-menu absolute z-dropdown-menu flex w-full min-w-80',
'cursor-auto flex-col overflow-y-auto rounded bg-default md:h-auto md:max-w-xs h-auto overflow-y-scroll',
);
export const PopoverItemClassNames = classNames(
'flex w-full items-center text-base gap-4 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground',
'focus:bg-info-backdrop cursor-pointer m-0 focus:bg-contrast focus:text-foreground',
);
export const PopoverItemSelectedClassNames = classNames(
'bg-contrast text-foreground',
);

View File

@@ -0,0 +1,5 @@
import {createCommand, LexicalCommand} from 'lexical';
export const INSERT_FILE_COMMAND: LexicalCommand<string> = createCommand(
'INSERT_FILE_COMMAND',
);

View File

@@ -31,6 +31,7 @@ interface PlaygroundEmbedConfig extends EmbedConfig {
// Icon for display.
icon?: JSX.Element;
iconName: string;
// An example of a matching url https://twitter.com/jack/status/20
exampleUrl: string;
@@ -49,6 +50,7 @@ export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
// Icon for display.
icon: <i className="icon youtube" />,
iconName: 'youtube',
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id);
@@ -84,6 +86,7 @@ export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
// Icon for display.
icon: <i className="icon tweet" />,
iconName: 'tweet',
// Create the Lexical embed node from the url data.
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {

View File

@@ -1,357 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import {$createCodeNode} from '@lexical/code';
import {
INSERT_CHECK_LIST_COMMAND,
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
} from '@lexical/list';
import {INSERT_EMBED_COMMAND} from '@lexical/react/LexicalAutoEmbedPlugin';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {INSERT_HORIZONTAL_RULE_COMMAND} from '@lexical/react/LexicalHorizontalRuleNode';
import {
LexicalTypeaheadMenuPlugin,
TypeaheadOption,
useBasicTypeaheadTriggerMatch,
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text';
import {$wrapNodes} from '@lexical/selection';
import {INSERT_TABLE_COMMAND} from '@lexical/table';
import {
$createParagraphNode,
$getSelection,
$isRangeSelection,
FORMAT_ELEMENT_COMMAND,
TextNode,
} from 'lexical';
import {useCallback, useMemo, useState} from 'react';
import * as ReactDOM from 'react-dom';
import useModal from '../../Hooks/useModal';
import {EmbedConfigs} from '../AutoEmbedPlugin';
import {INSERT_COLLAPSIBLE_COMMAND} from '../CollapsiblePlugin';
import {InsertTableDialog} from '../TablePlugin';
class ComponentPickerOption extends TypeaheadOption {
// What shows up in the editor
title: string;
// Icon for display
icon?: JSX.Element;
// For extra searching.
keywords: Array<string>;
// TBD
keyboardShortcut?: string;
// What happens when you select this option?
onSelect: (queryString: string) => void;
constructor(
title: string,
options: {
icon?: JSX.Element;
keywords?: Array<string>;
keyboardShortcut?: string;
onSelect: (queryString: string) => void;
},
) {
super(title);
this.title = title;
this.keywords = options.keywords || [];
this.icon = options.icon;
this.keyboardShortcut = options.keyboardShortcut;
this.onSelect = options.onSelect.bind(this);
}
}
function ComponentPickerMenuItem({
index,
isSelected,
onClick,
onMouseEnter,
option,
}: {
index: number;
isSelected: boolean;
onClick: () => void;
onMouseEnter: () => void;
option: ComponentPickerOption;
}) {
let className = 'item';
if (isSelected) {
className += ' selected';
}
return (
<li
key={option.key}
tabIndex={-1}
className={className}
ref={option.setRefElement}
role="option"
aria-selected={isSelected}
id={'typeahead-item-' + index}
onMouseEnter={onMouseEnter}
onClick={onClick}>
{option.icon}
<span className="text">{option.title}</span>
</li>
);
}
export default function ComponentPickerMenuPlugin(): JSX.Element {
const [editor] = useLexicalComposerContext();
const [modal, showModal] = useModal();
const [queryString, setQueryString] = useState<string | null>(null);
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
});
const getDynamicOptions = useCallback(() => {
const options: Array<ComponentPickerOption> = [];
if (queryString == null) {
return options;
}
const fullTableRegex = new RegExp(/^([1-9]|10)x([1-9]|10)$/);
const partialTableRegex = new RegExp(/^([1-9]|10)x?$/);
const fullTableMatch = fullTableRegex.exec(queryString);
const partialTableMatch = partialTableRegex.exec(queryString);
if (fullTableMatch) {
const [rows, columns] = fullTableMatch[0]
.split('x')
.map((n: string) => parseInt(n, 10));
options.push(
new ComponentPickerOption(`${rows}x${columns} Table`, {
icon: <i className="icon table" />,
keywords: ['table'],
onSelect: () =>
// @ts-ignore Correct types, but since they're dynamic TS doesn't like it.
editor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows}),
}),
);
} else if (partialTableMatch) {
const rows = parseInt(partialTableMatch[0], 10);
options.push(
...Array.from({length: 5}, (_, i) => i + 1).map(
(columns) =>
new ComponentPickerOption(`${rows}x${columns} Table`, {
icon: <i className="icon table" />,
keywords: ['table'],
onSelect: () =>
// @ts-ignore Correct types, but since they're dynamic TS doesn't like it.
editor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows}),
}),
),
);
}
return options;
}, [editor, queryString]);
const options = useMemo(() => {
const baseOptions = [
new ComponentPickerOption('Paragraph', {
icon: <i className="icon paragraph" />,
keywords: ['normal', 'paragraph', 'p', 'text'],
onSelect: () =>
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createParagraphNode());
}
}),
}),
...Array.from({length: 3}, (_, i) => i + 1).map(
(n) =>
new ComponentPickerOption(`Heading ${n}`, {
icon: <i className={`icon h${n}`} />,
keywords: ['heading', 'header', `h${n}`],
onSelect: () =>
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () =>
// @ts-ignore Correct types, but since they're dynamic TS doesn't like it.
$createHeadingNode(`h${n}`),
);
}
}),
}),
),
new ComponentPickerOption('Table', {
icon: <i className="icon table" />,
keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'],
onSelect: () =>
showModal('Insert Table', (onClose) => (
<InsertTableDialog activeEditor={editor} onClose={onClose} />
)),
}),
new ComponentPickerOption('Numbered List', {
icon: <i className="icon number" />,
keywords: ['numbered list', 'ordered list', 'ol'],
onSelect: () =>
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
}),
new ComponentPickerOption('Bulleted List', {
icon: <i className="icon bullet" />,
keywords: ['bulleted list', 'unordered list', 'ul'],
onSelect: () =>
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
}),
new ComponentPickerOption('Check List', {
icon: <i className="icon check" />,
keywords: ['check list', 'todo list'],
onSelect: () =>
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
}),
new ComponentPickerOption('Quote', {
icon: <i className="icon quote" />,
keywords: ['block quote'],
onSelect: () =>
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createQuoteNode());
}
}),
}),
new ComponentPickerOption('Code', {
icon: <i className="icon code" />,
keywords: ['javascript', 'python', 'js', 'codeblock'],
onSelect: () =>
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
if (selection.isCollapsed()) {
$wrapNodes(selection, () => $createCodeNode());
} else {
// Will this ever happen?
const textContent = selection.getTextContent();
const codeNode = $createCodeNode();
selection.insertNodes([codeNode]);
selection.insertRawText(textContent);
}
}
}),
}),
new ComponentPickerOption('Divider', {
icon: <i className="icon horizontal-rule" />,
keywords: ['horizontal rule', 'divider', 'hr'],
onSelect: () =>
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
}),
...EmbedConfigs.map(
(embedConfig) =>
new ComponentPickerOption(`Embed ${embedConfig.contentName}`, {
icon: embedConfig.icon,
keywords: [...embedConfig.keywords, 'embed'],
onSelect: () =>
editor.dispatchCommand(INSERT_EMBED_COMMAND, embedConfig.type),
}),
),
new ComponentPickerOption('Collapsible', {
icon: <i className="icon caret-right" />,
keywords: ['collapse', 'collapsible', 'toggle'],
onSelect: () =>
editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined),
}),
...['left', 'center', 'right', 'justify'].map(
(alignment) =>
new ComponentPickerOption(`Align ${alignment}`, {
icon: <i className={`icon ${alignment}-align`} />,
keywords: ['align', 'justify', alignment],
onSelect: () =>
// @ts-ignore Correct types, but since they're dynamic TS doesn't like it.
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment),
}),
),
];
const dynamicOptions = getDynamicOptions();
return queryString
? [
...dynamicOptions,
...baseOptions.filter((option) => {
return new RegExp(queryString, 'gi').exec(option.title) ||
option.keywords != null
? option.keywords.some((keyword) =>
new RegExp(queryString, 'gi').exec(keyword),
)
: false;
}),
]
: baseOptions;
}, [editor, getDynamicOptions, queryString, showModal]);
const onSelectOption = useCallback(
(
selectedOption: ComponentPickerOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => {
editor.update(() => {
if (nodeToRemove) {
nodeToRemove.remove();
}
selectedOption.onSelect(matchingString);
closeMenu();
});
},
[editor],
);
return (
<>
{modal}
<LexicalTypeaheadMenuPlugin<ComponentPickerOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
menuRenderFn={(
anchorElementRef,
{selectedIndex, selectOptionAndCleanUp, setHighlightedIndex},
) =>
anchorElementRef.current && options.length
? ReactDOM.createPortal(
<div className="typeahead-popover component-picker-menu">
<ul>
{options.map((option, i: number) => (
<ComponentPickerMenuItem
index={i}
isSelected={selectedIndex === i}
onClick={() => {
setHighlightedIndex(i);
selectOptionAndCleanUp(option);
}}
onMouseEnter={() => {
setHighlightedIndex(i);
}}
key={option.key}
option={option}
/>
))}
</ul>
</div>,
anchorElementRef.current,
)
: null
}
/>
</>
);
}

View File

@@ -1,93 +0,0 @@
.typeahead-popover {
background: #fff;
box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
border-radius: 8px;
margin-top: 25px;
}
.typeahead-popover ul {
padding: 0;
list-style: none;
margin: 0;
border-radius: 8px;
max-height: 200px;
overflow-y: scroll;
}
.typeahead-popover ul::-webkit-scrollbar {
display: none;
}
.typeahead-popover ul {
-ms-overflow-style: none;
scrollbar-width: none;
}
.typeahead-popover ul li {
margin: 0;
min-width: 180px;
font-size: 14px;
outline: none;
cursor: pointer;
border-radius: 8px;
}
.typeahead-popover ul li.selected {
background: #eee;
}
.typeahead-popover li {
margin: 0 8px 0 8px;
padding: 8px;
color: #050505;
cursor: pointer;
line-height: 16px;
font-size: 15px;
display: flex;
align-content: center;
flex-direction: row;
flex-shrink: 0;
background-color: #fff;
border-radius: 8px;
border: 0;
}
.typeahead-popover li.active {
display: flex;
width: 20px;
height: 20px;
background-size: contain;
}
.typeahead-popover li:first-child {
border-radius: 8px 8px 0px 0px;
}
.typeahead-popover li:last-child {
border-radius: 0px 0px 8px 8px;
}
.typeahead-popover li:hover {
background-color: #eee;
}
.typeahead-popover li .text {
display: flex;
line-height: 20px;
flex-grow: 1;
min-width: 150px;
}
.typeahead-popover li .icon {
display: flex;
width: 20px;
height: 20px;
user-select: none;
margin-right: 8px;
line-height: 16px;
background-size: contain;
}
.component-picker-menu {
width: 200px;
}

View File

@@ -21,13 +21,13 @@
}
.Lexical__h1 {
font-size: 24px;
color: rgb(5, 5, 5);
color: var(--sn-stylekit-editor-foreground-color);
font-weight: 400;
margin: 0;
}
.Lexical__h2 {
font-size: 15px;
color: rgb(101, 103, 107);
color: var(--sn-stylekit-editor-foreground-color);
font-weight: 700;
margin: 0;
text-transform: uppercase;

View File

@@ -1,5 +1,4 @@
@import 'base';
@import 'component_picker';
@import 'custom';
@import 'editor';
@import 'icons';

View File

@@ -1 +1,4 @@
export * from './Editor/BlocksEditor';
export * from './Editor/BlocksEditorComposer';
export * from './Editor/Commands';
export * from './Editor/ClassNames';