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;