Skip to content

Commit 2efe153

Browse files
fix(editor): add Tab/Shift+Tab list indentation (#202) (#211)
Co-authored-by: Ona <no-reply@ona.com>
1 parent 319f18a commit 2efe153

3 files changed

Lines changed: 174 additions & 0 deletions

File tree

e2e/editor-list-indent.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { test, expect } from "./fixtures/auth";
2+
import { navigateToEditorPage } from "./fixtures/editor-helpers";
3+
4+
test.describe("Editor list indentation", () => {
5+
test.beforeEach(async ({ authenticatedPage: page }) => {
6+
await navigateToEditorPage(page);
7+
});
8+
9+
test("Tab indents a bullet list item into a nested list", async ({
10+
authenticatedPage: page,
11+
}) => {
12+
const editor = page.locator('[contenteditable="true"]');
13+
await expect(editor).toBeVisible({ timeout: 10_000 });
14+
15+
// Create a bullet list with two items via slash command
16+
await editor.click();
17+
await page.keyboard.press("End");
18+
await page.keyboard.press("Enter");
19+
await page.keyboard.type("/");
20+
21+
const bulletOption = page
22+
.locator('[role="option"]')
23+
.filter({ hasText: "Bullet List" });
24+
await expect(bulletOption).toBeVisible({ timeout: 3_000 });
25+
await bulletOption.click();
26+
27+
await page.keyboard.type("First item");
28+
await page.keyboard.press("Enter");
29+
await page.keyboard.type("Second item");
30+
31+
// Verify we start with a flat list (no nested <ul>)
32+
await expect(editor.locator("ul ul")).not.toBeVisible();
33+
34+
// Press Tab to indent the second item
35+
await page.keyboard.press("Tab");
36+
37+
// Lexical wraps the indented item in a nested <ul> inside a wrapper <li>
38+
const nestedList = editor.locator("ul ul");
39+
await expect(nestedList).toBeVisible();
40+
await expect(nestedList.locator("> li")).toHaveCount(1);
41+
await expect(nestedList.locator("> li")).toContainText("Second item");
42+
});
43+
44+
test("Shift+Tab unindents a nested bullet list item", async ({
45+
authenticatedPage: page,
46+
}) => {
47+
const editor = page.locator('[contenteditable="true"]');
48+
await expect(editor).toBeVisible({ timeout: 10_000 });
49+
50+
// Create a bullet list and indent the second item
51+
await editor.click();
52+
await page.keyboard.press("End");
53+
await page.keyboard.press("Enter");
54+
await page.keyboard.type("/");
55+
56+
const bulletOption = page
57+
.locator('[role="option"]')
58+
.filter({ hasText: "Bullet List" });
59+
await expect(bulletOption).toBeVisible({ timeout: 3_000 });
60+
await bulletOption.click();
61+
62+
await page.keyboard.type("First item");
63+
await page.keyboard.press("Enter");
64+
await page.keyboard.type("Second item");
65+
await page.keyboard.press("Tab");
66+
67+
// Verify it's nested
68+
const nestedList = editor.locator("ul ul");
69+
await expect(nestedList).toBeVisible();
70+
71+
// Now Shift+Tab to unindent
72+
await page.keyboard.press("Shift+Tab");
73+
74+
// Should be back to a flat list with no nesting
75+
await expect(nestedList).not.toBeVisible();
76+
});
77+
78+
test("Tab indents a numbered list item into a nested list", async ({
79+
authenticatedPage: page,
80+
}) => {
81+
const editor = page.locator('[contenteditable="true"]');
82+
await expect(editor).toBeVisible({ timeout: 10_000 });
83+
84+
// Create a numbered list via slash command
85+
await editor.click();
86+
await page.keyboard.press("End");
87+
await page.keyboard.press("Enter");
88+
await page.keyboard.type("/");
89+
90+
const numberedOption = page
91+
.locator('[role="option"]')
92+
.filter({ hasText: "Numbered List" });
93+
await expect(numberedOption).toBeVisible({ timeout: 3_000 });
94+
await numberedOption.click();
95+
96+
await page.keyboard.type("First item");
97+
await page.keyboard.press("Enter");
98+
await page.keyboard.type("Second item");
99+
100+
// Press Tab to indent
101+
await page.keyboard.press("Tab");
102+
103+
// The second item should be nested inside the first
104+
const nestedList = editor.locator("ol ol");
105+
await expect(nestedList).toBeVisible();
106+
await expect(nestedList.locator("> li")).toHaveCount(1);
107+
});
108+
});

src/components/editor/editor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
99
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
1010
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
1111
import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin";
12+
import { ListTabIndentationPlugin } from "@/components/editor/list-tab-indentation-plugin";
1213
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
1314
import { ClickableLinkPlugin } from "@lexical/react/LexicalClickableLinkPlugin";
1415
import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin";
@@ -194,6 +195,7 @@ export function Editor({ pageId, initialContent, editorRef }: EditorProps) {
194195
<HistoryPlugin />
195196
<ListPlugin />
196197
<CheckListPlugin />
198+
<ListTabIndentationPlugin />
197199
<LinkPlugin validateUrl={validateUrl} />
198200
<ClickableLinkPlugin />
199201
<HorizontalRulePlugin />
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"use client";
2+
3+
import { useEffect } from "react";
4+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
5+
import { $isListItemNode } from "@lexical/list";
6+
import { $getNearestBlockElementAncestorOrThrow } from "@lexical/utils";
7+
import {
8+
$getSelection,
9+
$isRangeSelection,
10+
COMMAND_PRIORITY_HIGH,
11+
INDENT_CONTENT_COMMAND,
12+
KEY_TAB_COMMAND,
13+
OUTDENT_CONTENT_COMMAND,
14+
} from "lexical";
15+
16+
const MAX_LIST_INDENT = 7;
17+
18+
/**
19+
* Handles Tab/Shift+Tab for list item indentation. Lexical's built-in
20+
* TabIndentationPlugin only indents when the cursor is at block start
21+
* or the node reports canIndent()=true. ListItemNode returns false for
22+
* canIndent(), so Tab in the middle/end of a list item inserts a tab
23+
* character instead of nesting. This plugin intercepts Tab at a higher
24+
* priority and dispatches INDENT/OUTDENT commands when inside a list item.
25+
*/
26+
export function ListTabIndentationPlugin(): null {
27+
const [editor] = useLexicalComposerContext();
28+
29+
useEffect(() => {
30+
return editor.registerCommand(
31+
KEY_TAB_COMMAND,
32+
(event) => {
33+
const selection = $getSelection();
34+
if (!$isRangeSelection(selection)) {
35+
return false;
36+
}
37+
38+
const anchor = selection.anchor.getNode();
39+
const block = $getNearestBlockElementAncestorOrThrow(anchor);
40+
41+
if (!$isListItemNode(block)) {
42+
return false;
43+
}
44+
45+
event.preventDefault();
46+
47+
editor.update(() => {
48+
if (event.shiftKey) {
49+
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
50+
} else {
51+
if (block.getIndent() + 1 < MAX_LIST_INDENT) {
52+
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
53+
}
54+
}
55+
});
56+
57+
return true;
58+
},
59+
COMMAND_PRIORITY_HIGH,
60+
);
61+
}, [editor]);
62+
63+
return null;
64+
}

0 commit comments

Comments
 (0)