From 33e36779a335d1f4fcdb9969f59275b7a5629337 Mon Sep 17 00:00:00 2001 From: Maksim Horbachevsky Date: Wed, 8 Jan 2025 14:59:03 -0500 Subject: [PATCH] [lexical-react] Feature: Merge TabIndentionPlugin and ListMaxIndentLevelPlugin plugins (#7018) --- .../__tests__/e2e/Indentation.spec.mjs | 127 +++++++++++++++++ .../__tests__/e2e/ListMaxIndentLevel.spec.mjs | 128 ------------------ packages/lexical-playground/src/Editor.tsx | 4 +- .../plugins/ListMaxIndentLevelPlugin/index.ts | 85 ------------ .../flow/LexicalTabIndentationPlugin.js.flow | 7 +- .../src/LexicalTabIndentationPlugin.tsx | 74 +++++++--- 6 files changed, 187 insertions(+), 238 deletions(-) delete mode 100644 packages/lexical-playground/__tests__/e2e/ListMaxIndentLevel.spec.mjs delete mode 100644 packages/lexical-playground/src/plugins/ListMaxIndentLevelPlugin/index.ts diff --git a/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs b/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs index aee91bc356b..4811ea087bf 100644 --- a/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs @@ -14,9 +14,23 @@ import { html, initialize, insertTable, + selectFromAlignDropdown, test, } from '../utils/index.mjs'; +async function toggleBulletList(page) { + await click(page, '.block-controls'); + await click(page, '.dropdown .icon.bullet-list'); +} + +async function clickIndentButton(page, times = 1) { + for (let i = 0; i < times; i++) { + await selectFromAlignDropdown(page, '.indent'); + } +} + +const MAX_INDENT = 7; + test.describe('Identation', () => { test.beforeEach(({isCollab, page}) => initialize({isCollab, page, tableHorizontalScroll: false}), @@ -474,4 +488,117 @@ test.describe('Identation', () => { `, ); }); + + test(`Can only indent paragraph until the max depth`, async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await clickIndentButton(page, MAX_INDENT); + + const expectedHTML = + '


'; + + await assertHTML(page, expectedHTML); + await clickIndentButton(page, MAX_INDENT); + + // should stay the same + await assertHTML(page, expectedHTML); + }); + + test(`Can only indent until the max depth when list is empty`, async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await toggleBulletList(page); + + await clickIndentButton(page, MAX_INDENT); + + await assertHTML( + page, + '', + ); + + await clickIndentButton(page); + + // should stay the same + await assertHTML( + page, + '', + ); + }); + + test(`Can only indent until the max depth when list has content`, async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await toggleBulletList(page); + await page.keyboard.type('World'); + + await clickIndentButton(page, MAX_INDENT); + + await assertHTML( + page, + '', + ); + + await clickIndentButton(page); + + // should stay the same + await assertHTML( + page, + '', + ); + }); + + test(`Can only indent until the max depth a list with nested lists`, async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + await toggleBulletList(page); + await page.keyboard.type('Hello'); + await page.keyboard.press('Enter'); + await page.keyboard.type('from'); + await clickIndentButton(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('the'); + await clickIndentButton(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('other'); + await clickIndentButton(page); + await page.keyboard.press('Enter'); + await page.keyboard.type('side'); + await clickIndentButton(page); + await page.keyboard.press('Enter'); + + await assertHTML( + page, + '', + ); + + await selectAll(page); + + await clickIndentButton(page, 3); + + await assertHTML( + page, + '', + ); + + await clickIndentButton(page); + + // should stay the same + await assertHTML( + page, + '', + ); + }); }); diff --git a/packages/lexical-playground/__tests__/e2e/ListMaxIndentLevel.spec.mjs b/packages/lexical-playground/__tests__/e2e/ListMaxIndentLevel.spec.mjs deleted file mode 100644 index fe37a84b209..00000000000 --- a/packages/lexical-playground/__tests__/e2e/ListMaxIndentLevel.spec.mjs +++ /dev/null @@ -1,128 +0,0 @@ -/** - * 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 {selectAll} from '../keyboardShortcuts/index.mjs'; -import { - assertHTML, - click, - focusEditor, - initialize, - selectFromAlignDropdown, - test, -} from '../utils/index.mjs'; - -async function toggleBulletList(page) { - await click(page, '.block-controls'); - await click(page, '.dropdown .icon.bullet-list'); -} - -async function clickIndentButton(page, times = 1) { - for (let i = 0; i < times; i++) { - await selectFromAlignDropdown(page, '.indent'); - } -} - -const MAX_INDENT_LEVEL = 6; - -test.describe('Nested List', () => { - test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); - test(`Can only indent until the max depth when list is empty`, async ({ - page, - isPlainText, - }) => { - test.skip(isPlainText); - await focusEditor(page); - await toggleBulletList(page); - - await clickIndentButton(page, MAX_INDENT_LEVEL); - - await assertHTML( - page, - '', - ); - - await clickIndentButton(page, MAX_INDENT_LEVEL); - - // should stay the same - await assertHTML( - page, - '', - ); - }); - - test(`Can only indent until the max depth when list has content`, async ({ - page, - isPlainText, - }) => { - test.skip(isPlainText); - await focusEditor(page); - await toggleBulletList(page); - await page.keyboard.type('World'); - - await clickIndentButton(page, MAX_INDENT_LEVEL); - - await assertHTML( - page, - '', - ); - - await clickIndentButton(page, MAX_INDENT_LEVEL); - - // should stay the same - await assertHTML( - page, - '', - ); - }); - - test(`Can only indent until the max depth a list with nested lists`, async ({ - page, - isPlainText, - }) => { - test.skip(isPlainText); - - await focusEditor(page); - await toggleBulletList(page); - await page.keyboard.type('Hello'); - await page.keyboard.press('Enter'); - await page.keyboard.type('from'); - await clickIndentButton(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('the'); - await clickIndentButton(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('other'); - await clickIndentButton(page); - await page.keyboard.press('Enter'); - await page.keyboard.type('side'); - await clickIndentButton(page); - await page.keyboard.press('Enter'); - - await assertHTML( - page, - '', - ); - - await selectAll(page); - - await clickIndentButton(page, 3); - - await assertHTML( - page, - '', - ); - - await clickIndentButton(page, MAX_INDENT_LEVEL); - - // should stay the same - await assertHTML( - page, - '', - ); - }); -}); diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 1b752ced18c..ec1d2c0d1fa 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -55,7 +55,6 @@ import InlineImagePlugin from './plugins/InlineImagePlugin'; import KeywordsPlugin from './plugins/KeywordsPlugin'; import {LayoutPlugin} from './plugins/LayoutPlugin/LayoutPlugin'; import LinkPlugin from './plugins/LinkPlugin'; -import ListMaxIndentLevelPlugin from './plugins/ListMaxIndentLevelPlugin'; import MarkdownShortcutPlugin from './plugins/MarkdownShortcutPlugin'; import {MaxLengthPlugin} from './plugins/MaxLengthPlugin'; import MentionsPlugin from './plugins/MentionsPlugin'; @@ -200,7 +199,6 @@ export default function Editor(): JSX.Element { - - + diff --git a/packages/lexical-playground/src/plugins/ListMaxIndentLevelPlugin/index.ts b/packages/lexical-playground/src/plugins/ListMaxIndentLevelPlugin/index.ts deleted file mode 100644 index 198543784c1..00000000000 --- a/packages/lexical-playground/src/plugins/ListMaxIndentLevelPlugin/index.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 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, RangeSelection} from 'lexical'; - -import {$getListDepth, $isListItemNode, $isListNode} from '@lexical/list'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import { - $getSelection, - $isElementNode, - $isRangeSelection, - COMMAND_PRIORITY_CRITICAL, - INDENT_CONTENT_COMMAND, -} from 'lexical'; -import {useEffect} from 'react'; - -function getElementNodesInSelection( - selection: RangeSelection, -): Set { - const nodesInSelection = selection.getNodes(); - - if (nodesInSelection.length === 0) { - return new Set([ - selection.anchor.getNode().getParentOrThrow(), - selection.focus.getNode().getParentOrThrow(), - ]); - } - - return new Set( - nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())), - ); -} - -function $shouldPreventIndent(maxDepth: number): boolean { - const selection = $getSelection(); - - if (!$isRangeSelection(selection)) { - return false; - } - - const elementNodesInSelection: Set = - getElementNodesInSelection(selection); - - let totalDepth = 0; - - for (const elementNode of elementNodesInSelection) { - if ($isListNode(elementNode)) { - totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth); - } else if ($isListItemNode(elementNode)) { - const parent = elementNode.getParent(); - - if (!$isListNode(parent)) { - throw new Error( - 'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.', - ); - } - - totalDepth = Math.max($getListDepth(parent) + 1, totalDepth); - } - } - - return totalDepth > maxDepth; -} - -export default function ListMaxIndentLevelPlugin({ - maxDepth = 7, -}: { - maxDepth?: number; -}): null { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerCommand( - INDENT_CONTENT_COMMAND, - () => $shouldPreventIndent(maxDepth), - COMMAND_PRIORITY_CRITICAL, - ); - }, [editor, maxDepth]); - return null; -} diff --git a/packages/lexical-react/flow/LexicalTabIndentationPlugin.js.flow b/packages/lexical-react/flow/LexicalTabIndentationPlugin.js.flow index 44784b01131..64d9ff87caa 100644 --- a/packages/lexical-react/flow/LexicalTabIndentationPlugin.js.flow +++ b/packages/lexical-react/flow/LexicalTabIndentationPlugin.js.flow @@ -11,6 +11,11 @@ import type {LexicalEditor} from 'lexical'; declare export function registerTabIndentation( editor: LexicalEditor, + maxIndent?: number, ): () => void; -declare export function TabIndentationPlugin(): null; +type Props = $ReadOnly<{ + maxIndent?: number, +}>; + +declare export function TabIndentationPlugin(props: Props): null; diff --git a/packages/lexical-react/src/LexicalTabIndentationPlugin.tsx b/packages/lexical-react/src/LexicalTabIndentationPlugin.tsx index db8c0e8a6e2..84025cc7472 100644 --- a/packages/lexical-react/src/LexicalTabIndentationPlugin.tsx +++ b/packages/lexical-react/src/LexicalTabIndentationPlugin.tsx @@ -9,13 +9,18 @@ import type {LexicalCommand, LexicalEditor, RangeSelection} from 'lexical'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {$filter, $getNearestBlockElementAncestorOrThrow} from '@lexical/utils'; +import { + $filter, + $getNearestBlockElementAncestorOrThrow, + mergeRegister, +} from '@lexical/utils'; import { $createRangeSelection, $getSelection, $isBlockElementNode, $isRangeSelection, $normalizeSelection__EXPERIMENTAL, + COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_EDITOR, INDENT_CONTENT_COMMAND, INSERT_TAB_COMMAND, @@ -57,24 +62,51 @@ function $indentOverTab(selection: RangeSelection): boolean { return false; } -export function registerTabIndentation(editor: LexicalEditor) { - return editor.registerCommand( - KEY_TAB_COMMAND, - (event) => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return false; - } +export function registerTabIndentation( + editor: LexicalEditor, + maxIndent?: number, +) { + return mergeRegister( + editor.registerCommand( + KEY_TAB_COMMAND, + (event) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + event.preventDefault(); + const command: LexicalCommand = $indentOverTab(selection) + ? event.shiftKey + ? OUTDENT_CONTENT_COMMAND + : INDENT_CONTENT_COMMAND + : INSERT_TAB_COMMAND; + return editor.dispatchCommand(command, undefined); + }, + COMMAND_PRIORITY_EDITOR, + ), + + editor.registerCommand( + INDENT_CONTENT_COMMAND, + () => { + if (maxIndent == null) { + return false; + } + + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + + const indents = selection + .getNodes() + .map((node) => + $getNearestBlockElementAncestorOrThrow(node).getIndent(), + ); - event.preventDefault(); - const command: LexicalCommand = $indentOverTab(selection) - ? event.shiftKey - ? OUTDENT_CONTENT_COMMAND - : INDENT_CONTENT_COMMAND - : INSERT_TAB_COMMAND; - return editor.dispatchCommand(command, undefined); - }, - COMMAND_PRIORITY_EDITOR, + return Math.max(...indents) + 1 >= maxIndent; + }, + COMMAND_PRIORITY_CRITICAL, + ), ); } @@ -83,11 +115,11 @@ export function registerTabIndentation(editor: LexicalEditor) { * recommend using this plugin as it could negatively affect acessibility for keyboard * users, causing focus to become trapped within the editor. */ -export function TabIndentationPlugin(): null { +export function TabIndentationPlugin({maxIndent}: {maxIndent?: number}): null { const [editor] = useLexicalComposerContext(); useEffect(() => { - return registerTabIndentation(editor); - }, [editor]); + return registerTabIndentation(editor, maxIndent); + }, [editor, maxIndent]); return null; }