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 () => {
  1. This item starts at 5
  2. And another one
+ +
    +
  1. Lowercase alphabetical list
  2. +
  3. Second item
  4. +
+ +
    +
  1. Uppercase roman numerals
  2. +
  3. Second item
  4. +
`, }) 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
  • + +
      +
    1. Lowercase alphabetical list
    2. +
    3. Second item
    4. +
    + +
      +
    1. Uppercase roman numerals
    2. +
    3. Second item
    4. +
    `, }) }, 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( + '
    1. Item A

    2. 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('
    1. 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('
    1. 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: '
    1. 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: '
    1. 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('
    1. 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('
    1. 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('
    1. 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('
    1. 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('
    1. 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('
    1. 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('
    1. 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('
    1. 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('
    1. 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('
    1. Item

    ') + + expect(json.content[0].attrs?.type).toBe('A') + }) + + it('parses type="i" from HTML correctly', () => { + const json = setAndGetJSON('
    1. Item

    ') + + expect(json.content[0].attrs?.type).toBe('i') + }) + + it('parses type="I" from HTML correctly', () => { + const json = setAndGetJSON('
    1. Item

    ') + + expect(json.content[0].attrs?.type).toBe('I') + }) + + it('parses type="1" from HTML as null (default) in JSON', () => { + const json = setAndGetJSON('
    1. 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('
    1. Item

    ') + + expect(json.content[0].attrs?.type).toBeNull() + }) + + it('parses both start and type from HTML', () => { + const json = setAndGetJSON('
    1. 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: '
    1. Item

    ', + }) + + const html = editor.getHTML() + + expect(html).toBe('
    1. Item

    ') + }) + + // ── CSS list-style-type parsing (Google Docs / Word pattern) ── + + it('parses CSS list-style-type: upper-roman on ol element', () => { + const json = setAndGetJSON( + '
    1. Item

    ', + ) + + expect(json.content[0].attrs?.type).toBe('I') + }) + + it('parses CSS list-style-type: lower-roman on ol element', () => { + const json = setAndGetJSON( + '
    1. Item

    ', + ) + + expect(json.content[0].attrs?.type).toBe('i') + }) + + it('parses CSS list-style-type: upper-alpha on ol element', () => { + const json = setAndGetJSON( + '
    1. Item

    ', + ) + + expect(json.content[0].attrs?.type).toBe('A') + }) + + it('parses CSS list-style-type: lower-alpha on ol element', () => { + const json = setAndGetJSON( + '
    1. Item

    ', + ) + + expect(json.content[0].attrs?.type).toBe('a') + }) + + it('parses CSS list-style-type from li child (Google Docs pattern)', () => { + const json = setAndGetJSON( + '
    1. Item

    ', + ) + + expect(json.content[0].attrs?.type).toBe('I') + }) + + it('parses CSS list-style-type: decimal as default (no type)', () => { + const json = setAndGetJSON('
    1. 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
      1. 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'), })