diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 31d8a38d433..30f196ecd76 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -22,7 +22,6 @@ import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; import {TabIndentationPlugin} from '@lexical/react/LexicalTabIndentationPlugin'; import {TablePlugin} from '@lexical/react/LexicalTablePlugin'; import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; -import * as React from 'react'; import {useEffect, useState} from 'react'; import {CAN_USE_DOM} from 'shared/canUseDOM'; @@ -51,6 +50,8 @@ import FloatingTextFormatToolbarPlugin from './plugins/FloatingTextFormatToolbar import ImagesPlugin from './plugins/ImagesPlugin'; import InlineImagePlugin from './plugins/InlineImagePlugin'; import KeywordsPlugin from './plugins/KeywordsPlugin'; +import LayoutColumnResizerPlugin from './plugins/LayoutColumnResizer'; +import LayoutColumnHoverActions from './plugins/LayoutHoverActionsPlugin'; import {LayoutPlugin} from './plugins/LayoutPlugin/LayoutPlugin'; import LinkPlugin from './plugins/LinkPlugin'; import ListMaxIndentLevelPlugin from './plugins/ListMaxIndentLevelPlugin'; @@ -200,6 +201,8 @@ export default function Editor(): JSX.Element { + + {floatingAnchorElem && !isSmallWidthViewport && ( <> diff --git a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts index b89eed53b89..cf05ea83bdb 100644 --- a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts +++ b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts @@ -17,8 +17,11 @@ import type { Spread, } from 'lexical'; -import {addClassNamesToElement} from '@lexical/utils'; +import {$findMatchingParent, addClassNamesToElement} from '@lexical/utils'; import {ElementNode} from 'lexical'; +import {times} from 'lodash-es'; + +import {LayoutItemNode} from './LayoutItemNode'; export type SerializedLayoutContainerNode = Spread< { @@ -122,6 +125,13 @@ export class LayoutContainerNode extends ElementNode { setTemplateColumns(templateColumns: string) { this.getWritable().__templateColumns = templateColumns; } + + updateTemplateColumnWithIndex(index: number, value: string) { + const currentGridTemplateColumns = this.getTemplateColumns(); + const newGridTemplateColumns = currentGridTemplateColumns.split(' '); + newGridTemplateColumns[index] = value; + return this.setTemplateColumns(newGridTemplateColumns.join(' ')); + } } export function $createLayoutContainerNode( @@ -135,3 +145,32 @@ export function $isLayoutContainerNode( ): node is LayoutContainerNode { return node instanceof LayoutContainerNode; } + +export function $getLayoutContainerNodeIfLayoutItemNodeOrThrow( + layoutItemNode: LayoutItemNode, +): LayoutContainerNode { + const node = $findMatchingParent(layoutItemNode, $isLayoutContainerNode); + + if ($isLayoutContainerNode(node)) { + return node; + } + + throw new Error( + 'Expected LayoutItemNode to be inside of LayoutContainerNode.', + ); +} + +export function $findLayoutItemIndexGivenLayoutContainerNode( + layoutItemNode: LayoutItemNode, +): number { + const layoutContainerNode = + $getLayoutContainerNodeIfLayoutItemNodeOrThrow(layoutItemNode); + + return layoutContainerNode + .getChildren() + .findIndex((node) => node.is(layoutItemNode)); +} + +export function $getGridTemplateColumnsWithEqualWidth(count: number) { + return times(count, () => '1fr').join(' '); +} diff --git a/packages/lexical-playground/src/plugins/LayoutColumnResizer/index.css b/packages/lexical-playground/src/plugins/LayoutColumnResizer/index.css new file mode 100644 index 00000000000..22e7bace1f3 --- /dev/null +++ b/packages/lexical-playground/src/plugins/LayoutColumnResizer/index.css @@ -0,0 +1,4 @@ +.PlaygroundEditorTheme__resizer { + z-index: 1302; + position: absolute; +} diff --git a/packages/lexical-playground/src/plugins/LayoutColumnResizer/index.tsx b/packages/lexical-playground/src/plugins/LayoutColumnResizer/index.tsx new file mode 100644 index 00000000000..f0c7744467a --- /dev/null +++ b/packages/lexical-playground/src/plugins/LayoutColumnResizer/index.tsx @@ -0,0 +1,344 @@ +/** + * 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 './index.css'; + +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {calculateZoomLevel} from '@lexical/utils'; +import {$getNearestNodeFromDOMNode, LexicalEditor} from 'lexical'; +import * as React from 'react'; +import {createPortal} from 'react-dom'; + +import { + $findLayoutItemIndexGivenLayoutContainerNode, + $getLayoutContainerNodeIfLayoutItemNodeOrThrow, +} from '../../nodes/LayoutContainerNode'; +import {$isLayoutItemNode} from '../../nodes/LayoutItemNode'; + +export const MIN_LAYOUT_COLUMN_WIDTH = 100; + +interface Props { + editor: LexicalEditor; +} + +type MousePosition = { + x: number; + y: number; +}; + +type MouseDraggingDirection = 'right' | 'bottom'; + +const LayoutColumnResizer: React.FC = (props) => { + const {editor} = props; + const [mouseCurrentPos, updateMouseCurrentPos] = + React.useState(null); + + const [activeCell, updateActiveCell] = React.useState( + null, + ); + const [isMouseDown, updateIsMouseDown] = React.useState(false); + const [draggingDirection, updateDraggingDirection] = + React.useState(null); + + // refs + const mouseStartPosRef = React.useRef(null); + const targetRef = React.useRef(null); + const resizerRef = React.useRef(null); + const layoutRectRef = React.useRef(null); + + // actions + const resetState = React.useCallback(() => { + updateActiveCell(null); + targetRef.current = null; + updateDraggingDirection(null); + mouseStartPosRef.current = null; + layoutRectRef.current = null; + }, []); + + const isMouseDownOnEvent = React.useCallback((event: MouseEvent) => { + return (event.buttons & 1) === 1; + }, []); + + const isWidthChanging = (direction: MouseDraggingDirection) => { + if (direction === 'right') { + return true; + } + return false; + }; + + const updateColumnWidth = React.useCallback( + (widthChange: number) => { + if (!activeCell) { + throw new Error('LayoutColumnResizer: Expected active cell.'); + } + + editor.update( + () => { + const layoutItemNode = $getNearestNodeFromDOMNode(activeCell); + if (!$isLayoutItemNode(layoutItemNode)) { + throw new Error('LayoutColumnResizer: Expected layout item node.'); + } + + const layoutContainerNode = + $getLayoutContainerNodeIfLayoutItemNodeOrThrow(layoutItemNode); + + const layoutItemsCount = layoutContainerNode.getChildrenSize(); + const layoutItemIndex = + $findLayoutItemIndexGivenLayoutContainerNode(layoutItemNode); + + if (layoutItemIndex < 0 || layoutItemIndex >= layoutItemsCount) { + throw new Error('LayoutColumnResizer: Invalid layout item index.'); + } + + const columnWidth = activeCell.offsetWidth; // (width + padding + border) + + const newWidth = Math.max( + widthChange + columnWidth, + MIN_LAYOUT_COLUMN_WIDTH, + ); + layoutContainerNode.updateTemplateColumnWithIndex( + layoutItemIndex, + `${newWidth}px`, + ); + + const nextSiblingLayoutItemElement = + activeCell.nextElementSibling as HTMLElement; + + if (nextSiblingLayoutItemElement) { + const nextSiblingWidth = nextSiblingLayoutItemElement.offsetWidth; + + const nextSiblingNewWidth = Math.max( + nextSiblingWidth - widthChange, + MIN_LAYOUT_COLUMN_WIDTH, + ); + + layoutContainerNode.updateTemplateColumnWithIndex( + layoutItemIndex + 1, + `${nextSiblingNewWidth}px`, + ); + } + }, + {discrete: true, tag: 'skip-scroll-into-view'}, + ); + }, + [activeCell, editor], + ); + + const mouseUpHandler = React.useCallback( + (direction: MouseDraggingDirection) => { + const handler = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (!activeCell) { + throw new Error('LayoutColumnResizer: Expected active cell.'); + } + + if (mouseStartPosRef.current) { + const {x} = mouseStartPosRef.current; + + if (activeCell === null) { + return; + } + const zoom = calculateZoomLevel(event.target as Element); + + if (isWidthChanging(direction)) { + const widthChange = (event.clientX - x) / zoom; + updateColumnWidth(widthChange); + } + + resetState(); + document.removeEventListener('mouseup', handler); + } + }; + return handler; + }, + [activeCell, resetState, updateColumnWidth], + ); + + const toggleResize = React.useCallback( + ( + direction: MouseDraggingDirection, + ): React.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 = React.useCallback(() => { + if (activeCell) { + const {height, width, top, left} = activeCell.getBoundingClientRect(); + const zoom = calculateZoomLevel(activeCell); + const zoneWidth = 10; // Pixel width of the zone where you can drag the edge + const styles = { + 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 layoutRect = layoutRectRef.current; + + if (draggingDirection && mouseCurrentPos && layoutRect) { + styles.top = `${window.pageYOffset + layoutRect.top}px`; + styles.left = `${window.pageXOffset + mouseCurrentPos.x / zoom}px`; + styles.width = '3px'; + styles.height = `${layoutRect.height}px`; + styles.backgroundColor = '#adf'; + } + + return styles; + } + + return undefined; + }, [activeCell, draggingDirection, mouseCurrentPos]); + + // effects + React.useEffect(() => { + const onMouseMove = (event: MouseEvent) => { + setTimeout(() => { + const target = event.target as HTMLElement; + + 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; + + if (target && target !== activeCell) { + editor.update(() => { + const targetLayoutItemNode = $getNearestNodeFromDOMNode(target); + + if (!$isLayoutItemNode(targetLayoutItemNode)) { + return; + } + + const layoutContainerNode = + $getLayoutContainerNodeIfLayoutItemNodeOrThrow( + targetLayoutItemNode, + ); + const targetLayoutItemIndex = + $findLayoutItemIndexGivenLayoutContainerNode( + targetLayoutItemNode, + ); + const layoutItemsCount = layoutContainerNode.getChildrenSize(); + + if (targetLayoutItemIndex === layoutItemsCount - 1) { + resetState(); + return; + } + + const layoutContainerDomElement = editor.getElementByKey( + layoutContainerNode.getKey(), + ); + + if (!layoutContainerDomElement) { + throw new Error( + 'LayoutColumnResizer: Expected layout container Not Found.', + ); + } + + targetRef.current = target; + layoutRectRef.current = + layoutContainerDomElement.getBoundingClientRect(); + updateActiveCell(target); + }); + } else if (target === 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, isMouseDownOnEvent, resetState]); + + const resizerStyles = getResizers(); + + return ( +
+ {activeCell != null && !isMouseDown && ( +
+ )} +
+ ); +}; + +function LayoutColumnResizerPlugin(): null | React.ReactPortal { + const [editor] = useLexicalComposerContext(); + const isEditable = editor.isEditable(); + + return React.useMemo( + () => + createPortal( + isEditable ? : null, + document.body, + ), + [editor, isEditable], + ); +} + +export default LayoutColumnResizerPlugin; diff --git a/packages/lexical-playground/src/plugins/LayoutHoverActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/LayoutHoverActionsPlugin/index.tsx new file mode 100644 index 00000000000..68b5137b6fa --- /dev/null +++ b/packages/lexical-playground/src/plugins/LayoutHoverActionsPlugin/index.tsx @@ -0,0 +1,251 @@ +/** + * 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 {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$findMatchingParent, mergeRegister} from '@lexical/utils'; +import { + $createParagraphNode, + $getNearestNodeFromDOMNode, + NodeKey, +} from 'lexical'; +import * as React from 'react'; +import {createPortal} from 'react-dom'; + +import { + $findLayoutItemIndexGivenLayoutContainerNode, + $getGridTemplateColumnsWithEqualWidth, + $isLayoutContainerNode, + LayoutContainerNode, +} from '../../nodes/LayoutContainerNode'; +import { + $createLayoutItemNode, + $isLayoutItemNode, + LayoutItemNode, +} from '../../nodes/LayoutItemNode'; +import {useDebounce} from '../CodeActionMenuPlugin/utils'; + +interface Props { + anchorElem?: HTMLElement; +} + +const BUTTON_WIDTH_PX = 20; + +function getMouseInfo(event: MouseEvent): { + layoutItemNode: HTMLElement | null; + layoutContainerNode: HTMLElement | null; + isOutside: boolean; +} { + const target = event.target; + + if (target && target instanceof HTMLElement) { + const layoutContainerNode = target.closest( + '.PlaygroundEditorTheme__layoutContainer', + ); + const layoutItemNode = target.closest( + '.PlaygroundEditorTheme__layoutItem', + ); + + const isOutside = !( + layoutContainerNode || + layoutItemNode || + target.closest( + 'button.PlaygroundEditorTheme__layoutAddColumns', + ) || + target.closest('div.PlaygroundEditorTheme__resizer') + ); + + return {isOutside, layoutContainerNode, layoutItemNode}; + } + + return {isOutside: false, layoutContainerNode: null, layoutItemNode: null}; +} + +function LayoutColumnHoverActionsContainer(props: Props): JSX.Element | null { + const {anchorElem} = props; + + // states + const [editor] = useLexicalComposerContext(); + const [showColumnAction, setShowColumnAction] = + React.useState(false); + const [posiitonStyles, setPosiitonStyles] = React.useState({}); + const [shouldListenMouseMove, setShouldListenMouseMove] = + React.useState(false); + + // refs + const codeSetRef = React.useRef>(new Set()); + const layoutContainerNodeRef = React.useRef(null); + const layoutItemNodeRef = React.useRef(null); + + // actions + const debouncedOnMouseMove = useDebounce( + (event: MouseEvent) => { + const {isOutside, layoutContainerNode, layoutItemNode} = + getMouseInfo(event); + + if (isOutside) { + setShowColumnAction(false); + return; + } + + if (!layoutItemNode || !layoutContainerNode) { + return; + } + + layoutItemNodeRef.current = layoutItemNode; + layoutContainerNodeRef.current = layoutContainerNode; + + let hoveredLyoutItemNode: LayoutItemNode | null = null; + let layoutContainerDOMElement: HTMLElement | null = null; + + editor.update(() => { + const maybeLayoutItemNode = $getNearestNodeFromDOMNode(layoutItemNode); + + if ($isLayoutItemNode(maybeLayoutItemNode)) { + const maybeLayoutContainer = $findMatchingParent( + maybeLayoutItemNode, + $isLayoutContainerNode, + ); + + if (!$isLayoutContainerNode(maybeLayoutContainer)) { + return; + } + + layoutContainerDOMElement = editor.getElementByKey( + maybeLayoutContainer.getKey(), + ); + + if (layoutContainerDOMElement) { + const columnsCount = maybeLayoutContainer.getChildrenSize(); + + const columnIndex = + $findLayoutItemIndexGivenLayoutContainerNode(maybeLayoutItemNode); + + if (columnIndex === columnsCount - 1) { + hoveredLyoutItemNode = maybeLayoutItemNode; + } + } + } + }); + + if (layoutContainerDOMElement) { + const { + height: containerElemHeight, + y: containerElemY, + right: containerElemRight, + } = (layoutContainerDOMElement as HTMLElement).getBoundingClientRect(); + + const {y: editorElemY} = anchorElem!.getBoundingClientRect(); + + if (hoveredLyoutItemNode) { + setShowColumnAction(true); + setPosiitonStyles({ + height: containerElemHeight, + left: containerElemRight + 5, + top: containerElemY - editorElemY, + width: BUTTON_WIDTH_PX, + }); + } + } + }, + 50, + 250, + ); + + const insertAction = React.useCallback(() => { + editor.update(() => { + if (layoutContainerNodeRef.current) { + const maybeLayoutContainerNode = $getNearestNodeFromDOMNode( + layoutContainerNodeRef.current, + ); + if ($isLayoutContainerNode(maybeLayoutContainerNode)) { + maybeLayoutContainerNode.append( + $createLayoutItemNode().append($createParagraphNode()), + ); + + const newGridTemplateColumnsValue = + $getGridTemplateColumnsWithEqualWidth( + maybeLayoutContainerNode.getChildrenSize(), + ); + + maybeLayoutContainerNode.setTemplateColumns( + newGridTemplateColumnsValue, + ); + maybeLayoutContainerNode.selectEnd(); + } + } + setShowColumnAction(false); + }); + }, [editor]); + + // effects + React.useEffect(() => { + if (!shouldListenMouseMove) { + return; + } + + document.addEventListener('mousemove', debouncedOnMouseMove); + + return () => { + setShowColumnAction(false); + debouncedOnMouseMove.cancel(); + document.removeEventListener('mousemove', debouncedOnMouseMove); + }; + }, [shouldListenMouseMove, debouncedOnMouseMove]); + + React.useEffect(() => { + return mergeRegister( + editor.registerMutationListener( + LayoutContainerNode, + (mutations) => { + editor.getEditorState().read(() => { + for (const [key, type] of mutations) { + switch (type) { + case 'created': + codeSetRef.current.add(key); + setShouldListenMouseMove(codeSetRef.current.size > 0); + break; + + case 'destroyed': + codeSetRef.current.delete(key); + setShouldListenMouseMove(codeSetRef.current.size > 0); + break; + + default: + break; + } + } + }); + }, + {skipInitialization: false}, + ), + ); + }, [editor]); + + if (!showColumnAction) { + return null; + } + + return ( +