From dc60dab969dc68e9e31c56904cb9a5ae380c6c7d Mon Sep 17 00:00:00 2001 From: Lei OT Date: Thu, 21 Nov 2024 16:53:42 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E7=BC=96=E8=BE=91=E5=99=A8:=20?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E5=AE=BD=E5=BA=A6=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/images/icons/plus.svg | 1 + src/components/LexicalEditor/Index.jsx | 16 +- .../plugins/TableActionMenuPlugin/index.tsx | 773 ++++++++++++++++++ .../plugins/TableCellResizer/index.css | 13 + .../plugins/TableCellResizer/index.tsx | 439 ++++++++++ src/components/LexicalEditor/styles.css | 51 ++ .../LexicalEditor/themes/ExampleTheme.js | 23 +- .../themes/PlaygroundEditorTheme.css | 467 +++++++++++ 8 files changed, 1773 insertions(+), 10 deletions(-) create mode 100644 public/images/icons/plus.svg create mode 100644 src/components/LexicalEditor/plugins/TableActionMenuPlugin/index.tsx create mode 100644 src/components/LexicalEditor/plugins/TableCellResizer/index.css create mode 100644 src/components/LexicalEditor/plugins/TableCellResizer/index.tsx create mode 100644 src/components/LexicalEditor/themes/PlaygroundEditorTheme.css diff --git a/public/images/icons/plus.svg b/public/images/icons/plus.svg new file mode 100644 index 0000000..2f89926 --- /dev/null +++ b/public/images/icons/plus.svg @@ -0,0 +1 @@ + diff --git a/src/components/LexicalEditor/Index.jsx b/src/components/LexicalEditor/Index.jsx index 84a1bce..60434cf 100644 --- a/src/components/LexicalEditor/Index.jsx +++ b/src/components/LexicalEditor/Index.jsx @@ -38,12 +38,12 @@ import { ExtendedTextNode } from './nodes/ExtendedTextNode'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; // import { useLexicalEditable } from '@lexical/react/useLexicalEditable'; -// import {TablePlugin} from '@lexical/react/LexicalTablePlugin'; -// import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin'; -// import TableCellResizer from './plugins/TableCellResizer'; +import {TablePlugin} from '@lexical/react/LexicalTablePlugin'; +import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin'; +import TableCellResizer from './plugins/TableCellResizer'; // import TableHoverActionsPlugin from './plugins/TableHoverActionsPlugin'; -import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; +// import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import { TextNode, $getRoot, $getSelection, $createParagraphNode } from 'lexical'; @@ -190,13 +190,13 @@ export default function Editor({ isRichText, isDebug, editorRef, onChange, defau - {/* */} - {/* */} + + {/* */} - {/* */} + /> diff --git a/src/components/LexicalEditor/plugins/TableActionMenuPlugin/index.tsx b/src/components/LexicalEditor/plugins/TableActionMenuPlugin/index.tsx new file mode 100644 index 0000000..60a09ab --- /dev/null +++ b/src/components/LexicalEditor/plugins/TableActionMenuPlugin/index.tsx @@ -0,0 +1,773 @@ +/** + * 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, LexicalEditor} from 'lexical'; + +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; +import { + $deleteTableColumn__EXPERIMENTAL, + $deleteTableRow__EXPERIMENTAL, + $getNodeTriplet, + $getTableCellNodeFromLexicalNode, + $getTableColumnIndexFromTableCellNode, + $getTableNodeFromLexicalNodeOrThrow, + $getTableRowIndexFromTableCellNode, + $insertTableColumn__EXPERIMENTAL, + $insertTableRow__EXPERIMENTAL, + $isTableCellNode, + $isTableRowNode, + $isTableSelection, + $unmergeCell, + getTableObserverFromTableElement, + HTMLTableElementWithWithTableSelectionState, + TableCellHeaderStates, + TableCellNode, + TableRowNode, + TableSelection, +} from '@lexical/table'; +import { + $createParagraphNode, + $getRoot, + $getSelection, + $isElementNode, + $isParagraphNode, + $isRangeSelection, + $isTextNode, +} from 'lexical'; +import * as React from 'react'; +import {ReactPortal, useCallback, useEffect, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; + +import useModal from '../../hooks/useModal'; +import ColorPicker from '../../ui/ColorPicker'; + +function computeSelectionCount(selection: TableSelection): { + columns: number; + rows: number; +} { + const selectionShape = selection.getShape(); + return { + columns: selectionShape.toX - selectionShape.fromX + 1, + rows: selectionShape.toY - selectionShape.fromY + 1, + }; +} + +function $canUnmerge(): boolean { + const selection = $getSelection(); + if ( + ($isRangeSelection(selection) && !selection.isCollapsed()) || + ($isTableSelection(selection) && !selection.anchor.is(selection.focus)) || + (!$isRangeSelection(selection) && !$isTableSelection(selection)) + ) { + return false; + } + const [cell] = $getNodeTriplet(selection.anchor); + return cell.__colSpan > 1 || cell.__rowSpan > 1; +} + +function $cellContainsEmptyParagraph(cell: TableCellNode): boolean { + if (cell.getChildrenSize() !== 1) { + return false; + } + const firstChild = cell.getFirstChildOrThrow(); + if (!$isParagraphNode(firstChild) || !firstChild.isEmpty()) { + return false; + } + return true; +} + +function $selectLastDescendant(node: ElementNode): void { + const lastDescendant = node.getLastDescendant(); + if ($isTextNode(lastDescendant)) { + lastDescendant.select(); + } else if ($isElementNode(lastDescendant)) { + lastDescendant.selectEnd(); + } else if (lastDescendant !== null) { + lastDescendant.selectNext(); + } +} + +function currentCellBackgroundColor(editor: LexicalEditor): null | string { + return editor.getEditorState().read(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) || $isTableSelection(selection)) { + const [cell] = $getNodeTriplet(selection.anchor); + if ($isTableCellNode(cell)) { + return cell.getBackgroundColor(); + } + } + return null; + }); +} + +type TableCellActionMenuProps = Readonly<{ + contextRef: {current: null | HTMLElement}; + onClose: () => void; + setIsMenuOpen: (isOpen: boolean) => void; + showColorPickerModal: ( + title: string, + showModal: (onClose: () => void) => JSX.Element, + ) => void; + tableCellNode: TableCellNode; + cellMerge: boolean; +}>; + +function TableActionMenu({ + onClose, + tableCellNode: _tableCellNode, + setIsMenuOpen, + contextRef, + cellMerge, + showColorPickerModal, +}: TableCellActionMenuProps) { + const [editor] = useLexicalComposerContext(); + const dropDownRef = useRef(null); + const [tableCellNode, updateTableCellNode] = useState(_tableCellNode); + const [selectionCounts, updateSelectionCounts] = useState({ + columns: 1, + rows: 1, + }); + const [canMergeCells, setCanMergeCells] = useState(false); + const [canUnmergeCell, setCanUnmergeCell] = useState(false); + const [backgroundColor, setBackgroundColor] = useState( + () => currentCellBackgroundColor(editor) || '', + ); + + useEffect(() => { + return editor.registerMutationListener( + TableCellNode, + (nodeMutations) => { + const nodeUpdated = + nodeMutations.get(tableCellNode.getKey()) === 'updated'; + + if (nodeUpdated) { + editor.getEditorState().read(() => { + updateTableCellNode(tableCellNode.getLatest()); + }); + setBackgroundColor(currentCellBackgroundColor(editor) || ''); + } + }, + {skipInitialization: true}, + ); + }, [editor, tableCellNode]); + + useEffect(() => { + editor.getEditorState().read(() => { + const selection = $getSelection(); + // Merge cells + if ($isTableSelection(selection)) { + const currentSelectionCounts = computeSelectionCount(selection); + updateSelectionCounts(computeSelectionCount(selection)); + setCanMergeCells( + currentSelectionCounts.columns > 1 || currentSelectionCounts.rows > 1, + ); + } + // Unmerge cell + setCanUnmergeCell($canUnmerge()); + }); + }, [editor]); + + useEffect(() => { + const menuButtonElement = contextRef.current; + const dropDownElement = dropDownRef.current; + const rootElement = editor.getRootElement(); + + if ( + menuButtonElement != null && + dropDownElement != null && + rootElement != null + ) { + const rootEleRect = rootElement.getBoundingClientRect(); + const menuButtonRect = menuButtonElement.getBoundingClientRect(); + dropDownElement.style.opacity = '1'; + const dropDownElementRect = dropDownElement.getBoundingClientRect(); + const margin = 5; + let leftPosition = menuButtonRect.right + margin; + if ( + leftPosition + dropDownElementRect.width > window.innerWidth || + leftPosition + dropDownElementRect.width > rootEleRect.right + ) { + const position = + menuButtonRect.left - dropDownElementRect.width - margin; + leftPosition = (position < 0 ? margin : position) + window.pageXOffset; + } + dropDownElement.style.left = `${leftPosition + window.pageXOffset}px`; + + let topPosition = menuButtonRect.top; + if (topPosition + dropDownElementRect.height > window.innerHeight) { + const position = menuButtonRect.bottom - dropDownElementRect.height; + topPosition = (position < 0 ? margin : position) + window.pageYOffset; + } + dropDownElement.style.top = `${topPosition + +window.pageYOffset}px`; + } + }, [contextRef, dropDownRef, editor]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + dropDownRef.current != null && + contextRef.current != null && + !dropDownRef.current.contains(event.target as Node) && + !contextRef.current.contains(event.target as Node) + ) { + setIsMenuOpen(false); + } + } + + window.addEventListener('click', handleClickOutside); + + return () => window.removeEventListener('click', handleClickOutside); + }, [setIsMenuOpen, contextRef]); + + const clearTableSelection = useCallback(() => { + editor.update(() => { + if (tableCellNode.isAttached()) { + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + const tableElement = editor.getElementByKey( + tableNode.getKey(), + ) as HTMLTableElementWithWithTableSelectionState; + + if (!tableElement) { + throw new Error('Expected to find tableElement in DOM'); + } + + const tableObserver = getTableObserverFromTableElement(tableElement); + if (tableObserver !== null) { + tableObserver.clearHighlight(); + } + + tableNode.markDirty(); + updateTableCellNode(tableCellNode.getLatest()); + } + + const rootNode = $getRoot(); + rootNode.selectStart(); + }); + }, [editor, tableCellNode]); + + const mergeTableCellsAtSelection = () => { + editor.update(() => { + const selection = $getSelection(); + if ($isTableSelection(selection)) { + const {columns, rows} = computeSelectionCount(selection); + const nodes = selection.getNodes(); + let firstCell: null | TableCellNode = null; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ($isTableCellNode(node)) { + if (firstCell === null) { + node.setColSpan(columns).setRowSpan(rows); + firstCell = node; + const isEmpty = $cellContainsEmptyParagraph(node); + let firstChild; + if ( + isEmpty && + $isParagraphNode((firstChild = node.getFirstChild())) + ) { + firstChild.remove(); + } + } else if ($isTableCellNode(firstCell)) { + const isEmpty = $cellContainsEmptyParagraph(node); + if (!isEmpty) { + firstCell.append(...node.getChildren()); + } + node.remove(); + } + } + } + if (firstCell !== null) { + if (firstCell.getChildrenSize() === 0) { + firstCell.append($createParagraphNode()); + } + $selectLastDescendant(firstCell); + } + onClose(); + } + }); + }; + + const unmergeTableCellsAtSelection = () => { + editor.update(() => { + $unmergeCell(); + }); + }; + + const insertTableRowAtSelection = useCallback( + (shouldInsertAfter: boolean) => { + editor.update(() => { + $insertTableRow__EXPERIMENTAL(shouldInsertAfter); + onClose(); + }); + }, + [editor, onClose], + ); + + const insertTableColumnAtSelection = useCallback( + (shouldInsertAfter: boolean) => { + editor.update(() => { + for (let i = 0; i < selectionCounts.columns; i++) { + $insertTableColumn__EXPERIMENTAL(shouldInsertAfter); + } + onClose(); + }); + }, + [editor, onClose, selectionCounts.columns], + ); + + const deleteTableRowAtSelection = useCallback(() => { + editor.update(() => { + $deleteTableRow__EXPERIMENTAL(); + onClose(); + }); + }, [editor, onClose]); + + const deleteTableAtSelection = useCallback(() => { + editor.update(() => { + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + tableNode.remove(); + + clearTableSelection(); + onClose(); + }); + }, [editor, tableCellNode, clearTableSelection, onClose]); + + const deleteTableColumnAtSelection = useCallback(() => { + editor.update(() => { + $deleteTableColumn__EXPERIMENTAL(); + onClose(); + }); + }, [editor, onClose]); + + const toggleTableRowIsHeader = useCallback(() => { + editor.update(() => { + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + + const tableRowIndex = $getTableRowIndexFromTableCellNode(tableCellNode); + + const tableRows = tableNode.getChildren(); + + if (tableRowIndex >= tableRows.length || tableRowIndex < 0) { + throw new Error('Expected table cell to be inside of table row.'); + } + + const tableRow = tableRows[tableRowIndex]; + + if (!$isTableRowNode(tableRow)) { + throw new Error('Expected table row'); + } + + const newStyle = + tableCellNode.getHeaderStyles() ^ TableCellHeaderStates.ROW; + tableRow.getChildren().forEach((tableCell) => { + if (!$isTableCellNode(tableCell)) { + throw new Error('Expected table cell'); + } + + tableCell.setHeaderStyles(newStyle, TableCellHeaderStates.ROW); + }); + + clearTableSelection(); + onClose(); + }); + }, [editor, tableCellNode, clearTableSelection, onClose]); + + const toggleTableColumnIsHeader = useCallback(() => { + editor.update(() => { + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + + const tableColumnIndex = + $getTableColumnIndexFromTableCellNode(tableCellNode); + + const tableRows = tableNode.getChildren(); + const maxRowsLength = Math.max( + ...tableRows.map((row) => row.getChildren().length), + ); + + if (tableColumnIndex >= maxRowsLength || tableColumnIndex < 0) { + throw new Error('Expected table cell to be inside of table row.'); + } + + const newStyle = + tableCellNode.getHeaderStyles() ^ TableCellHeaderStates.COLUMN; + for (let r = 0; r < tableRows.length; r++) { + const tableRow = tableRows[r]; + + if (!$isTableRowNode(tableRow)) { + throw new Error('Expected table row'); + } + + const tableCells = tableRow.getChildren(); + if (tableColumnIndex >= tableCells.length) { + // if cell is outside of bounds for the current row (for example various merge cell cases) we shouldn't highlight it + continue; + } + + const tableCell = tableCells[tableColumnIndex]; + + if (!$isTableCellNode(tableCell)) { + throw new Error('Expected table cell'); + } + + tableCell.setHeaderStyles(newStyle, TableCellHeaderStates.COLUMN); + } + clearTableSelection(); + onClose(); + }); + }, [editor, tableCellNode, clearTableSelection, onClose]); + + const toggleRowStriping = useCallback(() => { + editor.update(() => { + if (tableCellNode.isAttached()) { + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + if (tableNode) { + tableNode.setRowStriping(!tableNode.getRowStriping()); + } + } + clearTableSelection(); + onClose(); + }); + }, [editor, tableCellNode, clearTableSelection, onClose]); + + const handleCellBackgroundColor = useCallback( + (value: string) => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection) || $isTableSelection(selection)) { + const [cell] = $getNodeTriplet(selection.anchor); + if ($isTableCellNode(cell)) { + cell.setBackgroundColor(value); + } + + if ($isTableSelection(selection)) { + const nodes = selection.getNodes(); + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if ($isTableCellNode(node)) { + node.setBackgroundColor(value); + } + } + } + } + }); + }, + [editor], + ); + + let mergeCellButton: null | JSX.Element = null; + if (cellMerge) { + if (canMergeCells) { + mergeCellButton = ( + + ); + } else if (canUnmergeCell) { + mergeCellButton = ( + + ); + } + } + + return createPortal( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions +
{ + e.stopPropagation(); + }}> + {mergeCellButton} + + +
+ + +
+ + +
+ + + +
+ + +
, + document.body, + ); +} + +function TableCellActionMenuContainer({ + anchorElem, + cellMerge, +}: { + anchorElem: HTMLElement; + cellMerge: boolean; +}): JSX.Element { + const [editor] = useLexicalComposerContext(); + + const menuButtonRef = useRef(null); + const menuRootRef = useRef(null); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const [tableCellNode, setTableMenuCellNode] = useState( + null, + ); + + const [colorPickerModal, showColorPickerModal] = useModal(); + + const $moveMenu = useCallback(() => { + const menu = menuButtonRef.current; + const selection = $getSelection(); + const nativeSelection = window.getSelection(); + const activeElement = document.activeElement; + + if (selection == null || menu == null) { + setTableMenuCellNode(null); + return; + } + + const rootElement = editor.getRootElement(); + + if ( + $isRangeSelection(selection) && + rootElement !== null && + nativeSelection !== null && + rootElement.contains(nativeSelection.anchorNode) + ) { + const tableCellNodeFromSelection = $getTableCellNodeFromLexicalNode( + selection.anchor.getNode(), + ); + + if (tableCellNodeFromSelection == null) { + setTableMenuCellNode(null); + return; + } + + const tableCellParentNodeDOM = editor.getElementByKey( + tableCellNodeFromSelection.getKey(), + ); + + if (tableCellParentNodeDOM == null) { + setTableMenuCellNode(null); + return; + } + + setTableMenuCellNode(tableCellNodeFromSelection); + } else if (!activeElement) { + setTableMenuCellNode(null); + } + }, [editor]); + + useEffect(() => { + return editor.registerUpdateListener(() => { + editor.getEditorState().read(() => { + $moveMenu(); + }); + }); + }); + + useEffect(() => { + const menuButtonDOM = menuButtonRef.current as HTMLButtonElement | null; + + if (menuButtonDOM != null && tableCellNode != null) { + const tableCellNodeDOM = editor.getElementByKey(tableCellNode.getKey()); + + if (tableCellNodeDOM != null) { + const tableCellRect = tableCellNodeDOM.getBoundingClientRect(); + const menuRect = menuButtonDOM.getBoundingClientRect(); + const anchorRect = anchorElem.getBoundingClientRect(); + + const top = tableCellRect.top - anchorRect.top + 4; + const left = + tableCellRect.right - menuRect.width - 10 - anchorRect.left; + + menuButtonDOM.style.opacity = '1'; + menuButtonDOM.style.transform = `translate(${left}px, ${top}px)`; + } else { + menuButtonDOM.style.opacity = '0'; + menuButtonDOM.style.transform = 'translate(-10000px, -10000px)'; + } + } + }, [menuButtonRef, tableCellNode, editor, anchorElem]); + + const prevTableCellDOM = useRef(tableCellNode); + + useEffect(() => { + if (prevTableCellDOM.current !== tableCellNode) { + setIsMenuOpen(false); + } + + prevTableCellDOM.current = tableCellNode; + }, [prevTableCellDOM, tableCellNode]); + + return ( +
+ {tableCellNode != null && ( + <> + + {colorPickerModal} + {isMenuOpen && ( + setIsMenuOpen(false)} + tableCellNode={tableCellNode} + cellMerge={cellMerge} + showColorPickerModal={showColorPickerModal} + /> + )} + + )} +
+ ); +} + +export default function TableActionMenuPlugin({ + anchorElem = document.body, + cellMerge = false, +}: { + anchorElem?: HTMLElement; + cellMerge?: boolean; +}): null | ReactPortal { + const isEditable = useLexicalEditable(); + return createPortal( + isEditable ? ( + + ) : null, + anchorElem, + ); +} diff --git a/src/components/LexicalEditor/plugins/TableCellResizer/index.css b/src/components/LexicalEditor/plugins/TableCellResizer/index.css new file mode 100644 index 0000000..809937b --- /dev/null +++ b/src/components/LexicalEditor/plugins/TableCellResizer/index.css @@ -0,0 +1,13 @@ +/** + * 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. + * + * + */ + +.TableCellResizer__resizer { + position: absolute; + z-index: 1202; +} diff --git a/src/components/LexicalEditor/plugins/TableCellResizer/index.tsx b/src/components/LexicalEditor/plugins/TableCellResizer/index.tsx new file mode 100644 index 0000000..b9f7ce9 --- /dev/null +++ b/src/components/LexicalEditor/plugins/TableCellResizer/index.tsx @@ -0,0 +1,439 @@ +/** + * 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 {TableCellNode, TableDOMCell, TableMapType} from '@lexical/table'; +import type {LexicalEditor} from 'lexical'; + +import './index.css'; + +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; +import { + $computeTableMapSkipCellCheck, + $getTableNodeFromLexicalNodeOrThrow, + $getTableRowIndexFromTableCellNode, + $isTableCellNode, + $isTableRowNode, + getDOMCellFromTarget, + TableNode, +} from '@lexical/table'; +import {calculateZoomLevel} from '@lexical/utils'; +import {$getNearestNodeFromDOMNode} from 'lexical'; +import * as React from 'react'; +import { + MouseEventHandler, + ReactPortal, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import {createPortal} from 'react-dom'; + +type MousePosition = { + x: number; + y: number; +}; + +type MouseDraggingDirection = 'right' | 'bottom'; + +const MIN_ROW_HEIGHT = 33; +const MIN_COLUMN_WIDTH = 92; + +function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { + const targetRef = useRef(null); + const resizerRef = useRef(null); + const tableRectRef = useRef(null); + + const mouseStartPosRef = useRef(null); + const [mouseCurrentPos, updateMouseCurrentPos] = + useState(null); + + const [activeCell, updateActiveCell] = useState(null); + const [isMouseDown, updateIsMouseDown] = useState(false); + const [draggingDirection, updateDraggingDirection] = + useState(null); + + const resetState = useCallback(() => { + updateActiveCell(null); + targetRef.current = null; + updateDraggingDirection(null); + mouseStartPosRef.current = null; + tableRectRef.current = null; + }, []); + + const isMouseDownOnEvent = (event: MouseEvent) => { + return (event.buttons & 1) === 1; + }; + + useEffect(() => { + return editor.registerNodeTransform(TableNode, (tableNode) => { + console.dir(TableNode); + console.dir(tableNode); + + if (tableNode.getColWidths()) { + return tableNode; + } + + const numColumns = tableNode.getColumnCount(); + const columnWidth = MIN_COLUMN_WIDTH; + + tableNode.setColWidths(Array(numColumns).fill(columnWidth)); + return tableNode; + }); + }, [editor]); + + useEffect(() => { + const onMouseMove = (event: MouseEvent) => { + setTimeout(() => { + const target = event.target; + + if (draggingDirection) { + updateMouseCurrentPos({ + x: event.clientX, + y: event.clientY, + }); + return; + } + updateIsMouseDown(isMouseDownOnEvent(event)); + if (resizerRef.current && resizerRef.current.contains(target as Node)) { + return; + } + + if (targetRef.current !== target) { + targetRef.current = target as HTMLElement; + const cell = getDOMCellFromTarget(target as HTMLElement); + + if (cell && activeCell !== cell) { + editor.update(() => { + const tableCellNode = $getNearestNodeFromDOMNode(cell.elem); + if (!tableCellNode) { + throw new Error('TableCellResizer: Table cell node not found.'); + } + + const tableNode = + $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + const tableElement = editor.getElementByKey(tableNode.getKey()); + + if (!tableElement) { + throw new Error('TableCellResizer: Table element not found.'); + } + + targetRef.current = target as HTMLElement; + tableRectRef.current = tableElement.getBoundingClientRect(); + updateActiveCell(cell); + }); + } else if (cell == null) { + resetState(); + } + } + }, 0); + }; + + const onMouseDown = (event: MouseEvent) => { + setTimeout(() => { + updateIsMouseDown(true); + }, 0); + }; + + const onMouseUp = (event: MouseEvent) => { + setTimeout(() => { + updateIsMouseDown(false); + }, 0); + }; + + const removeRootListener = editor.registerRootListener( + (rootElement, prevRootElement) => { + prevRootElement?.removeEventListener('mousemove', onMouseMove); + prevRootElement?.removeEventListener('mousedown', onMouseDown); + prevRootElement?.removeEventListener('mouseup', onMouseUp); + rootElement?.addEventListener('mousemove', onMouseMove); + rootElement?.addEventListener('mousedown', onMouseDown); + rootElement?.addEventListener('mouseup', onMouseUp); + }, + ); + + return () => { + removeRootListener(); + }; + }, [activeCell, draggingDirection, editor, resetState]); + + const isHeightChanging = (direction: MouseDraggingDirection) => { + if (direction === 'bottom') { + return true; + } + return false; + }; + + const updateRowHeight = useCallback( + (heightChange: number) => { + if (!activeCell) { + throw new Error('TableCellResizer: Expected active cell.'); + } + + editor.update( + () => { + const tableCellNode = $getNearestNodeFromDOMNode(activeCell.elem); + if (!$isTableCellNode(tableCellNode)) { + throw new Error('TableCellResizer: Table cell node not found.'); + } + + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + + const tableRowIndex = + $getTableRowIndexFromTableCellNode(tableCellNode) + + tableCellNode.getRowSpan() - + 1; + + const tableRows = tableNode.getChildren(); + + if (tableRowIndex >= tableRows.length || tableRowIndex < 0) { + throw new Error('Expected table cell to be inside of table row.'); + } + + const tableRow = tableRows[tableRowIndex]; + + if (!$isTableRowNode(tableRow)) { + throw new Error('Expected table row'); + } + + let height = tableRow.getHeight(); + if (height === undefined) { + const rowCells = tableRow.getChildren(); + height = Math.min( + ...rowCells.map( + (cell) => getCellNodeHeight(cell, editor) ?? Infinity, + ), + ); + } + + const newHeight = Math.max(height + heightChange, MIN_ROW_HEIGHT); + tableRow.setHeight(newHeight); + }, + {tag: 'skip-scroll-into-view'}, + ); + }, + [activeCell, editor], + ); + + const getCellNodeHeight = ( + cell: TableCellNode, + activeEditor: LexicalEditor, + ): number | undefined => { + const domCellNode = activeEditor.getElementByKey(cell.getKey()); + return domCellNode?.clientHeight; + }; + + const getCellColumnIndex = ( + tableCellNode: TableCellNode, + tableMap: TableMapType, + ) => { + for (let row = 0; row < tableMap.length; row++) { + for (let column = 0; column < tableMap[row].length; column++) { + if (tableMap[row][column].cell === tableCellNode) { + return column; + } + } + } + }; + + const updateColumnWidth = useCallback( + (widthChange: number) => { + if (!activeCell) { + throw new Error('TableCellResizer: Expected active cell.'); + } + editor.update( + () => { + const tableCellNode = $getNearestNodeFromDOMNode(activeCell.elem); + if (!$isTableCellNode(tableCellNode)) { + throw new Error('TableCellResizer: Table cell node not found.'); + } + + const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); + const [tableMap] = $computeTableMapSkipCellCheck( + tableNode, + null, + null, + ); + const columnIndex = getCellColumnIndex(tableCellNode, tableMap); + if (columnIndex === undefined) { + throw new Error('TableCellResizer: Table column not found.'); + } + + const colWidths = tableNode.getColWidths(); + if (!colWidths) { + return; + } + const width = colWidths[columnIndex]; + if (width === undefined) { + return; + } + const newColWidths = [...colWidths]; + const newWidth = Math.max(width + widthChange, MIN_COLUMN_WIDTH); + newColWidths[columnIndex] = newWidth; + tableNode.setColWidths(newColWidths); + }, + {tag: 'skip-scroll-into-view'}, + ); + }, + [activeCell, editor], + ); + + const mouseUpHandler = useCallback( + (direction: MouseDraggingDirection) => { + const handler = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (!activeCell) { + throw new Error('TableCellResizer: Expected active cell.'); + } + + if (mouseStartPosRef.current) { + const {x, y} = mouseStartPosRef.current; + + if (activeCell === null) { + return; + } + const zoom = calculateZoomLevel(event.target as Element); + + if (isHeightChanging(direction)) { + const heightChange = (event.clientY - y) / zoom; + updateRowHeight(heightChange); + } else { + const widthChange = (event.clientX - x) / zoom; + updateColumnWidth(widthChange); + } + + resetState(); + document.removeEventListener('mouseup', handler); + } + }; + return handler; + }, + [activeCell, resetState, updateColumnWidth, updateRowHeight], + ); + + const toggleResize = useCallback( + (direction: MouseDraggingDirection): MouseEventHandler => + (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (!activeCell) { + throw new Error('TableCellResizer: Expected active cell.'); + } + + mouseStartPosRef.current = { + x: event.clientX, + y: event.clientY, + }; + updateMouseCurrentPos(mouseStartPosRef.current); + updateDraggingDirection(direction); + + document.addEventListener('mouseup', mouseUpHandler(direction)); + }, + [activeCell, mouseUpHandler], + ); + + const getResizers = useCallback(() => { + if (activeCell) { + const {height, width, top, left} = + activeCell.elem.getBoundingClientRect(); + const zoom = calculateZoomLevel(activeCell.elem); + const zoneWidth = 10; // Pixel width of the zone where you can drag the edge + const styles = { + bottom: { + backgroundColor: 'none', + cursor: 'row-resize', + height: `${zoneWidth}px`, + left: `${window.pageXOffset + left}px`, + top: `${window.pageYOffset + top + height - zoneWidth / 2}px`, + width: `${width}px`, + }, + right: { + backgroundColor: 'none', + cursor: 'col-resize', + height: `${height}px`, + left: `${window.pageXOffset + left + width - zoneWidth / 2}px`, + top: `${window.pageYOffset + top}px`, + width: `${zoneWidth}px`, + }, + }; + + const tableRect = tableRectRef.current; + + if (draggingDirection && mouseCurrentPos && tableRect) { + if (isHeightChanging(draggingDirection)) { + styles[draggingDirection].left = `${ + window.pageXOffset + tableRect.left + }px`; + styles[draggingDirection].top = `${ + window.pageYOffset + mouseCurrentPos.y / zoom + }px`; + styles[draggingDirection].height = '3px'; + styles[draggingDirection].width = `${tableRect.width}px`; + } else { + styles[draggingDirection].top = `${ + window.pageYOffset + tableRect.top + }px`; + styles[draggingDirection].left = `${ + window.pageXOffset + mouseCurrentPos.x / zoom + }px`; + styles[draggingDirection].width = '3px'; + styles[draggingDirection].height = `${tableRect.height}px`; + } + + styles[draggingDirection].backgroundColor = '#adf'; + } + + return styles; + } + + return { + bottom: null, + left: null, + right: null, + top: null, + }; + }, [activeCell, draggingDirection, mouseCurrentPos]); + + const resizerStyles = getResizers(); + + return ( +
+ {activeCell != null && !isMouseDown && ( + <> +
+
+ + )} +
+ ); +} + +export default function TableCellResizerPlugin(): null | ReactPortal { + const [editor] = useLexicalComposerContext(); + const isEditable = useLexicalEditable(); + + return useMemo( + () => + isEditable + ? createPortal(, document.body) + : null, + [editor, isEditable], + ); +} diff --git a/src/components/LexicalEditor/styles.css b/src/components/LexicalEditor/styles.css index 1378496..771d1c7 100644 --- a/src/components/LexicalEditor/styles.css +++ b/src/components/LexicalEditor/styles.css @@ -1097,6 +1097,7 @@ color: #000; overflow: hidden; } +.PlaygroundEditorTheme__table, .editor-table { border-collapse: collapse; border-spacing: 0; @@ -1107,6 +1108,7 @@ width: fit-content; margin: 0px 25px 30px 0px; } +.PlaygroundEditorTheme__tableCell, .editor-tableCell { border: 1px solid #bbb; /* width: 75px; */ @@ -1116,3 +1118,52 @@ padding: 6px 8px; position: relative; outline: none; } + + +.table-cell-action-button-container { + position: absolute; + top: 5px; + left: 15px; + will-change: transform; + z-index: 1201; + } + + .table-cell-action-button { + background-color: none; + display: flex; + justify-content: center; + align-items: center; + border: 0; + position: relative; + border-radius: 15px; + color: #222; + display: inline-block; + cursor: pointer; + } + + .action-button { + background-color: #eee; + border: 0; + padding: 8px 12px; + position: relative; + margin-left: 5px; + border-radius: 15px; + color: #222; + display: inline-block; + cursor: pointer; + } + + .action-button:hover { + background-color: #ddd; + color: #000; + } + + .action-button-mic.active { + animation: mic-pulsate-color 3s infinite; + } + + button.action-button:disabled { + opacity: 0.6; + background: #eee; + cursor: not-allowed; + } diff --git a/src/components/LexicalEditor/themes/ExampleTheme.js b/src/components/LexicalEditor/themes/ExampleTheme.js index 6e0aebf..a2faa33 100644 --- a/src/components/LexicalEditor/themes/ExampleTheme.js +++ b/src/components/LexicalEditor/themes/ExampleTheme.js @@ -1,3 +1,6 @@ + +import './PlaygroundEditorTheme.css'; + const exampleTheme = { ltr: "ltr", rtl: "rtl", @@ -65,8 +68,24 @@ const exampleTheme = { url: "editor-tokenOperator", variable: "editor-tokenVariable" }, - table: 'editor-table', - tableCell: 'editor-tableCell', + // table: 'editor-table', + // tableCell: 'editor-tableCell', + table: 'PlaygroundEditorTheme__table', + tableCell: 'PlaygroundEditorTheme__tableCell', + tableCellActionButton: 'PlaygroundEditorTheme__tableCellActionButton', + tableCellActionButtonContainer: + 'PlaygroundEditorTheme__tableCellActionButtonContainer', + tableCellEditing: 'PlaygroundEditorTheme__tableCellEditing', + tableCellHeader: 'PlaygroundEditorTheme__tableCellHeader', + tableCellPrimarySelected: 'PlaygroundEditorTheme__tableCellPrimarySelected', + tableCellResizer: 'PlaygroundEditorTheme__tableCellResizer', + tableCellSelected: 'PlaygroundEditorTheme__tableCellSelected', + tableCellSortedIndicator: 'PlaygroundEditorTheme__tableCellSortedIndicator', + tableResizeRuler: 'PlaygroundEditorTheme__tableCellResizeRuler', + tableRowStriping: 'PlaygroundEditorTheme__tableRowStriping', + tableScrollableWrapper: 'PlaygroundEditorTheme__tableScrollableWrapper', + tableSelected: 'PlaygroundEditorTheme__tableSelected', + tableSelection: 'PlaygroundEditorTheme__tableSelection', }; export default exampleTheme; diff --git a/src/components/LexicalEditor/themes/PlaygroundEditorTheme.css b/src/components/LexicalEditor/themes/PlaygroundEditorTheme.css new file mode 100644 index 0000000..ba39bc5 --- /dev/null +++ b/src/components/LexicalEditor/themes/PlaygroundEditorTheme.css @@ -0,0 +1,467 @@ +/** + * 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. + * + * + */ +.PlaygroundEditorTheme__ltr { + text-align: left; +} +.PlaygroundEditorTheme__rtl { + text-align: right; +} +.PlaygroundEditorTheme__paragraph { + margin: 0; + position: relative; +} +.PlaygroundEditorTheme__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; +} +.PlaygroundEditorTheme__h1 { + font-size: 24px; + color: rgb(5, 5, 5); + font-weight: 400; + margin: 0; +} +.PlaygroundEditorTheme__h2 { + font-size: 15px; + color: rgb(101, 103, 107); + font-weight: 700; + margin: 0; + text-transform: uppercase; +} +.PlaygroundEditorTheme__h3 { + font-size: 12px; + margin: 0; + text-transform: uppercase; +} +.PlaygroundEditorTheme__indent { + --lexical-indent-base-value: 40px; +} +.PlaygroundEditorTheme__textBold { + font-weight: bold; +} +.PlaygroundEditorTheme__textItalic { + font-style: italic; +} +.PlaygroundEditorTheme__textUnderline { + text-decoration: underline; +} +.PlaygroundEditorTheme__textStrikethrough { + text-decoration: line-through; +} +.PlaygroundEditorTheme__textUnderlineStrikethrough { + text-decoration: underline line-through; +} +.PlaygroundEditorTheme__textSubscript { + font-size: 0.8em; + vertical-align: sub !important; +} +.PlaygroundEditorTheme__textSuperscript { + font-size: 0.8em; + vertical-align: super; +} +.PlaygroundEditorTheme__textCode { + background-color: rgb(240, 242, 245); + padding: 1px 0.25rem; + font-family: Menlo, Consolas, Monaco, monospace; + font-size: 94%; +} +.PlaygroundEditorTheme__hashtag { + background-color: rgba(88, 144, 255, 0.15); + border-bottom: 1px solid rgba(88, 144, 255, 0.3); +} +.PlaygroundEditorTheme__link { + color: rgb(33, 111, 219); + text-decoration: none; +} +.PlaygroundEditorTheme__link:hover { + text-decoration: underline; + cursor: pointer; +} +.PlaygroundEditorTheme__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; +} +.PlaygroundEditorTheme__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; +} +.PlaygroundEditorTheme__tableScrollableWrapper { + overflow-x: auto; + margin: 0px 25px 30px 0px; +} +.PlaygroundEditorTheme__tableScrollableWrapper > .PlaygroundEditorTheme__table { + /* Remove the table's margin and put it on the wrapper */ + margin: 0; +} +.PlaygroundEditorTheme__table { + border-collapse: collapse; + border-spacing: 0; + overflow-y: scroll; + overflow-x: scroll; + table-layout: fixed; + width: fit-content; + margin: 0px 25px 30px 0px; +} +.PlaygroundEditorTheme__tableRowStriping tr:nth-child(even) { + background-color: #f2f5fb; +} +.PlaygroundEditorTheme__tableSelection *::selection { + background-color: transparent; +} +.PlaygroundEditorTheme__tableSelected { + outline: 2px solid rgb(60, 132, 244); +} +.PlaygroundEditorTheme__tableCell { + border: 1px solid #bbb; + width: 75px; + vertical-align: top; + text-align: start; + padding: 6px 8px; + position: relative; + outline: none; +} +.PlaygroundEditorTheme__tableCellSortedIndicator { + display: block; + opacity: 0.5; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 4px; + background-color: #999; +} +.PlaygroundEditorTheme__tableCellResizer { + position: absolute; + right: -4px; + height: 100%; + width: 8px; + cursor: ew-resize; + /* z-index: 10; */ + top: 0; + z-index: 1202; +} +.PlaygroundEditorTheme__tableCellHeader { + background-color: #f2f3f5; + text-align: start; +} +.PlaygroundEditorTheme__tableCellSelected { + background-color: #c9dbf0; +} +.PlaygroundEditorTheme__tableCellPrimarySelected { + border: 2px solid rgb(60, 132, 244); + display: block; + height: calc(100% - 2px); + position: absolute; + width: calc(100% - 2px); + left: -1px; + top: -1px; + z-index: 2; +} +.PlaygroundEditorTheme__tableCellEditing { + box-shadow: 0 0 5px rgba(0, 0, 0, 0.4); + border-radius: 3px; +} +.PlaygroundEditorTheme__tableAddColumns { + position: absolute; + background-color: #eee; + height: 100%; + animation: table-controls 0.2s ease; + border: 0; + cursor: pointer; + z-index: 1202; +} +.PlaygroundEditorTheme__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; +} +.PlaygroundEditorTheme__tableAddColumns:hover, +.PlaygroundEditorTheme__tableAddRows:hover { + background-color: #c9dbf0; +} +.PlaygroundEditorTheme__tableAddRows { + position: absolute; + width: calc(100% - 25px); + background-color: #eee; + animation: table-controls 0.2s ease; + border: 0; + cursor: pointer; + z-index: 1202; +} +.PlaygroundEditorTheme__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; + } +} +.PlaygroundEditorTheme__tableCellResizeRuler { + display: block; + position: absolute; + width: 1px; + background-color: rgb(60, 132, 244); + height: 100%; + top: 0; + z-index: 1202; +} +.PlaygroundEditorTheme__tableCellActionButtonContainer { + display: block; + right: 5px; + top: 6px; + position: absolute; + z-index: 4; + width: 20px; + height: 20px; +} +.PlaygroundEditorTheme__tableCellActionButton { + background-color: #eee; + display: block; + border: 0; + border-radius: 20px; + width: 20px; + height: 20px; + color: #222; + cursor: pointer; +} +.PlaygroundEditorTheme__tableCellActionButton:hover { + background-color: #ddd; +} +.PlaygroundEditorTheme__characterLimit { + display: inline; + background-color: #ffbbbb !important; +} +.PlaygroundEditorTheme__ol1 { + padding: 0; + margin: 0; + list-style-position: outside; +} +.PlaygroundEditorTheme__ol2 { + padding: 0; + margin: 0; + list-style-type: upper-alpha; + list-style-position: outside; +} +.PlaygroundEditorTheme__ol3 { + padding: 0; + margin: 0; + list-style-type: lower-alpha; + list-style-position: outside; +} +.PlaygroundEditorTheme__ol4 { + padding: 0; + margin: 0; + list-style-type: upper-roman; + list-style-position: outside; +} +.PlaygroundEditorTheme__ol5 { + padding: 0; + margin: 0; + list-style-type: lower-roman; + list-style-position: outside; +} +.PlaygroundEditorTheme__ul { + padding: 0; + margin: 0; + list-style-position: outside; +} +.PlaygroundEditorTheme__listItem { + margin: 0 32px; +} +.PlaygroundEditorTheme__listItemChecked, +.PlaygroundEditorTheme__listItemUnchecked { + position: relative; + margin-left: 8px; + margin-right: 8px; + padding-left: 24px; + padding-right: 24px; + list-style-type: none; + outline: none; +} +.PlaygroundEditorTheme__listItemChecked { + text-decoration: line-through; +} +.PlaygroundEditorTheme__listItemUnchecked:before, +.PlaygroundEditorTheme__listItemChecked:before { + content: ''; + width: 16px; + height: 16px; + top: 2px; + left: 0; + cursor: pointer; + display: block; + background-size: cover; + position: absolute; +} +.PlaygroundEditorTheme__listItemUnchecked[dir='rtl']:before, +.PlaygroundEditorTheme__listItemChecked[dir='rtl']:before { + left: auto; + right: 0; +} +.PlaygroundEditorTheme__listItemUnchecked:focus:before, +.PlaygroundEditorTheme__listItemChecked:focus:before { + box-shadow: 0 0 0 2px #a6cdfe; + border-radius: 2px; +} +.PlaygroundEditorTheme__listItemUnchecked:before { + border: 1px solid #999; + border-radius: 2px; +} +.PlaygroundEditorTheme__listItemChecked:before { + border: 1px solid rgb(61, 135, 245); + border-radius: 2px; + background-color: #3d87f5; + background-repeat: no-repeat; +} +.PlaygroundEditorTheme__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; +} +.PlaygroundEditorTheme__nestedListItem { + list-style-type: none; +} +.PlaygroundEditorTheme__nestedListItem:before, +.PlaygroundEditorTheme__nestedListItem:after { + display: none; +} +.PlaygroundEditorTheme__tokenComment { + color: slategray; +} +.PlaygroundEditorTheme__tokenPunctuation { + color: #999; +} +.PlaygroundEditorTheme__tokenProperty { + color: #905; +} +.PlaygroundEditorTheme__tokenSelector { + color: #690; +} +.PlaygroundEditorTheme__tokenOperator { + color: #9a6e3a; +} +.PlaygroundEditorTheme__tokenAttr { + color: #07a; +} +.PlaygroundEditorTheme__tokenVariable { + color: #e90; +} +.PlaygroundEditorTheme__tokenFunction { + color: #dd4a68; +} +.PlaygroundEditorTheme__mark { + background: rgba(255, 212, 0, 0.14); + border-bottom: 2px solid rgba(255, 212, 0, 0.3); + padding-bottom: 2px; +} +.PlaygroundEditorTheme__markOverlap { + background: rgba(255, 212, 0, 0.3); + border-bottom: 2px solid rgba(255, 212, 0, 0.7); +} +.PlaygroundEditorTheme__mark.selected { + background: rgba(255, 212, 0, 0.5); + border-bottom: 2px solid rgba(255, 212, 0, 1); +} +.PlaygroundEditorTheme__markOverlap.selected { + background: rgba(255, 212, 0, 0.7); + border-bottom: 2px solid rgba(255, 212, 0, 0.7); +} +.PlaygroundEditorTheme__embedBlock { + user-select: none; +} +.PlaygroundEditorTheme__embedBlockFocus { + outline: 2px solid rgb(60, 132, 244); +} +.PlaygroundEditorTheme__layoutContainer { + display: grid; + gap: 10px; + margin: 10px 0; +} +.PlaygroundEditorTheme__layoutItem { + border: 1px dashed #ddd; + padding: 8px 16px; +} +.PlaygroundEditorTheme__autocomplete { + color: #ccc; +} +.PlaygroundEditorTheme__hr { + padding: 2px 2px; + border: none; + margin: 1em 0; + cursor: pointer; +} +.PlaygroundEditorTheme__hr:after { + content: ''; + display: block; + height: 2px; + background-color: #ccc; + line-height: 2px; +} +.PlaygroundEditorTheme__hr.selected { + outline: 2px solid rgb(60, 132, 244); + user-select: none; +}