/** * 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 {LexicalEditor} from 'lexical'; import {effect, namedSignals} from '@lexical/extension'; import {CoreImportExtension, DOMImportExtension} from '@lexical/html'; import { $fullReconcile, configExtension, defineExtension, isHTMLElement, mergeRegister, safeCast, } from 'lexical'; import {TableCellNode} from './LexicalTableCellNode'; import { $isScrollableTablesActive, attachStickyScrollbarListeners, findStickyScrollbarElements, setScrollableTablesActive, type StickyScrollbarElements, syncStickyScrollbar, TableNode, } from './LexicalTableNode'; import { registerTableCellUnmergeTransform, registerTablePlugin, registerTableSelectionObserver, } from './LexicalTablePluginHelpers'; import {TableRowNode} from './LexicalTableRowNode'; import {TableImportRules} from './TableImportExtension'; export interface TableConfig { /** * When `false` (default `true`), merged cell support (colspan and rowspan) will be disabled and all * tables will be forced into a regular grid with 1x1 table cells. */ hasCellMerge: boolean; /** * When `false` (default `true`), the background color of TableCellNode will always be removed. */ hasCellBackgroundColor: boolean; /** * When `true` (default `true`), the tab key can be used to navigate table cells. */ hasTabHandler: boolean; /** * When `true` (default `true`), tables will be wrapped in a `
` to enable horizontal scrolling */ hasHorizontalScroll: boolean; /** * When `true` (default `false`), a sticky scrollbar is rendered below each table that overflows horizontally. * Requires `hasHorizontalScroll` to be `true`. The native scrollbar is hidden via inline * `scrollbar-width: none` (Firefox/Chromium). Themed consumers providing a `tableScrollableWrapper` * class should also add `::-webkit-scrollbar { display: none }` for Safari/WebKit. * A themed scrollbar is expected to guarantee its own visible height (the * playground does this with `::-webkit-scrollbar { height: ... }`); the * unthemed fallback depends on classic native scrollbars, so on platforms * where those render with no thickness (overlay scrollbars, and non-layout * environments like jsdom) it is disabled at runtime and the wrapper's * native scrollbar is restored. */ hasStickyScrollbar: boolean; /** * When `true` (default `false`), nested tables will be allowed. * * @experimental Nested tables are not officially supported. */ hasNestedTables: boolean; } function registerStickyScrollbar(editor: LexicalEditor) { const attached = new Map< string, {cleanup: () => void; parts: StickyScrollbarElements} >(); const detachAll = () => { for (const {cleanup} of attached.values()) { cleanup(); } attached.clear(); }; return mergeRegister( detachAll, editor.registerMutationListener( TableNode, nodeMutations => { for (const [nodeKey, mutation] of nodeMutations) { const prev = attached.get(nodeKey); if (mutation === 'destroyed') { if (prev) { prev.cleanup(); attached.delete(nodeKey); } continue; } const dom = editor.getElementByKey(nodeKey); const parts = dom && isHTMLElement(dom) ? findStickyScrollbarElements(dom) : null; if ( prev && parts && prev.parts.scrollable === parts.scrollable && prev.parts.scrollbar === parts.scrollbar && prev.parts.tableElement === parts.tableElement ) { // Same DOM: keep the listeners, but resync since the // update may still change scrollability (e.g. a frozen // rows toggle switches the wrapper to overflow-x: clip). syncStickyScrollbar(parts.scrollable, parts.scrollbar); continue; } if (prev) { prev.cleanup(); attached.delete(nodeKey); } if (parts) { attached.set(nodeKey, { cleanup: attachStickyScrollbarListeners(parts), parts, }); } } }, {skipInitialization: false}, ), ); } /** * Configures {@link TableNode}, {@link TableRowNode}, {@link TableCellNode} and * registers table behaviors (see {@link TableConfig}) */ export const TableExtension = defineExtension({ build(editor, config, state) { return namedSignals(config); }, config: safeCast({ hasCellBackgroundColor: true, hasCellMerge: true, hasHorizontalScroll: true, hasNestedTables: false, hasStickyScrollbar: false, hasTabHandler: true, }), dependencies: [ // DOMImportExtension support for the nodes registered here. Inert // unless the editor routes HTML through the pipeline (e.g. via // ClipboardDOMImportExtension or $generateNodesFromDOMViaExtension). CoreImportExtension, configExtension(DOMImportExtension, { rules: TableImportRules, }), ], name: '@lexical/table/Table', nodes: () => [TableNode, TableRowNode, TableCellNode], register(editor, config, state) { const stores = state.getOutput(); let prevStickyScrollbar = false; return mergeRegister( effect(() => { const hasHorizontalScroll = stores.hasHorizontalScroll.value; const hasStickyScrollbar = stores.hasStickyScrollbar.value && hasHorizontalScroll; const hadHorizontalScroll = $isScrollableTablesActive(editor); if (hadHorizontalScroll !== hasHorizontalScroll) { setScrollableTablesActive(editor, hasHorizontalScroll); } if ( hadHorizontalScroll !== hasHorizontalScroll || prevStickyScrollbar !== hasStickyScrollbar ) { // Re-render existing tables through the new scroll-wrapper config // without cloning every TableNode the way marking them dirty would. A // full reconcile marks no nodes dirty, so it's deferred (no // synchronous render from this effect) and produces no history entry. editor.update($fullReconcile); } prevStickyScrollbar = hasStickyScrollbar; }), registerTablePlugin(editor, stores), effect(() => registerTableSelectionObserver(editor, stores.hasTabHandler.value), ), effect(() => { if ( stores.hasStickyScrollbar.value && stores.hasHorizontalScroll.value ) { return editor.registerRootListener(rootElement => { if (rootElement) { return registerStickyScrollbar(editor); } }); } }), effect(() => stores.hasCellMerge.value ? undefined : registerTableCellUnmergeTransform(editor), ), effect(() => stores.hasCellBackgroundColor.value ? undefined : editor.registerNodeTransform(TableCellNode, node => { if (node.getBackgroundColor() !== null) { node.setBackgroundColor(null); } }), ), ); }, }); /** * Bundles {@link TableImportRules} together with the runtime * {@link TableExtension}. * * @experimental * @deprecated {@link TableExtension} now registers * {@link TableImportRules} (and `CoreImportExtension`) itself — depend on * it directly instead. */ export const TableImportExtension = defineExtension({ dependencies: [TableExtension], name: '@lexical/table/Import', });