diff --git a/.changeset/2026-06-01-ordered-list-type-attribute.md b/.changeset/2026-06-01-ordered-list-type-attribute.md
new file mode 100644
index 0000000000..e4ff0aa95a
--- /dev/null
+++ b/.changeset/2026-06-01-ordered-list-type-attribute.md
@@ -0,0 +1,21 @@
+---
+'@tiptap/extension-list': minor
+'@tiptap/core': patch
+---
+
+**Ordered lists now support the `type` attribute** (`a`, `A`, `i`, `I`).
+
+The `
` `type` attribute is now fully preserved through the HTML round-trip:
+
+- `type="a"` → lowercase alphabetical markers
+- `type="A"` → uppercase alphabetical markers
+- `type="i"` → lowercase roman numeral markers
+- `type="I"` → uppercase roman numeral markers
+
+**Paste from external editors** (Google Docs, Word, LibreOffice) now correctly detects the list style — both from the HTML `type` attribute and from CSS `list-style-type` properties.
+
+**Plain text paste** of typed ordered list markers (e.g. `a. Item`, `I) Item`, `i. Item\nii. Item`) is detected and converted to the correct list type.
+
+**Markdown round-trip** preserves typed markers: parsing `a. Item` creates `type: "a"`, and serializing a typed list back to markdown uses the correct prefix (e.g. `I.`, `ii.`).
+
+**Joining** of adjacent lists now respects `type` — two lists with different types (e.g. default numeric and `type="a"`) are not merged.
diff --git a/demos/src/Nodes/OrderedList/React/index.jsx b/demos/src/Nodes/OrderedList/React/index.jsx
index fd7ac6751d..5d746e87ca 100644
--- a/demos/src/Nodes/OrderedList/React/index.jsx
+++ b/demos/src/Nodes/OrderedList/React/index.jsx
@@ -20,6 +20,16 @@ export default () => {
- This item starts at 5
- And another one
+
+
+ - Lowercase alphabetical list
+ - Second item
+
+
+
+ - Uppercase roman numerals
+ - Second item
+
`,
})
diff --git a/demos/src/Nodes/OrderedList/Vue/index.vue b/demos/src/Nodes/OrderedList/Vue/index.vue
index 9a872006c1..34bede8641 100644
--- a/demos/src/Nodes/OrderedList/Vue/index.vue
+++ b/demos/src/Nodes/OrderedList/Vue/index.vue
@@ -63,6 +63,16 @@ export default {
This item starts at 5
And another one
+
+
+ - Lowercase alphabetical list
+ - Second item
+
+
+
+ - Uppercase roman numerals
+ - Second item
+
`,
})
},
diff --git a/demos/src/Nodes/OrderedList/index.spec.ts b/demos/src/Nodes/OrderedList/index.spec.ts
index f8b1a00e43..de494d2f10 100644
--- a/demos/src/Nodes/OrderedList/index.spec.ts
+++ b/demos/src/Nodes/OrderedList/index.spec.ts
@@ -82,6 +82,37 @@ test.describe(`${demoPath}/${demoName}`, () => {
await editor.type('Example')
await expect(page.locator('.tiptap p')).toContainText('1. Example')
})
+
+ // ── Type attribute tests ──
+
+ test('preserves type="a" attribute in HTML output', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent(
+ 'Item A
Item B
',
+ )
+ return el.editor.getHTML()
+ })
+ expect(html).toContain('type="a"')
+ })
+
+ test('preserves type="I" attribute in HTML output', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Item 1
')
+ return el.editor.getHTML()
+ })
+ expect(html).toContain('type="I"')
+ })
+
+ test('does not render type attribute for default type', async ({ page }) => {
+ const editor = await getEditor(page)
+ const html = await editor.evaluate((el: any) => {
+ el.editor.commands.setContent('Item
')
+ return el.editor.getHTML()
+ })
+ expect(html).not.toContain('type')
+ })
})
})
})
diff --git a/packages/core/src/commands/toggleList.ts b/packages/core/src/commands/toggleList.ts
index de4cc02146..d040d0c266 100644
--- a/packages/core/src/commands/toggleList.ts
+++ b/packages/core/src/commands/toggleList.ts
@@ -8,6 +8,25 @@ import { getNodeType } from '../helpers/getNodeType.js'
import { isList } from '../helpers/isList.js'
import type { RawCommands } from '../types.js'
+/**
+ * Normalise a list type attribute for comparison.
+ * Treats null, undefined, and "1" as equivalent (the default numeric type).
+ */
+function normalizeListType(type: string | null | undefined): string | null {
+ return !type || type === '1' ? null : type
+}
+
+/**
+ * Check if two list type attributes are compatible for joining.
+ * Lists can only join when they have the same type (both default, or both the same non-default type).
+ */
+function areListTypesCompatible(
+ typeA: string | null | undefined,
+ typeB: string | null | undefined,
+): boolean {
+ return normalizeListType(typeA) === normalizeListType(typeB)
+}
+
const joinListBackwards = (tr: Transaction, listType: NodeType): boolean => {
const list = findParentNode(node => node.type === listType)(tr.selection)
@@ -28,6 +47,12 @@ const joinListBackwards = (tr: Transaction, listType: NodeType): boolean => {
return true
}
+ // Don't join if the type attributes are incompatible
+ // (e.g. a default-type list should not merge with a type="a" list)
+ if (!areListTypesCompatible(list.node.attrs.type, nodeBefore?.attrs.type)) {
+ return true
+ }
+
tr.join(list.pos)
return true
@@ -53,6 +78,11 @@ const joinListForwards = (tr: Transaction, listType: NodeType): boolean => {
return true
}
+ // Don't join if the type attributes are incompatible
+ if (!areListTypesCompatible(list.node.attrs.type, nodeAfter?.attrs.type)) {
+ return true
+ }
+
tr.join(after)
return true
diff --git a/packages/extension-list/__tests__/orderedListType.spec.ts b/packages/extension-list/__tests__/orderedListType.spec.ts
new file mode 100644
index 0000000000..ddf0c22fef
--- /dev/null
+++ b/packages/extension-list/__tests__/orderedListType.spec.ts
@@ -0,0 +1,877 @@
+import type { JSONContent } from '@tiptap/core'
+import { Editor } from '@tiptap/core'
+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 'vitest'
+
+import {
+ areOrderedListMarkersSequential,
+ detectMarkerType,
+ getListMarker,
+ ListItem,
+ markerToStart,
+ OrderedList,
+ parsePlainTextOrderedListPaste,
+} from '../src/index.js'
+
+describe('OrderedList type attribute', () => {
+ let editor: Editor
+
+ afterEach(() => {
+ editor?.destroy()
+ })
+
+ // ───────── Phase 4: Markdown utilities ─────────
+
+ describe('getListMarker', () => {
+ it('returns numeric markers for default type', () => {
+ expect(getListMarker(null, 0, '. ')).toBe('1. ')
+ expect(getListMarker(undefined, 4, '. ')).toBe('5. ')
+ expect(getListMarker('1', 0, '. ')).toBe('1. ')
+ })
+
+ it('returns lowercase alpha markers for type "a"', () => {
+ expect(getListMarker('a', 0, '. ')).toBe('a. ')
+ expect(getListMarker('a', 1, '. ')).toBe('b. ')
+ expect(getListMarker('a', 25, '. ')).toBe('z. ')
+ })
+
+ it('returns uppercase alpha markers for type "A"', () => {
+ expect(getListMarker('A', 0, '. ')).toBe('A. ')
+ expect(getListMarker('A', 1, '. ')).toBe('B. ')
+ expect(getListMarker('A', 25, '. ')).toBe('Z. ')
+ })
+
+ it('returns lowercase roman markers for type "i"', () => {
+ expect(getListMarker('i', 0, '. ')).toBe('i. ')
+ expect(getListMarker('i', 1, '. ')).toBe('ii. ')
+ expect(getListMarker('i', 3, '. ')).toBe('iv. ')
+ expect(getListMarker('i', 9, '. ')).toBe('x. ')
+ })
+
+ it('returns uppercase roman markers for type "I"', () => {
+ expect(getListMarker('I', 0, '. ')).toBe('I. ')
+ expect(getListMarker('I', 1, '. ')).toBe('II. ')
+ expect(getListMarker('I', 3, '. ')).toBe('IV. ')
+ expect(getListMarker('I', 9, '. ')).toBe('X. ')
+ })
+ })
+
+ describe('detectMarkerType', () => {
+ it('detects default type for numeric markers', () => {
+ expect(detectMarkerType('1')).toBeUndefined()
+ expect(detectMarkerType('42')).toBeUndefined()
+ })
+
+ it('detects lowercase alpha', () => {
+ expect(detectMarkerType('a')).toBe('a')
+ expect(detectMarkerType('b')).toBe('a')
+ expect(detectMarkerType('z')).toBe('a')
+ expect(detectMarkerType('A')).toBe('A')
+ expect(detectMarkerType('B')).toBe('A')
+ })
+
+ it('does not treat invalid roman strings as roman', () => {
+ expect(detectMarkerType('aa')).toBe('a')
+ })
+
+ it('does not treat alpha markers longer than 2 letters as alpha', () => {
+ expect(detectMarkerType('abc')).toBeUndefined()
+ expect(detectMarkerType('ABC')).toBeUndefined()
+ })
+
+ it('detects lowercase roman', () => {
+ expect(detectMarkerType('i')).toBe('i')
+ expect(detectMarkerType('ii')).toBe('i')
+ expect(detectMarkerType('iii')).toBe('i')
+ expect(detectMarkerType('iv')).toBe('i')
+ expect(detectMarkerType('v')).toBe('i')
+ })
+
+ it('detects uppercase roman', () => {
+ expect(detectMarkerType('I')).toBe('I')
+ expect(detectMarkerType('VI')).toBe('I')
+ expect(detectMarkerType('X')).toBe('I')
+ })
+ })
+
+ describe('areOrderedListMarkersSequential', () => {
+ it('accepts sequential markers of the same style', () => {
+ expect(areOrderedListMarkersSequential(['a', 'b', 'c'])).toBe(true)
+ expect(areOrderedListMarkersSequential(['1', '2', '3'])).toBe(true)
+ expect(areOrderedListMarkersSequential(['ii', 'iii', 'iv'])).toBe(true)
+ expect(areOrderedListMarkersSequential(['b', 'c'])).toBe(true)
+ })
+
+ it('rejects mixed styles', () => {
+ expect(areOrderedListMarkersSequential(['a', '1'])).toBe(false)
+ expect(areOrderedListMarkersSequential(['i', 'ii', 'a'])).toBe(false)
+ })
+
+ it('rejects non-sequential markers', () => {
+ expect(areOrderedListMarkersSequential(['a', 'c'])).toBe(false)
+ expect(areOrderedListMarkersSequential(['1', '3'])).toBe(false)
+ expect(areOrderedListMarkersSequential(['II', 'IV'])).toBe(false)
+ })
+ })
+
+ describe('markerToStart', () => {
+ it('parses numeric markers', () => {
+ expect(markerToStart('3')).toBe(3)
+ expect(markerToStart('42')).toBe(42)
+ })
+
+ it('parses alpha markers', () => {
+ expect(markerToStart('a')).toBe(1)
+ expect(markerToStart('b')).toBe(2)
+ expect(markerToStart('aa')).toBe(27)
+ })
+
+ it('parses roman markers', () => {
+ expect(markerToStart('i')).toBe(1)
+ expect(markerToStart('ii')).toBe(2)
+ expect(markerToStart('II')).toBe(2)
+ expect(markerToStart('IV')).toBe(4)
+ })
+ })
+
+ // ───────── Phase 4b: Markdown round-trip ─────────
+
+ 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('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)
+ })
+ })
+
+ // ───────── Phase 5: Plain-text paste detection ─────────
+
+ describe('plain-text paste detection', () => {
+ it('detects single-line lowercase alpha paste', () => {
+ const result = parsePlainTextOrderedListPaste('a. Item 1')
+
+ expect(result).not.toBeNull()
+ expect(result!.attrs?.type).toBe('a')
+ expect(result!.attrs?.start).toBeUndefined()
+ expect(result!.content).toHaveLength(1)
+ expect(result!.content![0].content![0].content![0].text).toBe('Item 1')
+ })
+
+ it('detects multi-line lowercase alpha paste', () => {
+ const text = 'a. Item 1\nb. Item 2'
+ const result = parsePlainTextOrderedListPaste(text)
+
+ expect(result).not.toBeNull()
+ expect(result!.attrs?.type).toBe('a')
+ expect(result!.content).toHaveLength(2)
+ expect(result!.content![0].content![0].content![0].text).toBe('Item 1')
+ expect(result!.content![1].content![0].content![0].text).toBe('Item 2')
+ })
+
+ it('sets start when pasting alpha list beginning at b', () => {
+ const result = parsePlainTextOrderedListPaste('b. Item 1\nc. Item 2')
+
+ expect(result!.attrs?.type).toBe('a')
+ expect(result!.attrs?.start).toBe(2)
+ })
+
+ it('sets start when pasting numeric list beginning at 3', () => {
+ const result = parsePlainTextOrderedListPaste('3. Item 1\n4. Item 2')
+
+ expect(result!.attrs?.type).toBeUndefined()
+ expect(result!.attrs?.start).toBe(3)
+ })
+
+ it('sets type and start when pasting roman list beginning at II', () => {
+ const result = parsePlainTextOrderedListPaste('II. Item 1\nIII. Item 2')
+
+ expect(result!.attrs?.type).toBe('I')
+ expect(result!.attrs?.start).toBe(2)
+ })
+
+ it('detects alpha paste with paren separator', () => {
+ const result = parsePlainTextOrderedListPaste('a) Item 1\nb) Item 2')
+
+ expect(result).not.toBeNull()
+ expect(result!.content).toHaveLength(2)
+ })
+
+ it('detects roman numeral paste with dot separator', () => {
+ const result = parsePlainTextOrderedListPaste('i. Item 1\nii. Item 2')
+
+ expect(result).not.toBeNull()
+ expect(result!.content).toHaveLength(2)
+ })
+
+ it('detects roman numeral paste with paren separator', () => {
+ const result = parsePlainTextOrderedListPaste('I) Item 1\nII) Item 2')
+
+ expect(result).not.toBeNull()
+ expect(result!.content).toHaveLength(2)
+ })
+
+ it('detects numeric paste with dot separator', () => {
+ const result = parsePlainTextOrderedListPaste('1. Item 1\n2. Item 2')
+
+ expect(result).not.toBeNull()
+ expect(result!.content).toHaveLength(2)
+ })
+
+ it('does not match plain text without list markers', () => {
+ const result = parsePlainTextOrderedListPaste('Just some text\nAnd more text')
+
+ expect(result).toBeNull()
+ })
+
+ it('does not match mixed content (some lines have markers, some do not)', () => {
+ const result = parsePlainTextOrderedListPaste('a. Item 1\nThis is not a list item')
+
+ expect(result).toBeNull()
+ })
+
+ it('does not match short patterns without content after marker', () => {
+ const result = parsePlainTextOrderedListPaste('a. ')
+
+ expect(result).toBeNull()
+ })
+
+ it('does not match three-letter alpha markers', () => {
+ const result = parsePlainTextOrderedListPaste('abc. Something')
+
+ expect(result).toBeNull()
+ })
+
+ it('does not match non-sequential markers', () => {
+ const result = parsePlainTextOrderedListPaste('a. Item 1\nc. Item 3')
+
+ expect(result).toBeNull()
+ })
+
+ it('creates an ordered list with type from JSON content (simulating paste handler)', () => {
+ 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')
+ })
+ })
+
+ // ───────── Phase 2: joinPredicate (in toggleList.ts) ─────────
+
+ 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: 'A
',
+ })
+
+ // 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: 'First
',
+ })
+
+ // 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)
+ })
+ })
+
+ // ───────── Phase 1: renderHTML ─────────
+
+ 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('Item
')
+ })
+
+ 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('Item
')
+ })
+
+ 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('Item
')
+ })
+
+ 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('Item
')
+ })
+
+ 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('Item
')
+ })
+
+ 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('Item
')
+ })
+
+ 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('Item
')
+ })
+
+ 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('Item
')
+ })
+ })
+
+ // ───────── Phase 3: HTML paste round-trip ─────────
+
+ 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('Item A
')
+
+ 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('Item
')
+
+ expect(json.content[0].attrs?.type).toBe('A')
+ })
+
+ it('parses type="i" from HTML correctly', () => {
+ const json = setAndGetJSON('Item
')
+
+ expect(json.content[0].attrs?.type).toBe('i')
+ })
+
+ it('parses type="I" from HTML correctly', () => {
+ const json = setAndGetJSON('Item
')
+
+ expect(json.content[0].attrs?.type).toBe('I')
+ })
+
+ it('parses type="1" from HTML as null (default) in JSON', () => {
+ const json = setAndGetJSON('Item
')
+
+ // 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('Item
')
+
+ expect(json.content[0].attrs?.type).toBeNull()
+ })
+
+ it('parses both start and type from HTML', () => {
+ const json = setAndGetJSON('Item
')
+
+ 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: 'Item
',
+ })
+
+ const html = editor.getHTML()
+
+ expect(html).toBe('Item
')
+ })
+
+ // ── CSS list-style-type parsing (Google Docs / Word pattern) ──
+
+ it('parses CSS list-style-type: upper-roman on ol element', () => {
+ const json = setAndGetJSON(
+ 'Item
',
+ )
+
+ expect(json.content[0].attrs?.type).toBe('I')
+ })
+
+ it('parses CSS list-style-type: lower-roman on ol element', () => {
+ const json = setAndGetJSON(
+ 'Item
',
+ )
+
+ expect(json.content[0].attrs?.type).toBe('i')
+ })
+
+ it('parses CSS list-style-type: upper-alpha on ol element', () => {
+ const json = setAndGetJSON(
+ 'Item
',
+ )
+
+ expect(json.content[0].attrs?.type).toBe('A')
+ })
+
+ it('parses CSS list-style-type: lower-alpha on ol element', () => {
+ const json = setAndGetJSON(
+ 'Item
',
+ )
+
+ expect(json.content[0].attrs?.type).toBe('a')
+ })
+
+ it('parses CSS list-style-type from li child (Google Docs pattern)', () => {
+ const json = setAndGetJSON(
+ 'Item
',
+ )
+
+ expect(json.content[0].attrs?.type).toBe('I')
+ })
+
+ it('parses CSS list-style-type: decimal as default (no type)', () => {
+ const json = setAndGetJSON('Item
')
+
+ expect(json.content[0].attrs?.type).toBeNull()
+ })
+ })
+})
diff --git a/packages/extension-list/src/item/list-item.ts b/packages/extension-list/src/item/list-item.ts
index 2b7a10d598..e1227a0526 100644
--- a/packages/extension-list/src/item/list-item.ts
+++ b/packages/extension-list/src/item/list-item.ts
@@ -3,6 +3,8 @@ import { mergeAttributes, Node, renderNestedMarkdownContent } from '@tiptap/core
import { createBranchingListDeleteKeymap } from '../helpers/createBranchingListDeleteKeymap.js'
+import { getListMarker } from '../ordered-list/roman.js'
+
export interface ListItemOptions {
/**
* The HTML attributes for a list item node.
@@ -173,7 +175,9 @@ export const ListItem = Node.create({
}
if (context.parentType === 'orderedList') {
const start = context.meta?.parentAttrs?.start || 1
- return `${start + context.index}. `
+ const type = context.meta?.parentAttrs?.type as string | undefined
+ const index = start - 1 + (context.index || 0)
+ return getListMarker(type, index, '. ')
}
// Fallback to bullet list for unknown parent types
return '- '
diff --git a/packages/extension-list/src/ordered-list/index.ts b/packages/extension-list/src/ordered-list/index.ts
index 516072696c..b1b36b147d 100644
--- a/packages/extension-list/src/ordered-list/index.ts
+++ b/packages/extension-list/src/ordered-list/index.ts
@@ -1 +1,13 @@
export * from './ordered-list.js'
+export {
+ areOrderedListMarkersSequential,
+ buildOrderedListAttrsFromMarker,
+ detectMarkerType,
+ getListMarker,
+ markerToStart,
+ ORDERED_LIST_MARKER_PATTERN,
+ parseListMarker,
+ toRoman,
+ toRomanUpper,
+} from './roman.js'
+export { parsePlainTextOrderedListPaste } from './utils.js'
diff --git a/packages/extension-list/src/ordered-list/ordered-list.ts b/packages/extension-list/src/ordered-list/ordered-list.ts
index 239b64817a..e4f891d72a 100644
--- a/packages/extension-list/src/ordered-list/ordered-list.ts
+++ b/packages/extension-list/src/ordered-list/ordered-list.ts
@@ -1,6 +1,15 @@
+import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
+import { Plugin } from '@tiptap/pm/state'
+
import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core'
-import { buildNestedStructure, collectOrderedListItems, parseListItems } from './utils.js'
+import {
+ buildNestedStructure,
+ collectOrderedListItems,
+ ORDERED_LIST_LINE_START_REGEX,
+ parseListItems,
+ parsePlainTextOrderedListPaste,
+} from './utils.js'
const ListItemName = 'listItem'
const TextStyleName = 'textStyle'
@@ -52,6 +61,34 @@ declare module '@tiptap/core' {
*/
export const orderedListInputRegex = /^(\d+)\.\s$/
+/**
+ * Maps CSS list-style-type values to HTML type attribute values.
+ * Google Docs and Word often use CSS instead of the HTML type attribute.
+ */
+function cssListStyleTypeToHtmlType(style: string): string | null {
+ const match = style.match(/list-style-type\s*:\s*([^;]+)/i)
+ if (!match) {
+ return null
+ }
+
+ const cssValue = match[1].trim().toLowerCase()
+
+ switch (cssValue) {
+ case 'upper-roman':
+ return 'I'
+ case 'lower-roman':
+ return 'i'
+ case 'upper-alpha':
+ case 'upper-latin':
+ return 'A'
+ case 'lower-alpha':
+ case 'lower-latin':
+ return 'a'
+ default:
+ return null
+ }
+}
+
/**
* This extension allows you to create ordered lists.
* This requires the ListItem extension
@@ -88,7 +125,36 @@ export const OrderedList = Node.create({
},
type: {
default: null,
- parseHTML: element => element.getAttribute('type'),
+ parseHTML: element => {
+ // 1. Check the HTML type attribute on
+ const htmlType = element.getAttribute('type')
+ if (htmlType) {
+ return htmlType
+ }
+
+ // 2. Check CSS list-style-type on the element's style attribute
+ const style = element.getAttribute('style')
+ if (style) {
+ const mappedFromOl = cssListStyleTypeToHtmlType(style)
+ if (mappedFromOl) {
+ return mappedFromOl
+ }
+ }
+
+ // 3. Check the first - child for list-style-type (Google Docs pattern)
+ const firstLi = element.querySelector('li')
+ if (firstLi) {
+ const liStyle = firstLi.getAttribute('style')
+ if (liStyle) {
+ const mappedFromLi = cssListStyleTypeToHtmlType(liStyle)
+ if (mappedFromLi) {
+ return mappedFromLi
+ }
+ }
+ }
+
+ return null
+ },
},
}
},
@@ -102,11 +168,19 @@ export const OrderedList = Node.create({
},
renderHTML({ HTMLAttributes }) {
- const { start, ...attributesWithoutStart } = HTMLAttributes
+ const { start, type, ...attributesWithoutType } = HTMLAttributes
+
+ const attrs = mergeAttributes(this.options.HTMLAttributes, attributesWithoutType)
+
+ if (start !== 1) {
+ attrs.start = start
+ }
+
+ if (type && type !== '1') {
+ attrs.type = type
+ }
- return start === 1
- ? ['ol', mergeAttributes(this.options.HTMLAttributes, attributesWithoutStart), 0]
- : ['ol', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]
+ return ['ol', attrs, 0]
},
markdownTokenName: 'list',
@@ -117,12 +191,24 @@ export const OrderedList = Node.create({
}
const startValue = token.start || 1
+ const typeValue = token.typeMarker as string | undefined
const content = token.items ? parseListItems(token.items, helpers) : []
+ // Build attrs only when they differ from defaults
+ const attrs: Record = {}
+
if (startValue !== 1) {
+ attrs.start = startValue
+ }
+
+ if (typeValue) {
+ attrs.type = typeValue
+ }
+
+ if (Object.keys(attrs).length > 0) {
return {
type: 'orderedList',
- attrs: { start: startValue },
+ attrs,
content,
}
}
@@ -145,7 +231,7 @@ export const OrderedList = Node.create({
name: 'orderedList',
level: 'block',
start: (src: string) => {
- const match = src.match(/^(\s*)(\d+)\.\s+/)
+ const match = src.match(ORDERED_LIST_LINE_START_REGEX)
const index = match?.index
return index !== undefined ? index : -1
},
@@ -164,11 +250,13 @@ export const OrderedList = Node.create({
}
const startValue = listItems[0]?.number || 1
+ const typeMarker = listItems[0]?.type
return {
type: 'list',
ordered: true,
start: startValue,
+ typeMarker,
items,
raw: lines.slice(0, consumed).join('\n'),
} as unknown as object
@@ -201,12 +289,59 @@ export const OrderedList = Node.create({
}
},
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ props: {
+ handlePaste: (view, event) => {
+ const html = event.clipboardData?.getData('text/html')
+
+ if (html?.trim()) {
+ return false
+ }
+
+ const text = event.clipboardData?.getData('text/plain')
+
+ if (!text) {
+ return false
+ }
+
+ const orderedListContent = parsePlainTextOrderedListPaste(text)
+
+ if (!orderedListContent) {
+ return false
+ }
+
+ try {
+ const orderedListNode = view.state.schema.nodeFromJSON(orderedListContent)
+ const tr = view.state.tr.replaceSelectionWith(orderedListNode)
+
+ view.dispatch(tr)
+
+ return true
+ } catch {
+ return false
+ }
+ },
+ },
+ }),
+ ]
+ },
+
addInputRules() {
+ const joinPredicate = (match: RegExpMatchArray, node: ProseMirrorNode) => {
+ // Only join if the existing list has a default type
+ // (not a typed list like "a" or "i" which should stay separate)
+ const hasDefaultType = !node.attrs.type || node.attrs.type === '1'
+
+ return hasDefaultType && node.childCount + node.attrs.start === +match[1]
+ }
+
let inputRule = wrappingInputRule({
find: orderedListInputRegex,
type: this.type,
getAttributes: match => ({ start: +match[1] }),
- joinPredicate: (match, node) => node.childCount + node.attrs.start === +match[1],
+ joinPredicate,
})
if (this.options.keepMarks || this.options.keepAttributes) {
@@ -216,7 +351,7 @@ export const OrderedList = Node.create({
keepMarks: this.options.keepMarks,
keepAttributes: this.options.keepAttributes,
getAttributes: match => ({ start: +match[1], ...this.editor.getAttributes(TextStyleName) }),
- joinPredicate: (match, node) => node.childCount + node.attrs.start === +match[1],
+ joinPredicate,
editor: this.editor,
})
}
diff --git a/packages/extension-list/src/ordered-list/roman.ts b/packages/extension-list/src/ordered-list/roman.ts
new file mode 100644
index 0000000000..5c01414b31
--- /dev/null
+++ b/packages/extension-list/src/ordered-list/roman.ts
@@ -0,0 +1,295 @@
+const ROMAN_NUMERALS: [number, string][] = [
+ [1000, 'm'],
+ [900, 'cm'],
+ [500, 'd'],
+ [400, 'cd'],
+ [100, 'c'],
+ [90, 'xc'],
+ [50, 'l'],
+ [40, 'xl'],
+ [10, 'x'],
+ [9, 'ix'],
+ [5, 'v'],
+ [4, 'iv'],
+ [1, 'i'],
+]
+
+const ALPHA_NUMERALS = 'abcdefghijklmnopqrstuvwxyz'
+
+/** Alpha list markers support at most 2 letters (a–z, aa–zz), matching {@link fromAlpha}. */
+export const ORDERED_LIST_ALPHA_MARKER_PATTERN = '[a-zA-Z]{1,2}'
+
+/**
+ * Marker segment for ordered list lines: numeric, roman, or 1–2 letter alpha.
+ * Roman is matched before alpha so "iii" is roman; invalid romans like "aa" fall through to alpha.
+ */
+export const ORDERED_LIST_MARKER_PATTERN = String.raw`\d+|[ivxlcdmIVXLCDM]+|${ORDERED_LIST_ALPHA_MARKER_PATTERN}`
+
+/**
+ * Convert a number to lowercase roman numerals.
+ * @example toRoman(1) // 'i'
+ * @example toRoman(4) // 'iv'
+ */
+export function toRoman(num: number): string {
+ let remaining = num
+ let result = ''
+
+ for (const [value, numeral] of ROMAN_NUMERALS) {
+ while (remaining >= value) {
+ result += numeral
+ remaining -= value
+ }
+ }
+
+ return result
+}
+
+/**
+ * Convert a number to uppercase roman numerals.
+ * @example toRomanUpper(1) // 'I'
+ * @example toRomanUpper(4) // 'IV'
+ */
+export function toRomanUpper(num: number): string {
+ return toRoman(num).toUpperCase()
+}
+
+function fromRoman(roman: string): number {
+ const lower = roman.toLowerCase()
+ let index = 0
+ let result = 0
+
+ while (index < lower.length) {
+ let matched = false
+
+ for (const [value, numeral] of ROMAN_NUMERALS) {
+ if (lower.startsWith(numeral, index)) {
+ result += value
+ index += numeral.length
+ matched = true
+ break
+ }
+ }
+
+ if (!matched) {
+ return 0
+ }
+ }
+
+ return result
+}
+
+function isValidRoman(marker: string): boolean {
+ if (!/^[ivxlcdmIVXLCDM]+$/.test(marker)) {
+ return false
+ }
+
+ const value = fromRoman(marker)
+
+ if (value <= 0) {
+ return false
+ }
+
+ const expected = marker === marker.toLowerCase() ? toRoman(value) : toRomanUpper(value)
+
+ return expected === marker
+}
+
+function fromAlpha(marker: string): number {
+ const lower = marker.toLowerCase()
+
+ if (lower.length === 1) {
+ return lower.charCodeAt(0) - 'a'.charCodeAt(0) + 1
+ }
+
+ if (lower.length === 2) {
+ const first = lower.charCodeAt(0) - 'a'.charCodeAt(0)
+ const second = lower.charCodeAt(1) - 'a'.charCodeAt(0)
+
+ return (first + 1) * 26 + second + 1
+ }
+
+ return 0
+}
+
+function toRomanAlpha(num: number): string {
+ if (num <= 26) {
+ return ALPHA_NUMERALS[num - 1]
+ }
+
+ const first = Math.floor((num - 1) / 26) - 1
+ const second = (num - 1) % 26
+
+ if (first < 0) {
+ return ALPHA_NUMERALS[second]
+ }
+
+ return ALPHA_NUMERALS[first] + ALPHA_NUMERALS[second]
+}
+
+/**
+ * Extract the list marker type from a marker string.
+ * Supports "1", "a", "A", "i", "I" marker styles.
+ *
+ * @param marker The text content of the list marker (e.g. "a", "1", "iii", "b")
+ * @returns The normalized type string, or undefined for default numeric type
+ */
+export function detectMarkerType(marker: string): string | undefined {
+ if (!marker || /^\d+$/.test(marker)) {
+ return undefined
+ }
+
+ if (isValidRoman(marker)) {
+ return marker === marker.toLowerCase() ? 'i' : 'I'
+ }
+
+ if (/^[a-z]{1,2}$/.test(marker)) {
+ return 'a'
+ }
+
+ if (/^[A-Z]{1,2}$/.test(marker)) {
+ return 'A'
+ }
+
+ return undefined
+}
+
+/**
+ * Convert a list marker string to its numeric start position.
+ *
+ * @param marker The text content of the list marker (e.g. "3", "b", "II")
+ * @returns The 1-based start value for the ordered list
+ */
+export function markerToStart(marker: string): number {
+ if (/^\d+$/.test(marker)) {
+ return parseInt(marker, 10)
+ }
+
+ const type = detectMarkerType(marker)
+
+ if (type === 'i' || type === 'I') {
+ return fromRoman(marker)
+ }
+
+ if (type === 'a' || type === 'A') {
+ const start = fromAlpha(marker)
+
+ return start > 0 ? start : 1
+ }
+
+ const parsed = parseInt(marker, 10)
+
+ return Number.isNaN(parsed) ? 1 : parsed
+}
+
+function startToMarker(type: string, start: number): string {
+ if (type === 'numeric') {
+ return String(start)
+ }
+
+ switch (type) {
+ case 'a':
+ return toRomanAlpha(start)
+ case 'A':
+ return toRomanAlpha(start).toUpperCase()
+ case 'i':
+ return toRoman(start)
+ case 'I':
+ return toRomanUpper(start)
+ default:
+ return String(start)
+ }
+}
+
+/**
+ * Returns true when all markers share the same style and increment by 1.
+ * Style is inferred from the first marker so ambiguous letters (e.g. "c", "i")
+ * are not re-classified differently on later lines.
+ */
+export function areOrderedListMarkersSequential(markers: string[]): boolean {
+ if (markers.length === 0) {
+ return false
+ }
+
+ const firstType = detectMarkerType(markers[0]) ?? 'numeric'
+ const firstStart = markerToStart(markers[0])
+
+ if (firstStart < 1) {
+ return false
+ }
+
+ for (let i = 0; i < markers.length; i++) {
+ const expected = startToMarker(firstType, firstStart + i)
+
+ if (markers[i] !== expected) {
+ return false
+ }
+ }
+
+ return true
+}
+
+export interface ParsedListMarker {
+ type?: string
+ start: number
+}
+
+/**
+ * Parse a list marker into HTML ordered-list attrs (type + start).
+ */
+export function parseListMarker(marker: string): ParsedListMarker {
+ return {
+ type: detectMarkerType(marker),
+ start: markerToStart(marker),
+ }
+}
+
+/**
+ * Build orderedList node attrs from the first list item marker.
+ */
+export function buildOrderedListAttrsFromMarker(marker: string): Record {
+ const { type, start } = parseListMarker(marker)
+ const attrs: Record = {}
+
+ if (type) {
+ attrs.type = type
+ }
+
+ if (start !== 1) {
+ attrs.start = start
+ }
+
+ return attrs
+}
+
+/**
+ * Returns the list marker prefix for a given item at a given index.
+ *
+ * @param type The list type attribute (e.g. "a", "A", "i", "I", null/undefined for default)
+ * @param index The zero-based index of the list item
+ * @param separator The separator to use (default: ". ")
+ * @returns The marker string (e.g. "a. ", "I. ", "1. ")
+ */
+export function getListMarker(
+ type: string | null | undefined,
+ index: number,
+ separator = '. ',
+): string {
+ const position = index + 1
+
+ if (!type || type === '1') {
+ return `${position}${separator}`
+ }
+
+ switch (type) {
+ case 'a':
+ return `${toRomanAlpha(position)}${separator}`
+ case 'A':
+ return `${toRomanAlpha(position).toUpperCase()}${separator}`
+ case 'i':
+ return `${toRoman(position)}${separator}`
+ case 'I':
+ return `${toRomanUpper(position)}${separator}`
+ default:
+ return `${position}${separator}`
+ }
+}
diff --git a/packages/extension-list/src/ordered-list/utils.ts b/packages/extension-list/src/ordered-list/utils.ts
index e50cddde95..bb5374ac61 100644
--- a/packages/extension-list/src/ordered-list/utils.ts
+++ b/packages/extension-list/src/ordered-list/utils.ts
@@ -5,12 +5,33 @@ import type {
MarkdownToken,
} from '@tiptap/core'
+import {
+ areOrderedListMarkersSequential,
+ buildOrderedListAttrsFromMarker,
+ detectMarkerType,
+ markerToStart,
+ ORDERED_LIST_MARKER_PATTERN,
+} from './roman.js'
+
+export { ORDERED_LIST_MARKER_PATTERN }
+
/**
* Matches an ordered list item line with optional leading whitespace.
- * Captures: (1) indentation spaces, (2) item number, (3) content after marker
- * Example matches: "1. Item", " 2. Nested item", " 3. Deeply nested"
+ * Captures: (1) indentation spaces, (2) item marker (number, letter, or roman numeral),
+ * (3) separator (. or )), (4) content after marker
+ *
+ * Examples: "1. Item", " a) Nested item", " I. Roman item", "iii. Another", "aa. Item 27"
*/
-const ORDERED_LIST_ITEM_REGEX = /^(\s*)(\d+)\.\s+(.*)$/
+export const ORDERED_LIST_ITEM_REGEX = new RegExp(
+ `^(\\s*)(${ORDERED_LIST_MARKER_PATTERN})([.)])\\s+(.*)$`,
+)
+
+/**
+ * Matches the start of an ordered list line (used by markdown tokenizer).
+ */
+export const ORDERED_LIST_LINE_START_REGEX = new RegExp(
+ `^(\\s*)(${ORDERED_LIST_MARKER_PATTERN})([.)])\\s+`,
+)
/**
* Matches any line that starts with whitespace (indented content).
@@ -24,19 +45,23 @@ const INDENTED_LINE_REGEX = /^\s/
export interface OrderedListItem {
indent: number
number: number
+ type?: string
content: string
contentLines: string[]
raw: string
}
+function isOrderedListMarkerLine(line: string): boolean {
+ return ORDERED_LIST_ITEM_REGEX.test(line.trimStart())
+}
+
function isBlockContentLine(line: string): boolean {
const trimmedLine = line.trimStart()
return (
// oxlint-disable-next-line prefer-string-starts-ends-with
/^[-+*]\s+/.test(trimmedLine) ||
- // oxlint-disable-next-line prefer-string-starts-ends-with
- /^\d+\.\s+/.test(trimmedLine) ||
+ isOrderedListMarkerLine(trimmedLine) ||
// oxlint-disable-next-line prefer-string-starts-ends-with
/^>\s?/.test(trimmedLine) ||
// oxlint-disable-next-line prefer-string-starts-ends-with
@@ -102,8 +127,13 @@ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], nu
break
}
- const [, indent, number, content] = match
+ const [, indent, marker, _separator, content] = match
const indentLevel = indent.length
+ const number = parseInt(marker, 10)
+
+ const markerType = isNaN(number) ? detectMarkerType(marker) : undefined
+ const itemNumber = isNaN(number) ? markerToStart(marker) : number
+
const itemContentLines = [content]
let nextLineIndex = currentLineIndex + 1
const itemLines = [line]
@@ -144,7 +174,8 @@ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], nu
listItems.push({
indent: indentLevel,
- number: parseInt(number, 10),
+ number: itemNumber,
+ type: markerType,
content: itemContentLines.join('\n').trim(),
contentLines: itemContentLines,
raw: itemLines.join('\n'),
@@ -157,6 +188,58 @@ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], nu
return [listItems, consumed]
}
+const PLAIN_TEXT_ORDERED_LIST_LINE_REGEX = new RegExp(
+ `^(${ORDERED_LIST_MARKER_PATTERN})([.)])\\s+(.+)$`,
+)
+
+/**
+ * Parse plain-text pasted ordered list lines into JSONContent, or null if not a typed list.
+ */
+export function parsePlainTextOrderedListPaste(text: string): JSONContent | null {
+ const lines = text.split('\n').filter(l => l.trim().length > 0)
+
+ if (lines.length === 0) {
+ return null
+ }
+
+ const parsedItems: Array<{ marker: string; content: string }> = []
+
+ for (const line of lines) {
+ const match = line.trim().match(PLAIN_TEXT_ORDERED_LIST_LINE_REGEX)
+
+ if (!match) {
+ return null
+ }
+
+ parsedItems.push({
+ marker: match[1],
+ content: match[3],
+ })
+ }
+
+ const markers = parsedItems.map(item => item.marker)
+
+ if (!areOrderedListMarkersSequential(markers)) {
+ return null
+ }
+
+ const attrs = buildOrderedListAttrsFromMarker(parsedItems[0].marker)
+
+ return {
+ type: 'orderedList',
+ attrs,
+ content: parsedItems.map(item => ({
+ type: 'listItem',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: item.content }],
+ },
+ ],
+ })),
+ }
+}
+
/**
* Recursively builds a nested structure from a flat array of list items
* based on their indentation levels. Creates proper markdown tokens with
@@ -223,6 +306,7 @@ export function buildNestedStructure(
type: 'list',
ordered: true,
start: nestedItems[0].number,
+ typeMarker: nestedItems[0].type,
items: nestedListItems,
raw: nestedItems.map(nestedItem => nestedItem.raw).join('\n'),
})