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

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View 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')
})
})

View 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')
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})
})