diff --git a/ui/src/editor/plugins/index.tsx b/ui/src/editor/plugins/index.tsx
index beebe697..b03e13b1 100644
--- a/ui/src/editor/plugins/index.tsx
+++ b/ui/src/editor/plugins/index.tsx
@@ -9,6 +9,7 @@ import { useEditorRef } from '@/context/editor/editor-ref-context';
import { FixIOSKoreanIssuePlugin } from '@/editor/plugins/fix-ios-korean-issue';
import { HistoryPlugin } from '@/editor/plugins/history';
+import { ListMaxIndentLevelPlugin } from '@/editor/plugins/list-max-indent-level';
import { MarkdownShortcutPlugin } from '@/editor/plugins/markdown-shorcut';
import { MaxLengthPlugin } from '@/editor/plugins/max-length';
@@ -37,6 +38,7 @@ export function Plugins({ maxLength }: { maxLength?: number }) {
+
{onRef !== undefined && }
{maxLength && }
>
diff --git a/ui/src/editor/plugins/list-max-indent-level/index.tsx b/ui/src/editor/plugins/list-max-indent-level/index.tsx
new file mode 100644
index 00000000..252e7fc3
--- /dev/null
+++ b/ui/src/editor/plugins/list-max-indent-level/index.tsx
@@ -0,0 +1,78 @@
+import type { ListNode } from '@lexical/list';
+import { $getListDepth, $isListItemNode, $isListNode } from '@lexical/list';
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
+import type { ElementNode, RangeSelection } from 'lexical';
+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();
+ // TODO: Remove this type assertion once the lexical types are updated. facebook#5710
+ const anchor = selection.anchor.getNode() as ElementNode;
+ const focus = selection.focus.getNode() as ElementNode;
+
+ if (nodesInSelection.length === 0) {
+ return new Set([anchor.getParentOrThrow(), focus.getParentOrThrow()]);
+ }
+
+ return new Set(nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())));
+}
+
+function shouldPreventIndent(maxDepth: number) {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+
+ const elementNodesInSelection: Set = getElementNodesInSelection(selection);
+
+ let totalDepth = 0;
+
+ for (const elementNode of elementNodesInSelection) {
+ let listNode: ListNode | null = null;
+
+ if ($isListNode(elementNode)) {
+ listNode = elementNode;
+ } else if ($isListItemNode(elementNode)) {
+ // TODO: Remove this type assertion once the lexical types are updated.
+ const parent = elementNode.getParent() as ElementNode;
+
+ if (!$isListNode(parent)) {
+ throw new Error(
+ 'ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent.'
+ );
+ }
+
+ listNode = parent;
+ }
+
+ if (listNode !== null) {
+ totalDepth = Math.max($getListDepth(listNode) + 1, totalDepth);
+ }
+ }
+
+ return totalDepth > maxDepth;
+}
+
+export function ListMaxIndentLevelPlugin({ maxDepth = 7 }: { maxDepth?: number }) {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(
+ () =>
+ editor.registerCommand(
+ INDENT_CONTENT_COMMAND,
+ () => shouldPreventIndent(maxDepth),
+ COMMAND_PRIORITY_CRITICAL
+ ),
+ [editor, maxDepth]
+ );
+
+ return null;
+}