diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index e42b0919a76..f832d154d2c 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -77,6 +77,7 @@ import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin'; import TableCellResizer from './plugins/TableCellResizer'; import TableHoverActionsPlugin from './plugins/TableHoverActionsPlugin'; import TableOfContentsPlugin from './plugins/TableOfContentsPlugin'; +import TableScrollShadowPlugin from './plugins/TableScrollShadowPlugin'; import ToolbarPlugin from './plugins/ToolbarPlugin'; import TreeViewPlugin from './plugins/TreeViewPlugin'; import TwitterPlugin from './plugins/TwitterPlugin'; @@ -241,6 +242,7 @@ export default function Editor(): JSX.Element { hasNestedTables={hasNestedTables} /> + diff --git a/packages/lexical-playground/src/plugins/TableScrollShadowPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableScrollShadowPlugin/index.tsx new file mode 100644 index 00000000000..ce4590abe1f --- /dev/null +++ b/packages/lexical-playground/src/plugins/TableScrollShadowPlugin/index.tsx @@ -0,0 +1,143 @@ +/** + * 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 {useEffect} from 'react'; + +const SCROLLABLE_WRAPPER_CLASS = + 'PlaygroundEditorTheme__tableScrollableWrapper'; +const HAS_SCROLL_RIGHT_CLASS = 'PlaygroundEditorTheme__tableScrollRight'; +const HAS_SCROLL_LEFT_CLASS = 'PlaygroundEditorTheme__tableScrollLeft'; +const HAS_SCROLL_MIDDLE_CLASS = 'PlaygroundEditorTheme__tableScrollMiddle'; + +function updateTableScrollState(element: HTMLElement): void { + const hasScroll = element.scrollWidth > element.clientWidth; + // Adding and removing 1 and -1 for floating point precision + const isScrolledToRight = + element.scrollLeft + element.clientWidth >= element.scrollWidth - 1; + const isScrolledToLeft = element.scrollLeft <= 1; + + // Remove all scroll classes first + element.classList.remove(HAS_SCROLL_RIGHT_CLASS); + element.classList.remove(HAS_SCROLL_LEFT_CLASS); + element.classList.remove(HAS_SCROLL_MIDDLE_CLASS); + + if (hasScroll) { + // Middle state: not at either edge + if (!isScrolledToLeft && !isScrolledToRight) { + element.classList.add(HAS_SCROLL_MIDDLE_CLASS); + } + // Right edge + else if (isScrolledToLeft && !isScrolledToRight) { + element.classList.add(HAS_SCROLL_RIGHT_CLASS); + } + // Left edge + else if (!isScrolledToLeft && isScrolledToRight) { + element.classList.add(HAS_SCROLL_LEFT_CLASS); + } + } +} + +export default function TableScrollShadowPlugin(): null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const editorElement = editor.getRootElement(); + if (!editorElement) { + return; + } + + const updateAllTableScrollStates = () => { + const wrappers = editorElement.querySelectorAll( + `.${SCROLLABLE_WRAPPER_CLASS}`, + ); + wrappers.forEach(updateTableScrollState); + }; + + // Initial check + updateAllTableScrollStates(); + + // Watch for new table wrappers being added + const observer = new MutationObserver(() => { + updateAllTableScrollStates(); + }); + + observer.observe(editorElement, { + childList: true, + subtree: true, + }); + + // Also watch for resize events on the wrappers themselves + const resizeObserver = new ResizeObserver(() => { + updateAllTableScrollStates(); + }); + + const scrollHandlers = new Map void>(); + + const addScrollListener = (wrapper: HTMLElement) => { + if (scrollHandlers.has(wrapper)) { + return; // Already has a listener + } + const handler = () => { + updateTableScrollState(wrapper); + }; + wrapper.addEventListener('scroll', handler, {passive: true}); + scrollHandlers.set(wrapper, handler); + }; + + const wrappers = editorElement.querySelectorAll( + `.${SCROLLABLE_WRAPPER_CLASS}`, + ); + wrappers.forEach((wrapper) => { + resizeObserver.observe(wrapper); + addScrollListener(wrapper); + }); + + // Watch for new wrappers to observe + const wrapperObserver = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node instanceof HTMLElement) { + if (node.classList.contains(SCROLLABLE_WRAPPER_CLASS)) { + resizeObserver.observe(node); + addScrollListener(node); + updateTableScrollState(node); + } + + const childWrappers = node.querySelectorAll( + `.${SCROLLABLE_WRAPPER_CLASS}`, + ); + childWrappers.forEach((wrapper) => { + resizeObserver.observe(wrapper); + addScrollListener(wrapper); + updateTableScrollState(wrapper); + }); + } + }); + }); + }); + + wrapperObserver.observe(editorElement, { + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + resizeObserver.disconnect(); + wrapperObserver.disconnect(); + + scrollHandlers.forEach((handler, wrapper) => { + wrapper.removeEventListener('scroll', handler); + }); + scrollHandlers.clear(); + }; + }, [editor]); + + return null; +} diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css index cf3aa06754a..01ad0cb681d 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css @@ -193,6 +193,17 @@ overflow-x: auto; margin: 0px 25px 30px 0px; } +.PlaygroundEditorTheme__tableScrollRight { + box-shadow: inset -10px 0 10px rgba(0, 0, 0, 0.15); +} +.PlaygroundEditorTheme__tableScrollLeft { + box-shadow: inset 10px 0 10px rgba(0, 0, 0, 0.15); +} +.PlaygroundEditorTheme__tableScrollMiddle { + box-shadow: + inset 10px 0 10px rgba(0, 0, 0, 0.15), + inset -10px 0 10px rgba(0, 0, 0, 0.15); +} .PlaygroundEditorTheme__tableScrollableWrapper > .PlaygroundEditorTheme__table { /* Remove the table's vertical margin and put it on the wrapper */ margin-top: 0;