snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped
This commit is contained in:
230
packages/extension-list/tests/integration/listItemDelete.test.ts
Normal file
230
packages/extension-list/tests/integration/listItemDelete.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { NodeSelection } from '@tiptap/pm/state'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { BulletList, ListItem } from '../../src/index.js'
|
||||
import { hasBranchingNestedListAfterCursor } from '../../src/helpers/hasBranchingNestedListAfterCursor.js'
|
||||
import { hoistBranchingNestedList } from '../../src/helpers/hoistBranchingNestedList.js'
|
||||
|
||||
const shallowNestedListContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Outer' }] },
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Inner' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const branchingNestedListContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] },
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1.1' }] },
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Item 1.1.1' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'Item 1.1.2' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1.2' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const wrapperNames = ['bulletList', 'orderedList']
|
||||
|
||||
function findTextPosition(editor: Editor, text: string) {
|
||||
let position: number | null = null
|
||||
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (position !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (node.isText && node.text === text) {
|
||||
position = pos + node.text.length
|
||||
}
|
||||
})
|
||||
|
||||
if (position === null) {
|
||||
throw new Error(`Could not find text position for "${text}"`)
|
||||
}
|
||||
|
||||
return position
|
||||
}
|
||||
|
||||
function dispatchDelete(editor: Editor) {
|
||||
return editor.view.someProp('handleKeyDown', f =>
|
||||
f(
|
||||
editor.view,
|
||||
new KeyboardEvent('keydown', { key: 'Delete', bubbles: true, cancelable: true }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe('ListItem Delete with nested lists', () => {
|
||||
let editor: Editor
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
})
|
||||
|
||||
it('hoists branching nested list via command', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, BulletList, ListItem],
|
||||
content: branchingNestedListContent,
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(findTextPosition(editor, 'Item 1'))
|
||||
|
||||
const handled = hoistBranchingNestedList(
|
||||
editor.state,
|
||||
editor.view.dispatch,
|
||||
'listItem',
|
||||
wrapperNames,
|
||||
)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(editor.getJSON().content?.[0]?.content).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('does not treat shallow nested lists as branching', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, BulletList, ListItem],
|
||||
content: shallowNestedListContent,
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(findTextPosition(editor, 'Outer'))
|
||||
|
||||
expect(hasBranchingNestedListAfterCursor(editor.state, 'listItem', wrapperNames)).toBe(false)
|
||||
expect(hoistBranchingNestedList(editor.state, undefined, 'listItem', wrapperNames)).toBe(false)
|
||||
})
|
||||
|
||||
it('hoists branching nested list items on Delete without node selection', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, BulletList, ListItem],
|
||||
content: branchingNestedListContent,
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(findTextPosition(editor, 'Item 1'))
|
||||
|
||||
expect(dispatchDelete(editor)).toBe(true)
|
||||
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
|
||||
|
||||
const topList = editor.getJSON().content?.[0]
|
||||
|
||||
expect(topList?.type).toBe('bulletList')
|
||||
expect(topList?.content).toHaveLength(4)
|
||||
|
||||
const item1 = topList?.content?.[0]
|
||||
|
||||
expect(item1?.type).toBe('listItem')
|
||||
expect(item1?.content).toHaveLength(1)
|
||||
expect(item1?.content?.[0]?.type).toBe('paragraph')
|
||||
|
||||
expect(topList?.content?.[1]?.content?.[0]?.content?.[0]?.text).toBe('Item 1.1')
|
||||
expect(topList?.content?.[2]?.content?.[0]?.content?.[0]?.text).toBe('Item 1.2')
|
||||
expect(topList?.content?.[3]?.content?.[0]?.content?.[0]?.text).toBe('Item 2')
|
||||
})
|
||||
|
||||
it('does not create node selection on a second Delete after hoisting', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, BulletList, ListItem],
|
||||
content: branchingNestedListContent,
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(findTextPosition(editor, 'Item 1'))
|
||||
|
||||
dispatchDelete(editor)
|
||||
dispatchDelete(editor)
|
||||
|
||||
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
|
||||
|
||||
const docText = JSON.stringify(editor.getJSON())
|
||||
|
||||
expect(docText).toContain('Item 1.1')
|
||||
expect(docText).toContain('Item 1.2')
|
||||
expect(docText).toContain('Item 2')
|
||||
expect(editor.getJSON().content?.[0]?.content?.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it('still allows joinForward for shallow nested lists when nested items are leaf nodes', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, BulletList, ListItem],
|
||||
content: shallowNestedListContent,
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(findTextPosition(editor, 'Outer'))
|
||||
|
||||
expect(dispatchDelete(editor)).toBe(true)
|
||||
expect(editor.state.selection).not.toBeInstanceOf(NodeSelection)
|
||||
|
||||
const docText = JSON.stringify(editor.getJSON())
|
||||
|
||||
expect(docText).toContain('Outer')
|
||||
expect(docText).toContain('Inner')
|
||||
})
|
||||
})
|
||||
155
packages/extension-list/tests/integration/listKeymapTab.test.ts
Normal file
155
packages/extension-list/tests/integration/listKeymapTab.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Blockquote from '@tiptap/extension-blockquote'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import HorizontalRule from '@tiptap/extension-horizontal-rule'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { GapCursor } from '@tiptap/pm/gapcursor'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { BulletList, ListItem, ListKeymap, TaskItem, TaskList } from '../../src/index.js'
|
||||
|
||||
function findNodePosition(editor: Editor, typeName: string) {
|
||||
let position: number | null = null
|
||||
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (position !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (node.type.name === typeName) {
|
||||
position = pos
|
||||
}
|
||||
})
|
||||
|
||||
if (position === null) {
|
||||
throw new Error(`Could not find position of "${typeName}"`)
|
||||
}
|
||||
|
||||
return position
|
||||
}
|
||||
|
||||
function findTextStart(editor: Editor, text: string) {
|
||||
let position: number | null = null
|
||||
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (position !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (node.isText && node.text === text) {
|
||||
position = pos
|
||||
}
|
||||
})
|
||||
|
||||
if (position === null) {
|
||||
throw new Error(`Could not find text position for "${text}"`)
|
||||
}
|
||||
|
||||
return position
|
||||
}
|
||||
|
||||
function dispatchTab(editor: Editor) {
|
||||
return editor.view.someProp('handleKeyDown', f =>
|
||||
f(editor.view, new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true })),
|
||||
)
|
||||
}
|
||||
|
||||
describe('ListKeymap Tab', () => {
|
||||
let editor: Editor
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
})
|
||||
|
||||
it('does nothing at a gap cursor position', () => {
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
BulletList,
|
||||
ListItem,
|
||||
Blockquote,
|
||||
HorizontalRule,
|
||||
ListKeymap,
|
||||
],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'blockquote', content: [{ type: 'horizontalRule' }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// a gap cursor before the hr passes handleTab's empty-selection and parentOffset checks
|
||||
const $gap = editor.state.doc.resolve(findNodePosition(editor, 'horizontalRule'))
|
||||
editor.view.dispatch(editor.state.tr.setSelection(new GapCursor($gap)))
|
||||
|
||||
const before = editor.getJSON()
|
||||
|
||||
expect(dispatchTab(editor)).toBeFalsy()
|
||||
expect(editor.getJSON()).toEqual(before)
|
||||
})
|
||||
|
||||
it('sinks a paragraph only once when list types are chained', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, BulletList, ListItem, TaskList, TaskItem, ListKeymap],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'A' }] },
|
||||
{
|
||||
type: 'taskList',
|
||||
content: [
|
||||
{
|
||||
type: 'taskItem',
|
||||
attrs: { checked: false },
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'T' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
editor.commands.setTextSelection(findTextStart(editor, 'B'))
|
||||
|
||||
expect(dispatchTab(editor)).toBe(true)
|
||||
|
||||
const listItem = editor.getJSON().content?.[0]?.content?.[0]
|
||||
|
||||
// "B" becomes the last child of the list item and must not sink again into the taskItem
|
||||
expect(listItem?.content?.map(node => node.type)).toEqual([
|
||||
'paragraph',
|
||||
'taskList',
|
||||
'paragraph',
|
||||
])
|
||||
expect(listItem?.content?.[2]?.content?.[0]?.text).toBe('B')
|
||||
|
||||
const taskItem = listItem?.content?.[1]?.content?.[0]
|
||||
|
||||
expect(taskItem?.content).toHaveLength(1)
|
||||
expect(taskItem?.content?.[0]?.content?.[0]?.text).toBe('T')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { MarkdownManager } from '@tiptap/markdown'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { BulletList, ListItem, OrderedList } from '../../src/index.js'
|
||||
|
||||
describe('phone numbers starting a line', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, BulletList, OrderedList, ListItem],
|
||||
})
|
||||
|
||||
const paragraph = (text: string) => ({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||
})
|
||||
|
||||
it('keeps "(216) 555-1234" as a single paragraph', () => {
|
||||
// The mid-line "216)" must not split the paragraph into "(" + an ordered
|
||||
// list. A list marker only starts a list at the beginning of a line.
|
||||
const json = markdownManager.parse('(216) 555-1234')
|
||||
|
||||
expect(json).toEqual(paragraph('(216) 555-1234'))
|
||||
})
|
||||
|
||||
it('does not split "Call me at 555) later" mid-line', () => {
|
||||
const json = markdownManager.parse('Call me at 555) later')
|
||||
|
||||
expect(json).toEqual(paragraph('Call me at 555) later'))
|
||||
})
|
||||
|
||||
it('still treats a leading "216) ..." as an ordered list (CommonMark)', () => {
|
||||
const json = markdownManager.parse('216) 555-1234')
|
||||
|
||||
expect(json).toEqual({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 216 },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: '555-1234' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('still splits a paragraph followed by a real ordered list', () => {
|
||||
const json = markdownManager.parse('Intro\n1. First\n2. Second')
|
||||
|
||||
expect(json.content?.[0].type).toBe('paragraph')
|
||||
expect(json.content?.[1].type).toBe('orderedList')
|
||||
})
|
||||
|
||||
it('does not interrupt a paragraph with a non-1 ordered list', () => {
|
||||
const json = markdownManager.parse('Intro\n3. Third\n4. Fourth')
|
||||
|
||||
expect(json.content).toHaveLength(1)
|
||||
expect(json.content?.[0].type).toBe('paragraph')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,696 @@
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import Bold from '@tiptap/extension-bold'
|
||||
import Document from '@tiptap/extension-document'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import { MarkdownManager } from '@tiptap/markdown'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
import { ListItem, OrderedList } from '../../src/index.js'
|
||||
|
||||
describe('OrderedList type attribute', () => {
|
||||
let editor: Editor
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy()
|
||||
})
|
||||
|
||||
describe('markdown round-trip', () => {
|
||||
it('parses markdown with lowercase alpha markers as type "a"', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const json = markdownManager.parse('a. Item 1\nb. Item 2')
|
||||
|
||||
expect(json.content).toHaveLength(1)
|
||||
expect(json.content[0].type).toBe('orderedList')
|
||||
expect(json.content[0].attrs?.type).toBe('a')
|
||||
expect(json.content[0].attrs?.start).toBeUndefined()
|
||||
expect(json.content[0].content).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('parses markdown with alpha list starting at b', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const json = markdownManager.parse('b. Item 1\nc. Item 2')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('a')
|
||||
expect(json.content[0].attrs?.start).toBe(2)
|
||||
})
|
||||
|
||||
it('parses markdown with numeric list starting at 3', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const json = markdownManager.parse('3. Item 1\n4. Item 2')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBeUndefined()
|
||||
expect(json.content[0].attrs?.start).toBe(3)
|
||||
})
|
||||
|
||||
it('parses markdown with roman list starting at II', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const json = markdownManager.parse('II. Item 1\nIII. Item 2')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('I')
|
||||
expect(json.content[0].attrs?.start).toBe(2)
|
||||
})
|
||||
|
||||
it('parses markdown with uppercase alpha markers as type "A"', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const json = markdownManager.parse('A. Item 1\nB. Item 2')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('A')
|
||||
})
|
||||
|
||||
it('parses markdown with lowercase roman markers as type "i"', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const json = markdownManager.parse('i. Item 1\nii. Item 2\niii. Item 3')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('i')
|
||||
expect(json.content[0].content).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('parses markdown with uppercase roman markers as type "I"', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const json = markdownManager.parse('I. Item 1\nII. Item 2')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('I')
|
||||
})
|
||||
|
||||
it('parses default numeric markdown markers with default type', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const json = markdownManager.parse('1. Item 1\n2. Item 2')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the first character of an under-indented continuation line', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
// The continuation line is indented by a single space, fewer columns than the
|
||||
// marker width. The leading indentation must be stripped without eating the
|
||||
// first real character of the line.
|
||||
const json = markdownManager.parse('1. Item one\n continued text')
|
||||
|
||||
const collectText = (node: JSONContent): string =>
|
||||
(node.text ?? '') + (node.content ?? []).map(collectText).join('')
|
||||
|
||||
const text = collectText(json.content[0].content[0])
|
||||
|
||||
expect(text).toBe('Item one\ncontinued text')
|
||||
})
|
||||
|
||||
it('keeps the first character of an under-indented continuation line for wider markers', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const collectText = (node: JSONContent): string =>
|
||||
(node.text ?? '') + (node.content ?? []).map(collectText).join('')
|
||||
|
||||
const numeric = markdownManager.parse('10. Item ten\n continued text')
|
||||
expect(collectText(numeric.content[0].content[0])).toBe('Item ten\ncontinued text')
|
||||
|
||||
const roman = markdownManager.parse('iv. Item four\n continued text')
|
||||
expect(collectText(roman.content[0].content[0])).toBe('Item four\ncontinued text')
|
||||
})
|
||||
|
||||
it('serializes an ordered list with type="a" to lowercase alpha markers', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const md = markdownManager.serialize({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: 'a' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item A' }] }],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item B' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(md).toBe('a. Item A\nb. Item B')
|
||||
})
|
||||
|
||||
it('serializes an ordered list with type="I" to uppercase roman markers', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const md = markdownManager.serialize({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: 'I' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'One' }] }],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Two' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(md).toBe('I. One\nII. Two')
|
||||
})
|
||||
|
||||
it('serializes a default ordered list with numeric markers', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const md = markdownManager.serialize({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(md).toBe('1. Item 1\n2. Item 2')
|
||||
})
|
||||
|
||||
it('round-trips markdown with type="a" preserving the type', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const original = 'a. Item A\nb. Item B'
|
||||
|
||||
// Parse
|
||||
const json = markdownManager.parse(original)
|
||||
expect(json.content[0].attrs?.type).toBe('a')
|
||||
|
||||
// Serialize back
|
||||
const md = markdownManager.serialize(json)
|
||||
expect(md).toBe(original)
|
||||
})
|
||||
|
||||
it('round-trips markdown with type="I" preserving the type', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const original = 'I. One\nII. Two'
|
||||
|
||||
// Parse
|
||||
const json = markdownManager.parse(original)
|
||||
expect(json.content[0].attrs?.type).toBe('I')
|
||||
|
||||
// Serialize back
|
||||
const md = markdownManager.serialize(json)
|
||||
expect(md).toBe(original)
|
||||
})
|
||||
|
||||
it('does not parse three-letter alpha markers as a list', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const json = markdownManager.parse('abc. Not a list')
|
||||
|
||||
expect(json.content[0].type).not.toBe('orderedList')
|
||||
})
|
||||
|
||||
it('round-trips multi-letter alpha markers beyond 26 items', () => {
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const original = 'aa. Item 27\nab. Item 28'
|
||||
|
||||
const json = markdownManager.parse(original)
|
||||
expect(json.content[0].attrs?.type).toBe('a')
|
||||
expect(json.content[0].attrs?.start).toBe(27)
|
||||
|
||||
const md = markdownManager.serialize(json)
|
||||
expect(md).toBe(original)
|
||||
})
|
||||
|
||||
it('parses inline formatting inside an indented ordered list item', () => {
|
||||
// A single leading space before the marker (e.g. a top-level ordered
|
||||
// list nested one level inside another list) previously made the
|
||||
// custom ordered-list tokenizer bail out silently, falling back to a
|
||||
// path that left the item's content as literal, unparsed text.
|
||||
const markdownManager = new MarkdownManager({
|
||||
extensions: [Document, Paragraph, Text, Bold, ListItem, OrderedList],
|
||||
})
|
||||
|
||||
const noIndent = markdownManager.parse('1. **bold** item')
|
||||
const indented = markdownManager.parse(' 1. **bold** item')
|
||||
|
||||
expect(indented.content).toEqual(noIndent.content)
|
||||
|
||||
const textNode = indented.content[0].content[0].content[0].content[0]
|
||||
expect(textNode.text).toBe('bold')
|
||||
expect(textNode.marks?.[0]?.type).toBe('bold')
|
||||
})
|
||||
})
|
||||
|
||||
it('creates an ordered list from typed JSON content', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { type: 'a' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item A' }] }],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item B' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const json = editor.getJSON()
|
||||
|
||||
expect(json.content).toHaveLength(1)
|
||||
expect(json.content[0].type).toBe('orderedList')
|
||||
expect(json.content[0].attrs?.type).toBe('a')
|
||||
expect(json.content[0].content).toHaveLength(2)
|
||||
expect(json.content[0].content[0].content[0].content[0].text).toBe('Item A')
|
||||
})
|
||||
|
||||
describe('joinPredicate', () => {
|
||||
it('does not merge adjacent orderedLists with different type values when toggling list', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: '<ol type="a"><li><p>A</p></li></ol><p></p>',
|
||||
})
|
||||
|
||||
// Position cursor at the empty paragraph after the typed list
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size)
|
||||
|
||||
// Toggle ordered list on the paragraph - should create NEW list, not merge
|
||||
editor.commands.toggleOrderedList()
|
||||
|
||||
const json = editor.getJSON()
|
||||
|
||||
// Should have two separate orderedList nodes (types differ)
|
||||
expect(json.content).toHaveLength(2)
|
||||
expect(json.content[0].attrs?.type).toBe('a')
|
||||
expect(json.content[1].attrs?.type).toBe(null)
|
||||
})
|
||||
|
||||
it('does merge adjacent orderedLists when both have default type', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: '<ol><li><p>First</p></li></ol><p></p>',
|
||||
})
|
||||
|
||||
// Position cursor at the empty paragraph
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size)
|
||||
|
||||
// Toggle ordered list - should merge with the existing list
|
||||
editor.commands.toggleOrderedList()
|
||||
|
||||
const json = editor.getJSON()
|
||||
|
||||
// Should have one list with both items (merged)
|
||||
expect(json.content).toHaveLength(1)
|
||||
expect(json.content[0].content).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderHTML', () => {
|
||||
it('does not render a type attribute when type is null', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: null },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toBe('<ol><li><p>Item</p></li></ol>')
|
||||
})
|
||||
|
||||
it('does not render a type attribute when type is "1" (default)', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: '1' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toBe('<ol><li><p>Item</p></li></ol>')
|
||||
})
|
||||
|
||||
it('renders type="a" on the ol element', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: 'a' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toBe('<ol type="a"><li><p>Item</p></li></ol>')
|
||||
})
|
||||
|
||||
it('renders type="A" on the ol element', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: 'A' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toBe('<ol type="A"><li><p>Item</p></li></ol>')
|
||||
})
|
||||
|
||||
it('renders type="i" on the ol element', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: 'i' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toBe('<ol type="i"><li><p>Item</p></li></ol>')
|
||||
})
|
||||
|
||||
it('renders type="I" on the ol element', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 1, type: 'I' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toBe('<ol type="I"><li><p>Item</p></li></ol>')
|
||||
})
|
||||
|
||||
it('renders both start and type when both are set', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 5, type: 'i' },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toBe('<ol start="5" type="i"><li><p>Item</p></li></ol>')
|
||||
})
|
||||
|
||||
it('renders start attribute only (not default 1) when type is null', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { start: 3, type: null },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toBe('<ol start="3"><li><p>Item</p></li></ol>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTML paste round-trip', () => {
|
||||
const setAndGetJSON = (html: string): JSONContent => {
|
||||
const editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: html,
|
||||
})
|
||||
const json = editor.getJSON()
|
||||
editor.destroy()
|
||||
return json
|
||||
}
|
||||
|
||||
it('parses type="a" from HTML and preserves it in getJSON', () => {
|
||||
const json = setAndGetJSON('<ol type="a"><li><p>Item A</p></li></ol>')
|
||||
|
||||
expect(json.content).toHaveLength(1)
|
||||
expect(json.content[0].type).toBe('orderedList')
|
||||
expect(json.content[0].attrs?.type).toBe('a')
|
||||
expect(json.content[0].attrs?.start).toBe(1)
|
||||
})
|
||||
|
||||
it('parses type="A" from HTML correctly', () => {
|
||||
const json = setAndGetJSON('<ol type="A"><li><p>Item</p></li></ol>')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('A')
|
||||
})
|
||||
|
||||
it('parses type="i" from HTML correctly', () => {
|
||||
const json = setAndGetJSON('<ol type="i"><li><p>Item</p></li></ol>')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('i')
|
||||
})
|
||||
|
||||
it('parses type="I" from HTML correctly', () => {
|
||||
const json = setAndGetJSON('<ol type="I"><li><p>Item</p></li></ol>')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('I')
|
||||
})
|
||||
|
||||
it('parses type="1" from HTML as null (default) in JSON', () => {
|
||||
const json = setAndGetJSON('<ol type="1"><li><p>Item</p></li></ol>')
|
||||
|
||||
// Note: ProseMirror stores the raw parsed value. With type="1", the
|
||||
// parseHTML returns "1" — which is stored. But it renders as no attribute
|
||||
// because the HTML spec considers "1" as default.
|
||||
expect(json.content[0].attrs?.type).toBe('1')
|
||||
})
|
||||
|
||||
it('parses ol without type attribute as null in JSON', () => {
|
||||
const json = setAndGetJSON('<ol><li><p>Item</p></li></ol>')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBeNull()
|
||||
})
|
||||
|
||||
it('parses both start and type from HTML', () => {
|
||||
const json = setAndGetJSON('<ol type="I" start="5"><li><p>Item</p></li></ol>')
|
||||
|
||||
expect(json.content[0].attrs?.start).toBe(5)
|
||||
expect(json.content[0].attrs?.type).toBe('I')
|
||||
})
|
||||
|
||||
it('preserves type through full HTML round-trip: setContent -> getHTML', () => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, ListItem, OrderedList],
|
||||
content: '<ol type="i"><li><p>Item</p></li></ol>',
|
||||
})
|
||||
|
||||
const html = editor.getHTML()
|
||||
|
||||
expect(html).toBe('<ol type="i"><li><p>Item</p></li></ol>')
|
||||
})
|
||||
|
||||
// ── CSS list-style-type parsing (Google Docs / Word pattern) ──
|
||||
|
||||
it('parses CSS list-style-type: upper-roman on ol element', () => {
|
||||
const json = setAndGetJSON(
|
||||
'<ol style="list-style-type: upper-roman;"><li><p>Item</p></li></ol>',
|
||||
)
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('I')
|
||||
})
|
||||
|
||||
it('parses CSS list-style-type: lower-roman on ol element', () => {
|
||||
const json = setAndGetJSON(
|
||||
'<ol style="list-style-type: lower-roman;"><li><p>Item</p></li></ol>',
|
||||
)
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('i')
|
||||
})
|
||||
|
||||
it('parses CSS list-style-type: upper-alpha on ol element', () => {
|
||||
const json = setAndGetJSON(
|
||||
'<ol style="list-style-type: upper-alpha;"><li><p>Item</p></li></ol>',
|
||||
)
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('A')
|
||||
})
|
||||
|
||||
it('parses CSS list-style-type: lower-alpha on ol element', () => {
|
||||
const json = setAndGetJSON(
|
||||
'<ol style="list-style-type: lower-alpha;"><li><p>Item</p></li></ol>',
|
||||
)
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('a')
|
||||
})
|
||||
|
||||
it('parses CSS list-style-type from li child (Google Docs pattern)', () => {
|
||||
const json = setAndGetJSON(
|
||||
'<ol><li style="list-style-type: upper-roman;"><p>Item</p></li></ol>',
|
||||
)
|
||||
|
||||
expect(json.content[0].attrs?.type).toBe('I')
|
||||
})
|
||||
|
||||
it('parses CSS list-style-type: decimal as default (no type)', () => {
|
||||
const json = setAndGetJSON('<ol style="list-style-type: decimal;"><li><p>Item</p></li></ol>')
|
||||
|
||||
expect(json.content[0].attrs?.type).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user