From fd1b9b296950ade023e75040d5a759d1430f8755 Mon Sep 17 00:00:00 2001 From: fantactuka Date: Tue, 29 Aug 2023 18:11:44 -0400 Subject: [PATCH] Add column layout plugin example --- packages/lexical-playground/src/Editor.tsx | 2 + .../src/images/icons/3-columns.svg | 3 + packages/lexical-playground/src/index.css | 39 +++-- .../src/nodes/KeywordNode.ts | 4 +- .../src/nodes/LayoutContainerNode.ts | 100 ++++++++++++ .../src/nodes/LayoutItemNode.ts | 71 +++++++++ .../src/nodes/PlaygroundNodes.ts | 4 + .../LayoutPlugin/InsertLayoutDialog.tsx | 56 +++++++ .../src/plugins/LayoutPlugin/LayoutPlugin.tsx | 142 ++++++++++++++++++ .../src/plugins/ToolbarPlugin/index.tsx | 14 ++ .../src/themes/PlaygroundEditorTheme.css | 9 ++ .../src/themes/PlaygroundEditorTheme.ts | 2 + 12 files changed, 429 insertions(+), 17 deletions(-) create mode 100644 packages/lexical-playground/src/images/icons/3-columns.svg create mode 100644 packages/lexical-playground/src/nodes/LayoutContainerNode.ts create mode 100644 packages/lexical-playground/src/nodes/LayoutItemNode.ts create mode 100644 packages/lexical-playground/src/plugins/LayoutPlugin/InsertLayoutDialog.tsx create mode 100644 packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 1b68acf46a9..82199abd711 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -52,6 +52,7 @@ import FloatingTextFormatToolbarPlugin from './plugins/FloatingTextFormatToolbar import ImagesPlugin from './plugins/ImagesPlugin'; import InlineImagePlugin from './plugins/InlineImagePlugin'; import KeywordsPlugin from './plugins/KeywordsPlugin'; +import {LayoutPlugin} from './plugins/LayoutPlugin/LayoutPlugin'; import LinkPlugin from './plugins/LinkPlugin'; import ListMaxIndentLevelPlugin from './plugins/ListMaxIndentLevelPlugin'; import MarkdownShortcutPlugin from './plugins/MarkdownShortcutPlugin'; @@ -225,6 +226,7 @@ export default function Editor(): JSX.Element { + {floatingAnchorElem && !isSmallWidthViewport && ( <> diff --git a/packages/lexical-playground/src/images/icons/3-columns.svg b/packages/lexical-playground/src/images/icons/3-columns.svg new file mode 100644 index 00000000000..06496e2e0aa --- /dev/null +++ b/packages/lexical-playground/src/images/icons/3-columns.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index d1a8727097b..6be2f318ee8 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -468,6 +468,10 @@ i.poll { background-image: url(images/icons/card-checklist.svg); } +i.columns { + background-image: url(images/icons/3-columns.svg); +} + i.tweet { background-image: url(images/icons/tweet.svg); } @@ -714,7 +718,7 @@ i.page-break, } .dropdown { - z-index: 10; + z-index: 100; display: block; position: fixed; box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1), @@ -1437,7 +1441,7 @@ button.action-button:disabled { z-index: 2; } -.toolbar button.toolbar-item { +button.toolbar-item { border: 0; display: flex; background: none; @@ -1450,15 +1454,15 @@ button.action-button:disabled { justify-content: space-between; } -.toolbar button.toolbar-item:disabled { +button.toolbar-item:disabled { cursor: not-allowed; } -.toolbar button.toolbar-item.spaced { +button.toolbar-item.spaced { margin-right: 2px; } -.toolbar button.toolbar-item i.format { +button.toolbar-item i.format { background-size: contain; display: inline-block; height: 18px; @@ -1468,26 +1472,26 @@ button.action-button:disabled { opacity: 0.6; } -.toolbar button.toolbar-item:disabled .icon, -.toolbar button.toolbar-item:disabled .text, -.toolbar button.toolbar-item:disabled i.format, -.toolbar button.toolbar-item:disabled .chevron-down { +button.toolbar-item:disabled .icon, +button.toolbar-item:disabled .text, +button.toolbar-item:disabled i.format, +button.toolbar-item:disabled .chevron-down { opacity: 0.2; } -.toolbar button.toolbar-item.active { +button.toolbar-item.active { background-color: rgba(223, 232, 250, 0.3); } -.toolbar button.toolbar-item.active i { +button.toolbar-item.active i { opacity: 1; } -.toolbar .toolbar-item:hover:not([disabled]) { +.toolbar-item:hover:not([disabled]) { background-color: #eee; } -.toolbar .toolbar-item.font-family .text { +.toolbar-item.font-family .text { display: block; max-width: 40px; } @@ -1519,7 +1523,8 @@ button.action-button:disabled { background-size: contain; } -.toolbar i.chevron-down { +.toolbar i.chevron-down, +.toolbar-item i.chevron-down { margin-top: 3px; width: 16px; height: 16px; @@ -1770,3 +1775,9 @@ hr.selected { visibility: hidden; } } + +.dialog-dropdown { + background-color: #eee !important; + margin-bottom: 10px; + width: 100%; +} diff --git a/packages/lexical-playground/src/nodes/KeywordNode.ts b/packages/lexical-playground/src/nodes/KeywordNode.ts index 3e603586929..9d179b8cf2d 100644 --- a/packages/lexical-playground/src/nodes/KeywordNode.ts +++ b/packages/lexical-playground/src/nodes/KeywordNode.ts @@ -62,8 +62,6 @@ export function $createKeywordNode(keyword: string): KeywordNode { return new KeywordNode(keyword); } -export function $isKeywordNode( - node: LexicalNode | null | undefined | undefined, -): boolean { +export function $isKeywordNode(node: LexicalNode | null | undefined): boolean { return node instanceof KeywordNode; } diff --git a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts new file mode 100644 index 00000000000..26eb02a695e --- /dev/null +++ b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts @@ -0,0 +1,100 @@ +/** + * 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 type { + DOMConversionMap, + EditorConfig, + LexicalNode, + NodeKey, + SerializedElementNode, + Spread, +} from 'lexical'; + +import {addClassNamesToElement} from '@lexical/utils'; +import {ElementNode} from 'lexical'; + +export type SerializedLayoutContainerNode = Spread< + { + templateColumns: string; + }, + SerializedElementNode +>; + +export class LayoutContainerNode extends ElementNode { + __templateColumns: string; + + constructor(templateColumns: string, key?: NodeKey) { + super(key); + this.__templateColumns = templateColumns; + } + + static getType(): string { + return 'layout-container'; + } + + static clone(node: LayoutContainerNode): LayoutContainerNode { + return new LayoutContainerNode(node.__templateColumns, node.__key); + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = document.createElement('div'); + dom.style.gridTemplateColumns = this.__templateColumns; + if (typeof config.theme.layoutContainer === 'string') { + addClassNamesToElement(dom, config.theme.layoutContainer); + } + return dom; + } + + updateDOM(prevNode: LayoutContainerNode, dom: HTMLElement): boolean { + if (prevNode.__templateColumns !== this.__templateColumns) { + dom.style.gridTemplateColumns = this.__templateColumns; + } + return false; + } + + static importDOM(): DOMConversionMap | null { + return {}; + } + + static importJSON(json: SerializedLayoutContainerNode): LayoutContainerNode { + return $createLayoutContainerNode(json.templateColumns); + } + + canBeEmpty(): boolean { + return false; + } + + exportJSON(): SerializedLayoutContainerNode { + return { + ...super.exportJSON(), + templateColumns: this.__templateColumns, + type: 'layout-container', + version: 1, + }; + } + + getTemplateColumns(): string { + return this.getLatest().__templateColumns; + } + + setTemplateColumns(templateColumns: string) { + this.getWritable().__templateColumns = templateColumns; + } +} + +export function $createLayoutContainerNode( + templateColumns: string, +): LayoutContainerNode { + return new LayoutContainerNode(templateColumns); +} + +export function $isLayoutContainerNode( + node: LexicalNode | null | undefined, +): node is LayoutContainerNode { + return node instanceof LayoutContainerNode; +} diff --git a/packages/lexical-playground/src/nodes/LayoutItemNode.ts b/packages/lexical-playground/src/nodes/LayoutItemNode.ts new file mode 100644 index 00000000000..d579b4ad6d8 --- /dev/null +++ b/packages/lexical-playground/src/nodes/LayoutItemNode.ts @@ -0,0 +1,71 @@ +/** + * 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 type { + DOMConversionMap, + EditorConfig, + LexicalNode, + SerializedElementNode, +} from 'lexical'; + +import {addClassNamesToElement} from '@lexical/utils'; +import {ElementNode} from 'lexical'; + +export type SerializedLayoutItemNode = SerializedElementNode; + +export class LayoutItemNode extends ElementNode { + static getType(): string { + return 'layout-item'; + } + + static clone(node: LayoutItemNode): LayoutItemNode { + return new LayoutItemNode(node.__key); + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = document.createElement('div'); + if (typeof config.theme.layoutItem === 'string') { + addClassNamesToElement(dom, config.theme.layoutItem); + } + return dom; + } + + updateDOM(): boolean { + return false; + } + + static importDOM(): DOMConversionMap | null { + return {}; + } + + static importJSON(): LayoutItemNode { + return $createLayoutItemNode(); + } + + isShadowRoot(): boolean { + return true; + } + + exportJSON(): SerializedLayoutItemNode { + return { + ...super.exportJSON(), + type: 'layout-item', + version: 1, + }; + } +} + +export function $createLayoutItemNode(): LayoutItemNode { + return new LayoutItemNode(); +} + +export function $isLayoutItemNode( + node: LexicalNode | null | undefined, +): node is LayoutItemNode { + return node instanceof LayoutItemNode; +} diff --git a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts index 90164d324da..8fb052f61c0 100644 --- a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts +++ b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts @@ -29,6 +29,8 @@ import {FigmaNode} from './FigmaNode'; import {ImageNode} from './ImageNode'; import {InlineImageNode} from './InlineImageNode'; import {KeywordNode} from './KeywordNode'; +import {LayoutContainerNode} from './LayoutContainerNode'; +import {LayoutItemNode} from './LayoutItemNode'; import {MentionNode} from './MentionNode'; import {PageBreakNode} from './PageBreakNode'; import {PollNode} from './PollNode'; @@ -71,6 +73,8 @@ const PlaygroundNodes: Array> = [ CollapsibleContentNode, CollapsibleTitleNode, PageBreakNode, + LayoutContainerNode, + LayoutItemNode, ]; export default PlaygroundNodes; diff --git a/packages/lexical-playground/src/plugins/LayoutPlugin/InsertLayoutDialog.tsx b/packages/lexical-playground/src/plugins/LayoutPlugin/InsertLayoutDialog.tsx new file mode 100644 index 00000000000..cf333bb8a45 --- /dev/null +++ b/packages/lexical-playground/src/plugins/LayoutPlugin/InsertLayoutDialog.tsx @@ -0,0 +1,56 @@ +/** + * 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 {LexicalEditor} from 'lexical'; +import * as React from 'react'; +import {useState} from 'react'; + +import Button from '../../ui/Button'; +import DropDown, {DropDownItem} from '../../ui/DropDown'; +import {INSERT_LAYOUT_COMMAND} from './LayoutPlugin'; + +const LAYOUTS = [ + {label: '2 columns (equal width)', value: '1fr 1fr'}, + {label: '2 columns (25% - 75%)', value: '1fr 3fr'}, + {label: '3 columns (equal width)', value: '1fr 1fr 1fr'}, + {label: '3 columns (25% - 50% - 25%)', value: '1fr 2fr 1fr'}, + {label: '4 columns (equal width)', value: '1fr 1fr 1fr 1fr'}, +]; + +export default function InsertLayoutDialog({ + activeEditor, + onClose, +}: { + activeEditor: LexicalEditor; + onClose: () => void; +}): JSX.Element { + const [layout, setLayout] = useState(LAYOUTS[0].value); + const buttonLabel = LAYOUTS.find((item) => item.value === layout)?.label; + + const onClick = () => { + activeEditor.dispatchCommand(INSERT_LAYOUT_COMMAND, layout); + onClose(); + }; + + return ( + <> + + {LAYOUTS.map(({label, value}) => ( + setLayout(value)}> + {label} + + ))} + + + + ); +} diff --git a/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx b/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx new file mode 100644 index 00000000000..182b415dde0 --- /dev/null +++ b/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx @@ -0,0 +1,142 @@ +/** + * 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 type {ElementNode, LexicalCommand, LexicalNode, NodeKey} from 'lexical'; + +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$insertNodeToNearestRoot, mergeRegister} from '@lexical/utils'; +import { + $createParagraphNode, + $getNodeByKey, + COMMAND_PRIORITY_EDITOR, + createCommand, +} from 'lexical'; +import {useEffect} from 'react'; + +import { + $createLayoutContainerNode, + $isLayoutContainerNode, + LayoutContainerNode, +} from '../../nodes/LayoutContainerNode'; +import { + $createLayoutItemNode, + $isLayoutItemNode, + LayoutItemNode, +} from '../../nodes/LayoutItemNode'; + +export const INSERT_LAYOUT_COMMAND: LexicalCommand = + createCommand(); + +export const UPDATE_LAYOUT_COMMAND: LexicalCommand<{ + template: string; + nodeKey: NodeKey; +}> = createCommand<{template: string; nodeKey: NodeKey}>(); + +export function LayoutPlugin(): null { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + if (!editor.hasNodes([LayoutContainerNode, LayoutItemNode])) { + throw new Error( + 'LayoutPlugin: LayoutContainerNode, or LayoutItemNode not registered on editor', + ); + } + + return mergeRegister( + editor.registerCommand( + INSERT_LAYOUT_COMMAND, + (template) => { + editor.update(() => { + const container = $createLayoutContainerNode(template); + const itemsCount = getItemsCountFromTemplate(template); + + for (let i = 0; i < itemsCount; i++) { + container.append( + $createLayoutItemNode().append($createParagraphNode()), + ); + } + + $insertNodeToNearestRoot(container); + container.selectStart(); + }); + + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + UPDATE_LAYOUT_COMMAND, + ({template, nodeKey}) => { + editor.update(() => { + const container = $getNodeByKey(nodeKey); + + if (!$isLayoutContainerNode(container)) { + return; + } + + const itemsCount = getItemsCountFromTemplate(template); + const prevItemsCount = getItemsCountFromTemplate( + container.getTemplateColumns(), + ); + + // Add or remove extra columns if new template does not match existing one + if (itemsCount > prevItemsCount) { + for (let i = prevItemsCount; i < itemsCount; i++) { + container.append( + $createLayoutItemNode().append($createParagraphNode()), + ); + } + } else if (itemsCount < prevItemsCount) { + for (let i = prevItemsCount; i < itemsCount; i++) { + const layoutItem = container.getChildAtIndex(i); + + if ($isLayoutItemNode(layoutItem)) { + for (const child of layoutItem.getChildren()) { + container.insertAfter(child); + } + } + } + } + + container.setTemplateColumns(template); + }); + + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + // Structure enforcing transformers for each node type. In case nesting structure is not + // "Container > Item" it'll unwrap nodes and convert it back + // to regular content. + editor.registerNodeTransform(LayoutItemNode, (node) => { + const parent = node.getParent(); + if (!$isLayoutContainerNode(parent)) { + const children = node.getChildren(); + for (const child of children) { + node.insertBefore(child); + } + node.remove(); + } + }), + editor.registerNodeTransform(LayoutContainerNode, (node) => { + const children = node.getChildren(); + if (!children.every($isLayoutItemNode)) { + for (const child of children) { + node.insertBefore(child); + } + node.remove(); + } + }), + ); + }, [editor]); + + return null; +} + +function getItemsCountFromTemplate(template: string): number { + return template.trim().split(/\s+/).length; +} diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx index bcfeb9e0cc0..ae6c6efd7e6 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx @@ -92,6 +92,7 @@ import { InsertImagePayload, } from '../ImagesPlugin'; import {InsertInlineImageDialog} from '../InlineImagePlugin'; +import InsertLayoutDialog from '../LayoutPlugin/InsertLayoutDialog'; import {INSERT_PAGE_BREAK} from '../PageBreakPlugin'; import {InsertPollDialog} from '../PollPlugin'; import {InsertNewTableDialog, InsertTableDialog} from '../TablePlugin'; @@ -1083,6 +1084,19 @@ export default function ToolbarPlugin(): JSX.Element { Poll + { + showModal('Insert Columns Layout', (onClose) => ( + + )); + }} + className="item"> + + Columns Layout + { diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css index d1d7514f09c..eba0ecc34e3 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css @@ -430,3 +430,12 @@ .PlaygroundEditorTheme__embedBlockFocus { outline: 2px solid rgb(60, 132, 244); } +.PlaygroundEditorTheme__layoutContaner { + display: grid; + gap: 10px; + margin: 10px 0; +} +.PlaygroundEditorTheme__layoutItem { + border: 1px dashed #ddd; + padding: 8px 16px; +} diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts index 48c43eecd06..8dd22731936 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts @@ -62,6 +62,8 @@ const theme: EditorThemeClasses = { image: 'editor-image', indent: 'PlaygroundEditorTheme__indent', inlineImage: 'inline-editor-image', + layoutContainer: 'PlaygroundEditorTheme__layoutContaner', + layoutItem: 'PlaygroundEditorTheme__layoutItem', link: 'PlaygroundEditorTheme__link', list: { listitem: 'PlaygroundEditorTheme__listItem',