Skip to content

Commit 513adb3

Browse files
committed
feat(webapp): tune editor indent contexts and add Cypress indent suite
Expose literal tab indent for configured pad contexts including headings; keep the message composer on the same tab character. Add end-to-end coverage for indent, outdent, and delegation paths. Made-with: Cursor
1 parent dab8e30 commit 513adb3

9 files changed

Lines changed: 769 additions & 2 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Indent extension E2E
2+
3+
Coverage for `@docs.plus/extension-indent` in the real editor (`TipTap.tsx`: `indentChars: '\t'`; default `allowedIndentContexts` are body + blockquote paragraphs). Non-default context rules are covered by Jest in `packages/extension-indent`.
4+
5+
**Files:** `indent-shared.js` (helpers); `indent-paragraph-and-gates.cy.js`; `indent-lists.cy.js`; `indent-multiline.cy.js`; `indent-table.cy.js`; `indent-code-list.cy.js`.
6+
7+
## Scope
8+
9+
- **`indent-paragraph-and-gates`:** literal Tab / Shift+Tab in paragraphs; heading vs paragraph vs blockquote gates
10+
- **`indent-lists`:** keyboard sink/lift; programmatic nested / mixed lists; `indent()` false in list when parent is `listItem` (default allowlist)
11+
- **`indent-multiline`:** `indent()` / `outdent()` and keyboard Tab across multi-paragraph selections
12+
- **`indent-table`:** Tab / Shift+Tab delegate to table cells (no literal `\t` in text)
13+
- **`indent-code-list`:** code block has no literal tab indent under default contexts; list item `indent()` API gate
14+
15+
Shared (`indent-shared.js`): `EDITOR` / `PM`, `MS`, `getEditor` / `getText`, `setupIndentSpec(docName)` (visit + clear), `paragraphTextCaretPos` / `paragraphSelectionBounds`, TipTap JSON (`heading1`, `paragraphNode`, doc builders), `setDoc`, list/table helpers. Re-exports `docMaker` + `TEST_TITLE` so specs use one import for fixture + selectors.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/* eslint-disable no-undef */
2+
/**
3+
* E2E: code block (no literal paragraph indent); list item indent() API gate.
4+
* @see indent-shared.js
5+
*/
6+
import {
7+
MS,
8+
PM,
9+
TAB,
10+
TEST_TITLE,
11+
bulletList,
12+
getEditor,
13+
getText,
14+
heading1,
15+
listItem,
16+
paragraphTextCaretPos,
17+
section,
18+
setupIndentSpec
19+
} from './indent-shared.js'
20+
21+
describe('Indent — code block & list item gate', { testIsolation: false }, () => {
22+
setupIndentSpec('indent-code-list-e2e')
23+
24+
describe('Code block — literal indent not in default allowlist', () => {
25+
it('keeps code block active and does not apply literal indent Tab from Indent extension', () => {
26+
cy.window().then((win) => {
27+
const ed = getEditor(win)
28+
ed.chain()
29+
.focus()
30+
.setContent([heading1('S'), { type: 'paragraph', content: [] }])
31+
.toggleCodeBlock()
32+
.insertContent('codehere')
33+
.run()
34+
})
35+
cy.get(`${PM} pre`).should('exist')
36+
cy.get(`${PM} pre`).click()
37+
cy.wait(MS.cmd)
38+
cy.get(PM).realPress('Tab')
39+
cy.wait(MS.key)
40+
cy.window().then((win) => {
41+
const ed = getEditor(win)
42+
expect(ed.isActive('codeBlock')).to.eq(true)
43+
expect(getText(win)).to.contain('codehere')
44+
})
45+
})
46+
})
47+
48+
describe('List item — indent() blocked (parent listItem)', () => {
49+
it('does not insert tab via indent() inside list item paragraph', () => {
50+
cy.createDocument({
51+
documentName: TEST_TITLE.HelloDocy,
52+
sections: [section('L', [bulletList([listItem('Item')])])]
53+
})
54+
cy.window().then((win) => {
55+
const ed = getEditor(win)
56+
const p = paragraphTextCaretPos(ed, 'Item')
57+
expect(p).not.to.equal(null)
58+
const ok = ed.chain().focus().setTextSelection(p).indent().run()
59+
expect(ok).to.eq(false)
60+
})
61+
cy.wait(MS.cmd)
62+
cy.window().then((win) => {
63+
expect(getText(win)).to.contain('Item')
64+
expect(getText(win)).not.to.match(new RegExp(`${TAB}Item`))
65+
})
66+
})
67+
})
68+
})
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/* eslint-disable no-undef */
2+
/**
3+
* E2E: list Tab delegation (sink/lift) and programmatic nested / mixed documents.
4+
* @see indent-shared.js
5+
*/
6+
import {
7+
EDITOR,
8+
MS,
9+
PM,
10+
docH1WithTwoListItems,
11+
docMixedParagraphAndList,
12+
docNestedBulletTwoLevels,
13+
getEditor,
14+
getText,
15+
paragraphTextCaretPos,
16+
seedEmptySection,
17+
setDoc,
18+
setupIndentSpec,
19+
startBulletListFromEmptyParagraph,
20+
TAB
21+
} from './indent-shared.js'
22+
23+
describe('Indent — lists (delegation & nested)', { testIsolation: false }, () => {
24+
setupIndentSpec('indent-lists-e2e')
25+
26+
describe('Bullet list — delegation (sink / lift)', () => {
27+
it('nests a list item with Tab (sinkListItem before literal indent)', () => {
28+
seedEmptySection()
29+
startBulletListFromEmptyParagraph()
30+
cy.get(`${EDITOR} ul > li`).realType('Root')
31+
cy.get(`${EDITOR} ul > li`).realPress('Enter')
32+
cy.wait(MS.cmd)
33+
cy.get(EDITOR).realPress('Tab')
34+
cy.wait(MS.list)
35+
cy.get(`${PM} ul li ul`).should('exist')
36+
cy.get(`${PM} ul li ul li`).should('exist')
37+
})
38+
39+
it('lifts a nested item with Shift+Tab', () => {
40+
seedEmptySection()
41+
startBulletListFromEmptyParagraph()
42+
cy.get(`${EDITOR} ul > li`).realType('Root')
43+
cy.get(`${EDITOR} ul > li`).realPress('Enter')
44+
cy.wait(MS.cmd)
45+
cy.get(EDITOR).realPress('Tab')
46+
cy.wait(MS.list)
47+
cy.get(`${PM} ul li ul li`).should('exist')
48+
cy.get(`${PM} ul li ul li`).click()
49+
cy.wait(MS.ui)
50+
cy.get(EDITOR).realPress(['Shift', 'Tab'])
51+
cy.wait(MS.list)
52+
cy.get(`${PM} ul li ul`).should('not.exist')
53+
})
54+
55+
it('nested keyboard flow: two levels deep then Shift+Tab steps up (bullet-list parity)', () => {
56+
seedEmptySection()
57+
startBulletListFromEmptyParagraph()
58+
cy.get(`${EDITOR} ul > li`).realType('A')
59+
cy.get(`${EDITOR} ul > li`).realPress('Enter')
60+
cy.get(EDITOR).realPress('Tab')
61+
cy.get(`${EDITOR} ul li ul li`).realType('B')
62+
cy.get(`${EDITOR} ul li ul li`).realPress('Enter')
63+
cy.wait(80)
64+
cy.get(EDITOR).realPress('Tab')
65+
cy.wait(MS.key)
66+
cy.get(`${EDITOR} ul li ul li ul li`).should('exist')
67+
cy.get(`${EDITOR} ul li ul li ul li`).click()
68+
cy.wait(MS.ui)
69+
cy.get(EDITOR).realPress(['Shift', 'Tab'])
70+
cy.wait(MS.key)
71+
cy.get(`${EDITOR} ul li ul li ul`).should('not.exist')
72+
})
73+
})
74+
75+
describe('Complex document — programmatic schema, nested lists, Tab routing', () => {
76+
it('Shift+Tab lifts programmatic nested bullet (setContent), no nested ul after lift', () => {
77+
setDoc(docNestedBulletTwoLevels())
78+
cy.get(`${EDITOR} ul li ul li`).should('contain', 'Nested')
79+
cy.get(`${EDITOR} ul li ul li`).click()
80+
cy.wait(MS.ui)
81+
cy.get(EDITOR).realPress(['Shift', 'Tab'])
82+
cy.wait(MS.list)
83+
cy.get(`${PM} ul li ul`).should('not.exist')
84+
cy.window().then((win) => {
85+
expect(getText(win)).to.contain('Nested')
86+
expect(getText(win)).to.contain('Top')
87+
})
88+
})
89+
90+
it('Tab on second top-level bullet line sinks under first (typed), matching multi-item tree', () => {
91+
setDoc(docH1WithTwoListItems('bulletList', 'L', 'First', 'Second'))
92+
cy.get(`${EDITOR} ul > li`).eq(1).click()
93+
cy.wait(MS.ui)
94+
cy.get(EDITOR).realPress('Tab')
95+
cy.wait(MS.list)
96+
cy.get(`${EDITOR} ul li ul li`).should('contain', 'Second')
97+
})
98+
99+
it('ordered list: Tab sinks second item (delegation, not literal tab in list)', () => {
100+
setDoc(docH1WithTwoListItems('orderedList', 'OL', 'One', 'Two'))
101+
cy.get(`${EDITOR} ol > li`).eq(1).click()
102+
cy.wait(MS.ui)
103+
cy.get(EDITOR).realPress('Tab')
104+
cy.wait(MS.list)
105+
cy.get(`${PM} ol li ol li`).should('contain', 'Two')
106+
})
107+
108+
it('mixed blocks: Tab in paragraph adds literal tab; Tab in list still delegates to sink', () => {
109+
setDoc(docMixedParagraphAndList())
110+
cy.contains(`${PM} p`, 'Plain body').click()
111+
cy.wait(MS.ui)
112+
cy.get(EDITOR).realPress('Home')
113+
cy.get(EDITOR).realPress('Tab')
114+
cy.wait(MS.cmd)
115+
cy.window().then((win) => {
116+
expect(getText(win)).to.match(/\tPlain body/)
117+
})
118+
119+
cy.get(`${EDITOR} ul > li`).contains('OnlyItem').click()
120+
cy.wait(MS.ui)
121+
cy.get(`${EDITOR} ul > li`).realPress('Enter')
122+
cy.wait(80)
123+
cy.get(EDITOR).realPress('Tab')
124+
cy.wait(MS.list)
125+
cy.get(`${EDITOR} ul li ul li`).should('exist')
126+
})
127+
128+
it('deep nest: Tab from level-2 item creates third-level bullet', () => {
129+
setDoc(docNestedBulletTwoLevels())
130+
cy.get(`${EDITOR} ul li ul li`).contains('Nested').click()
131+
cy.wait(MS.ui)
132+
cy.get(`${EDITOR} ul li ul li`).realPress('Enter')
133+
cy.wait(80)
134+
cy.get(EDITOR).realPress('Tab')
135+
cy.wait(MS.list)
136+
cy.get(`${PM} ul li ul li ul`).should('exist')
137+
})
138+
139+
it('indent() in nested list item is false (default contexts exclude listItem paragraphs)', () => {
140+
cy.window().then((win) => {
141+
const ed = getEditor(win)
142+
ed.chain().focus().setContent(docNestedBulletTwoLevels()).run()
143+
const p = paragraphTextCaretPos(ed, 'Nested')
144+
expect(p).not.to.equal(null)
145+
const ok = ed.chain().focus().setTextSelection(p).indent().run()
146+
expect(ok).to.eq(false)
147+
})
148+
cy.wait(MS.cmd)
149+
cy.window().then((win) => {
150+
expect(getText(win)).to.contain('Nested')
151+
expect(getText(win)).not.to.match(new RegExp(`${TAB}Nested`))
152+
})
153+
})
154+
})
155+
})
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/* eslint-disable no-undef */
2+
/**
3+
* E2E: multiline indent/outdent via commands and keyboard.
4+
* @see indent-shared.js
5+
*/
6+
import {
7+
MS,
8+
PM,
9+
TAB,
10+
getEditor,
11+
getText,
12+
heading1,
13+
paragraphNode,
14+
paragraphSelectionBounds,
15+
setupIndentSpec
16+
} from './indent-shared.js'
17+
18+
describe('Indent — multiline selection', { testIsolation: false }, () => {
19+
setupIndentSpec('indent-multiline-e2e')
20+
21+
describe('Multi-line selection — commands', () => {
22+
it('prefixes each selected line with Tab via indent()', () => {
23+
cy.window().then((win) => {
24+
const ed = getEditor(win)
25+
ed.chain()
26+
.focus()
27+
.setContent([heading1('S'), paragraphNode('AA'), paragraphNode('BB')])
28+
.run()
29+
const { from, to } = paragraphSelectionBounds(ed, 'AA', 'BB')
30+
ed.chain().setTextSelection({ from, to }).indent().run()
31+
})
32+
cy.wait(MS.cmd)
33+
cy.window().then((win) => {
34+
const t = getText(win)
35+
expect(t).to.contain(`${TAB}AA`)
36+
expect(t).to.contain(`${TAB}BB`)
37+
})
38+
})
39+
40+
it('strips leading tabs from lines that have them via outdent()', () => {
41+
cy.window().then((win) => {
42+
const ed = getEditor(win)
43+
ed.chain()
44+
.focus()
45+
.setContent([heading1('S'), paragraphNode(`${TAB}X`), paragraphNode(`${TAB}Y`)])
46+
.run()
47+
const { from, to } = paragraphSelectionBounds(ed, `${TAB}X`, `${TAB}Y`)
48+
ed.chain().setTextSelection({ from, to }).outdent().run()
49+
})
50+
cy.wait(MS.cmd)
51+
cy.window().then((win) => {
52+
const t = getText(win)
53+
expect(t).to.contain('X')
54+
expect(t).to.contain('Y')
55+
expect(t).not.to.match(/\tX/)
56+
expect(t).not.to.match(/\tY/)
57+
})
58+
})
59+
60+
it('outdent() returns false when no line starts with Tab', () => {
61+
cy.window().then((win) => {
62+
const ed = getEditor(win)
63+
ed.chain()
64+
.focus()
65+
.setContent([heading1('S'), paragraphNode('no'), paragraphNode('tabs')])
66+
.run()
67+
const { from, to } = paragraphSelectionBounds(ed, 'no', 'tabs')
68+
const ok = ed.chain().setTextSelection({ from, to }).outdent().run()
69+
expect(ok).to.eq(false)
70+
})
71+
})
72+
})
73+
74+
describe('Multi-line — keyboard Tab / Shift-Tab', () => {
75+
it('adds a leading tab to each selected paragraph with Tab', () => {
76+
cy.window().then((win) => {
77+
const ed = getEditor(win)
78+
ed.chain()
79+
.focus()
80+
.setContent([heading1('S'), paragraphNode('L1'), paragraphNode('L2')])
81+
.run()
82+
const { from, to } = paragraphSelectionBounds(ed, 'L1', 'L2')
83+
ed.chain().setTextSelection({ from, to }).run()
84+
})
85+
cy.get(PM).focus()
86+
cy.wait(MS.ui)
87+
cy.get(PM).realPress('Tab')
88+
cy.wait(MS.key)
89+
cy.window().then((win) => {
90+
const t = getText(win)
91+
expect(t).to.contain(`${TAB}L1`)
92+
expect(t).to.contain(`${TAB}L2`)
93+
})
94+
})
95+
96+
it('strips leading tab from each line in selection with Shift+Tab when present', () => {
97+
cy.window().then((win) => {
98+
const ed = getEditor(win)
99+
ed.chain()
100+
.focus()
101+
.setContent([heading1('S'), paragraphNode(`${TAB}a`), paragraphNode(`${TAB}b`)])
102+
.run()
103+
const { from, to } = paragraphSelectionBounds(ed, `${TAB}a`, `${TAB}b`)
104+
ed.chain().setTextSelection({ from, to }).run()
105+
})
106+
cy.get(PM).focus()
107+
cy.wait(MS.ui)
108+
cy.get(PM).realPress(['Shift', 'Tab'])
109+
cy.wait(MS.key)
110+
cy.window().then((win) => {
111+
const t = getText(win)
112+
expect(t).to.contain('a')
113+
expect(t).to.contain('b')
114+
})
115+
})
116+
})
117+
})

0 commit comments

Comments
 (0)