Skip to content

Commit 2573a64

Browse files
committed
improvement(files): Notion-style bullet delete/indent, fix empty-nested-bullet heading corruption
Backspace at the start of a list item now outdents a nested item or clears a top-level item to a paragraph in place (Notion behavior) instead of deleting the row and jumping the caret to the previous block; Enter on an empty nested item outdents. Empty non-trailing top-level items still collapse cleanly since they cannot round-trip as a lifted paragraph. Also strips nested empty list-item marker lines on serialize: a nested empty bullet re-parsed as a Setext heading underline, silently turning its parent line into an H2 and dropping the bullet. Top-level empty items are preserved.
1 parent 0a36d64 commit 2573a64

4 files changed

Lines changed: 359 additions & 48 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts

Lines changed: 159 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { GapCursor } from '@tiptap/pm/gapcursor'
1111
import { AllSelection, NodeSelection } from '@tiptap/pm/state'
1212
import { beforeEach, describe, expect, it, vi } from 'vitest'
1313
import { createMarkdownEditorExtensions } from './editor-extensions'
14+
import { postProcessSerializedMarkdown } from './markdown-fidelity'
1415
import { MENTION_PLUGIN_KEY } from './mention'
1516
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
1617

@@ -192,13 +193,11 @@ describe('empty wrapped-block Backspace', () => {
192193
it.each([
193194
['bullet middle', '- one\n- two\n- three', 'two', '- one\n- three'],
194195
['bullet first', '- one\n- two\n- three', 'one', '- two\n- three'],
195-
['bullet last', '- one\n- two', 'two', '- one'],
196196
['ordered middle', '1. one\n2. two\n3. three', 'two', '1. one\n2. three'],
197197
['task middle', '- [ ] one\n- [ ] two\n- [ ] three', 'two', '- [ ] one\n- [ ] three'],
198198
['blockquote middle', '> one\n>\n> two\n>\n> three', 'two', '> one\n>\n> three'],
199-
['nested item', '- one\n - two\n- three', 'two', '- one\n- three'],
200199
])(
201-
'removes the emptied %s cleanly — one container, no stray paragraph, round-trips',
200+
'removes an emptied non-trailing %s cleanly — one container, no stray paragraph, round-trips',
202201
(_label, markdown, word, expected) => {
203202
const editor = editorWith('')
204203
editor.commands.setContent(markdown, { contentType: 'markdown' })
@@ -213,10 +212,10 @@ describe('empty wrapped-block Backspace', () => {
213212
}
214213
)
215214

216-
it('leaves a gap cursor — never a NodeSelection — when the removed bullet was followed by an image at doc start', () => {
217-
// Regression: `Selection.near` after the delete silently NodeSelected the following image, so a
218-
// second Backspace while "clearing the bullet" deleted the image (and typing would have replaced
219-
// it). The selection left behind must never make the next keystroke destructive.
215+
it('clears a lone empty bullet before an image to a paragraph and never destroys the image', () => {
216+
// Regression: an earlier delete-and-jump path silently NodeSelected the following image, so a
217+
// second Backspace while "clearing the bullet" deleted it. The lone empty bullet now lifts into an
218+
// empty paragraph in place (Notion), the image is untouched, and no repeated Backspace destroys it.
220219
const editor = editorWith({
221220
type: 'doc',
222221
content: [
@@ -227,11 +226,11 @@ describe('empty wrapped-block Backspace', () => {
227226
editor.commands.setTextSelection(3)
228227
pressBackspace(editor)
229228

230-
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
229+
expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph'])
231230
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
232231

233232
pressBackspace(editor)
234-
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
233+
expect(blockShape(editor)).toContain('image')
235234
editor.destroy()
236235
})
237236

@@ -252,11 +251,10 @@ describe('empty wrapped-block Backspace', () => {
252251
editor.destroy()
253252
})
254253

255-
it('never NodeSelects a leaf BEFORE the removed bullet either (findFrom textOnly skips atoms)', () => {
256-
// `Selection.findFrom($gap, -1, true)` cannot return a NodeSelection: with textOnly,
257-
// prosemirror-state's findSelectionIn skips atoms entirely (`!text && isSelectable`). With an
258-
// image directly before the emptied bullet and no textblock behind it, the backward search
259-
// returns null and the gap-cursor branch takes over — the image is never silently selected.
254+
it('clears a lone empty bullet after an image to a paragraph without selecting the image', () => {
255+
// With an image directly before the emptied bullet, clearing the bullet must not silently select
256+
// (and so endanger) the image. The bullet lifts into an empty paragraph in place; the image is
257+
// untouched and no repeated Backspace destroys it.
260258
const editor = editorWith({
261259
type: 'doc',
262260
content: [
@@ -268,11 +266,11 @@ describe('empty wrapped-block Backspace', () => {
268266
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
269267
pressBackspace(editor)
270268

271-
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
269+
expect(blockShape(editor)).toEqual(['image', 'paragraph', 'paragraph'])
272270
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
273271

274272
pressBackspace(editor)
275-
expect(blockShape(editor)).toEqual(['image', 'paragraph'])
273+
expect(blockShape(editor)).toContain('image')
276274
editor.destroy()
277275
})
278276

@@ -291,7 +289,7 @@ describe('empty wrapped-block Backspace', () => {
291289
editor.destroy()
292290
})
293291

294-
it('still prefers the previous textblock caret when one exists (image after the bullet untouched)', () => {
292+
it('clears a lone empty bullet between a paragraph and an image to a paragraph, leaving the image', () => {
295293
const editor = editorWith({
296294
type: 'doc',
297295
content: [
@@ -303,10 +301,138 @@ describe('empty wrapped-block Backspace', () => {
303301
editor.commands.setTextSelection(10)
304302
pressBackspace(editor)
305303

306-
expect(blockShape(editor)).toEqual(['paragraph', 'image', 'paragraph'])
304+
expect(blockShape(editor)).toEqual(['paragraph', 'paragraph', 'image', 'paragraph'])
307305
expect(editor.state.selection.empty).toBe(true)
308306
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
309-
expect(editor.state.selection.$from.parent.textContent).toBe('hello')
307+
// The cleared bullet is now an empty paragraph, and 'hello' is untouched above it.
308+
expect(editor.state.doc.firstChild?.textContent).toBe('hello')
309+
editor.destroy()
310+
})
311+
})
312+
313+
describe('Notion-style list Backspace (clear / outdent)', () => {
314+
beforeEach(() => {
315+
Element.prototype.scrollIntoView = vi.fn()
316+
})
317+
318+
/** Puts the caret at the very start of the item text `word`. */
319+
function caretAtStartOf(editor: Editor, word: string): void {
320+
editor.state.doc.descendants((node, pos) => {
321+
if (node.isText && node.text === word) editor.commands.setTextSelection(pos)
322+
})
323+
}
324+
325+
it('lifts a top-level bullet WITH TEXT into a paragraph, keeping the text (round-trips)', () => {
326+
const editor = editorWith('')
327+
editor.commands.setContent('- one\n- two', { contentType: 'markdown' })
328+
editor.commands.focus()
329+
caretAtStartOf(editor, 'two')
330+
pressBackspace(editor)
331+
332+
// A bulletList (just 'one') followed by the lifted 'two' paragraph (+ TipTap's trailing filler).
333+
expect(blockShape(editor).slice(0, 2)).toEqual(['bulletList', 'paragraph'])
334+
const { md, reparsed } = markdownRoundTrip(editor)
335+
expect(md.trim()).toBe('- one\n\ntwo')
336+
expect(reparsed).toBe(md)
337+
editor.destroy()
338+
})
339+
340+
it('clears an empty TRAILING bullet to a paragraph in place — no delete, caret stays on the line', () => {
341+
const editor = editorWith('')
342+
editor.commands.setContent('- one\n- two', { contentType: 'markdown' })
343+
editor.commands.focus()
344+
emptyItem(editor, 'two')
345+
pressBackspace(editor)
346+
347+
// The bullet becomes a paragraph; the list keeps only 'one'. The caret sits in the new empty
348+
// paragraph rather than jumping back into the 'one' bullet.
349+
const list = editor.getJSON().content?.find((node) => node.type === 'bulletList')
350+
expect(list?.content).toHaveLength(1)
351+
expect(editor.state.selection.empty).toBe(true)
352+
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
353+
expect(editor.state.selection.$from.parent.textContent).toBe('')
354+
expect(editor.getMarkdown().trim()).toBe('- one')
355+
editor.destroy()
356+
})
357+
358+
it('clears a lone empty bullet to an empty paragraph (whole doc)', () => {
359+
const editor = editorWith('')
360+
editor.commands.setContent('- one', { contentType: 'markdown' })
361+
editor.commands.focus()
362+
emptyItem(editor, 'one')
363+
pressBackspace(editor)
364+
365+
expect(editor.getJSON().content?.some((n) => n.type === 'bulletList')).toBe(false)
366+
expect(editor.state.selection.$from.parent.type.name).toBe('paragraph')
367+
editor.destroy()
368+
})
369+
370+
it('outdents a nested bullet WITH TEXT one level instead of merging it (round-trips)', () => {
371+
const editor = editorWith('')
372+
editor.commands.setContent('- one\n - two', { contentType: 'markdown' })
373+
editor.commands.focus()
374+
caretAtStartOf(editor, 'two')
375+
pressBackspace(editor)
376+
377+
const { md, reparsed } = markdownRoundTrip(editor)
378+
expect(md.trim()).toBe('- one\n- two')
379+
expect(reparsed).toBe(md)
380+
editor.destroy()
381+
})
382+
383+
it('outdents an empty nested bullet one level (round-trips)', () => {
384+
const editor = editorWith('')
385+
editor.commands.setContent('- one\n - two\n- three', { contentType: 'markdown' })
386+
editor.commands.focus()
387+
emptyItem(editor, 'two')
388+
pressBackspace(editor)
389+
390+
const { md, reparsed } = markdownRoundTrip(editor)
391+
expect(md.trim()).toBe('- one\n- \n- three')
392+
expect(reparsed).toBe(md)
393+
editor.destroy()
394+
})
395+
396+
it('clears a checklist item the same way (task item → paragraph)', () => {
397+
const editor = editorWith('')
398+
editor.commands.setContent('- [ ] one\n- [ ] two', { contentType: 'markdown' })
399+
editor.commands.focus()
400+
caretAtStartOf(editor, 'two')
401+
pressBackspace(editor)
402+
403+
expect(blockShape(editor).slice(0, 2)).toEqual(['taskList', 'paragraph'])
404+
expect(editor.getMarkdown().trim()).toBe('- [ ] one\n\ntwo')
405+
editor.destroy()
406+
})
407+
})
408+
409+
describe('empty nested bullet does not corrupt its parent (Enter → Tab)', () => {
410+
beforeEach(() => {
411+
Element.prototype.scrollIntoView = vi.fn()
412+
})
413+
414+
it('serializes a stranded empty sub-bullet away instead of turning the parent into a heading', () => {
415+
// Repro: type a bullet, Enter for a new bullet, Tab to indent it into an empty sub-bullet, then
416+
// leave it. The serialized `- one\n - ` would re-parse as `- ## one` (Setext underline). The
417+
// serialize step must strip the empty sub-bullet so the parent stays a bullet and round-trips.
418+
const editor = editorWith('')
419+
editor.commands.setContent('- one', { contentType: 'markdown' })
420+
editor.commands.focus()
421+
let end = -1
422+
editor.state.doc.descendants((node, pos) => {
423+
if (node.isText && node.text === 'one') end = pos + 3
424+
})
425+
editor.commands.setTextSelection(end)
426+
pressKey(editor, 'Enter')
427+
pressKey(editor, 'Tab')
428+
429+
const saved = postProcessSerializedMarkdown(editor.getMarkdown())
430+
expect(saved).toBe('- one\n')
431+
432+
// Reloading the saved markdown keeps a bullet — never a heading.
433+
editor.commands.setContent(saved, { contentType: 'markdown' })
434+
expect(blockShape(editor)).not.toContain('heading')
435+
expect(blockShape(editor)).toContain('bulletList')
310436
editor.destroy()
311437
})
312438
})
@@ -341,6 +467,20 @@ describe('empty list-item Enter', () => {
341467
expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true)
342468
editor.destroy()
343469
})
470+
471+
it('outdents an empty NESTED item one level (Notion) instead of removing it', () => {
472+
const editor = editorWith('')
473+
editor.commands.setContent('- one\n - two', { contentType: 'markdown' })
474+
editor.commands.focus()
475+
emptyItem(editor, 'two')
476+
pressKey(editor, 'Enter')
477+
478+
// The emptied nested item outdents to a second top-level bullet rather than being deleted.
479+
const list = editor.getJSON().content?.find((node) => node.type === 'bulletList')
480+
expect(list?.content).toHaveLength(2)
481+
expect(list?.content?.every((item) => item.type === 'listItem')).toBe(true)
482+
editor.destroy()
483+
})
344484
})
345485

346486
describe('verbatim block boundary (isolating)', () => {

0 commit comments

Comments
 (0)