@thomasjahoda-forks/tiptap-extension-list 3.0.8 → 3.18.0-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/bullet-list/index.cjs +19 -0
  2. package/dist/bullet-list/index.cjs.map +1 -1
  3. package/dist/bullet-list/index.js +19 -0
  4. package/dist/bullet-list/index.js.map +1 -1
  5. package/dist/index.cjs +412 -4
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +2 -2
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +421 -7
  10. package/dist/index.js.map +1 -1
  11. package/dist/item/index.cjs +61 -0
  12. package/dist/item/index.cjs.map +1 -1
  13. package/dist/item/index.js +62 -1
  14. package/dist/item/index.js.map +1 -1
  15. package/dist/keymap/index.d.cts +2 -2
  16. package/dist/keymap/index.d.ts +2 -2
  17. package/dist/kit/index.cjs +412 -4
  18. package/dist/kit/index.cjs.map +1 -1
  19. package/dist/kit/index.js +421 -7
  20. package/dist/kit/index.js.map +1 -1
  21. package/dist/ordered-list/index.cjs +189 -0
  22. package/dist/ordered-list/index.cjs.map +1 -1
  23. package/dist/ordered-list/index.js +189 -0
  24. package/dist/ordered-list/index.js.map +1 -1
  25. package/dist/task-item/index.cjs +52 -4
  26. package/dist/task-item/index.cjs.map +1 -1
  27. package/dist/task-item/index.js +59 -5
  28. package/dist/task-item/index.js.map +1 -1
  29. package/dist/task-list/index.cjs +91 -0
  30. package/dist/task-list/index.cjs.map +1 -1
  31. package/dist/task-list/index.js +92 -1
  32. package/dist/task-list/index.js.map +1 -1
  33. package/package.json +5 -5
  34. package/src/bullet-list/bullet-list.ts +25 -0
  35. package/src/item/list-item.ts +82 -1
  36. package/src/ordered-list/ordered-list.ts +72 -0
  37. package/src/ordered-list/utils.ts +234 -0
  38. package/src/task-item/task-item.ts +85 -6
  39. package/src/task-list/task-list.ts +105 -1
@@ -0,0 +1,234 @@
1
+ import type { JSONContent, MarkdownLexerConfiguration, MarkdownParseHelpers, MarkdownToken } from '@tiptap/core'
2
+
3
+ /**
4
+ * Matches an ordered list item line with optional leading whitespace.
5
+ * Captures: (1) indentation spaces, (2) item number, (3) content after marker
6
+ * Example matches: "1. Item", " 2. Nested item", " 3. Deeply nested"
7
+ */
8
+ const ORDERED_LIST_ITEM_REGEX = /^(\s*)(\d+)\.\s+(.*)$/
9
+
10
+ /**
11
+ * Matches any line that starts with whitespace (indented content).
12
+ * Used to identify continuation content that belongs to a list item.
13
+ */
14
+ const INDENTED_LINE_REGEX = /^\s/
15
+
16
+ /**
17
+ * Represents a parsed ordered list item with indentation information
18
+ */
19
+ export interface OrderedListItem {
20
+ indent: number
21
+ number: number
22
+ content: string
23
+ raw: string
24
+ }
25
+
26
+ /**
27
+ * Collects all ordered list items from lines, parsing them into a flat array
28
+ * with indentation information. Stops collecting continuation content when
29
+ * encountering nested list items, allowing them to be processed separately.
30
+ *
31
+ * @param lines - Array of source lines to parse
32
+ * @returns Tuple of [listItems array, number of lines consumed]
33
+ */
34
+ export function collectOrderedListItems(lines: string[]): [OrderedListItem[], number] {
35
+ const listItems: OrderedListItem[] = []
36
+ let currentLineIndex = 0
37
+ let consumed = 0
38
+
39
+ while (currentLineIndex < lines.length) {
40
+ const line = lines[currentLineIndex]
41
+ const match = line.match(ORDERED_LIST_ITEM_REGEX)
42
+
43
+ if (!match) {
44
+ break
45
+ }
46
+
47
+ const [, indent, number, content] = match
48
+ const indentLevel = indent.length
49
+ let itemContent = content
50
+ let nextLineIndex = currentLineIndex + 1
51
+ const itemLines = [line]
52
+
53
+ // Collect continuation lines for this item (but NOT nested list items)
54
+ while (nextLineIndex < lines.length) {
55
+ const nextLine = lines[nextLineIndex]
56
+ const nextMatch = nextLine.match(ORDERED_LIST_ITEM_REGEX)
57
+
58
+ // If it's another list item (nested or not), stop collecting
59
+ if (nextMatch) {
60
+ break
61
+ }
62
+
63
+ // Check for continuation content (non-list content)
64
+ if (nextLine.trim() === '') {
65
+ // Empty line
66
+ itemLines.push(nextLine)
67
+ itemContent += '\n'
68
+ nextLineIndex += 1
69
+ } else if (nextLine.match(INDENTED_LINE_REGEX)) {
70
+ // Indented content - part of this item (but not a list item)
71
+ itemLines.push(nextLine)
72
+ itemContent += `\n${nextLine.slice(indentLevel + 2)}` // Remove list marker indent
73
+ nextLineIndex += 1
74
+ } else {
75
+ // Non-indented line means end of list
76
+ break
77
+ }
78
+ }
79
+
80
+ listItems.push({
81
+ indent: indentLevel,
82
+ number: parseInt(number, 10),
83
+ content: itemContent.trim(),
84
+ raw: itemLines.join('\n'),
85
+ })
86
+
87
+ consumed = nextLineIndex
88
+ currentLineIndex = nextLineIndex
89
+ }
90
+
91
+ return [listItems, consumed]
92
+ }
93
+
94
+ /**
95
+ * Recursively builds a nested structure from a flat array of list items
96
+ * based on their indentation levels. Creates proper markdown tokens with
97
+ * nested lists where appropriate.
98
+ *
99
+ * @param items - Flat array of list items with indentation info
100
+ * @param baseIndent - The indentation level to process at this recursion level
101
+ * @param lexer - Markdown lexer for parsing inline and block content
102
+ * @returns Array of list_item tokens with proper nesting
103
+ */
104
+ export function buildNestedStructure(
105
+ items: OrderedListItem[],
106
+ baseIndent: number,
107
+ lexer: MarkdownLexerConfiguration,
108
+ ): unknown[] {
109
+ const result: unknown[] = []
110
+ let currentIndex = 0
111
+
112
+ while (currentIndex < items.length) {
113
+ const item = items[currentIndex]
114
+
115
+ if (item.indent === baseIndent) {
116
+ // This item belongs at the current level
117
+ const contentLines = item.content.split('\n')
118
+ const mainText = contentLines[0]?.trim() || ''
119
+
120
+ const tokens = []
121
+
122
+ // Always wrap the main text in a paragraph token
123
+ if (mainText) {
124
+ tokens.push({
125
+ type: 'paragraph',
126
+ raw: mainText,
127
+ tokens: lexer.inlineTokens(mainText),
128
+ })
129
+ }
130
+
131
+ // Handle additional content after the main text
132
+ const additionalContent = contentLines.slice(1).join('\n').trim()
133
+ if (additionalContent) {
134
+ // Parse as block tokens (handles mixed unordered lists, etc.)
135
+ const blockTokens = lexer.blockTokens(additionalContent)
136
+ tokens.push(...blockTokens)
137
+ }
138
+
139
+ // Look ahead to find nested items at deeper indent levels
140
+ let lookAheadIndex = currentIndex + 1
141
+ const nestedItems = []
142
+
143
+ while (lookAheadIndex < items.length && items[lookAheadIndex].indent > baseIndent) {
144
+ nestedItems.push(items[lookAheadIndex])
145
+ lookAheadIndex += 1
146
+ }
147
+
148
+ // If we have nested items, recursively build their structure
149
+ if (nestedItems.length > 0) {
150
+ // Find the next indent level (immediate children)
151
+ const nextIndent = Math.min(...nestedItems.map(nestedItem => nestedItem.indent))
152
+
153
+ // Build the nested list recursively with all nested items
154
+ // The recursive call will handle further nesting
155
+ const nestedListItems = buildNestedStructure(nestedItems, nextIndent, lexer)
156
+
157
+ // Create a nested list token
158
+ tokens.push({
159
+ type: 'list',
160
+ ordered: true,
161
+ start: nestedItems[0].number,
162
+ items: nestedListItems,
163
+ raw: nestedItems.map(nestedItem => nestedItem.raw).join('\n'),
164
+ })
165
+ }
166
+
167
+ result.push({
168
+ type: 'list_item',
169
+ raw: item.raw,
170
+ tokens,
171
+ })
172
+
173
+ // Skip the nested items we just processed
174
+ currentIndex = lookAheadIndex
175
+ } else {
176
+ // This item has deeper indent than we're currently processing
177
+ // It should be handled by a recursive call
178
+ currentIndex += 1
179
+ }
180
+ }
181
+
182
+ return result
183
+ }
184
+
185
+ /**
186
+ * Parses markdown list item tokens into Tiptap JSONContent structure,
187
+ * ensuring text content is properly wrapped in paragraph nodes.
188
+ *
189
+ * @param items - Array of markdown tokens representing list items
190
+ * @param helpers - Markdown parse helpers for recursive parsing
191
+ * @returns Array of listItem JSONContent nodes
192
+ */
193
+ export function parseListItems(items: MarkdownToken[], helpers: MarkdownParseHelpers): JSONContent[] {
194
+ return items.map(item => {
195
+ if (item.type !== 'list_item') {
196
+ return helpers.parseChildren([item])[0]
197
+ }
198
+
199
+ // Parse the tokens within the list item
200
+ const content: JSONContent[] = []
201
+
202
+ if (item.tokens && item.tokens.length > 0) {
203
+ item.tokens.forEach(itemToken => {
204
+ // If it's already a proper block node (paragraph, list, etc.), parse it directly
205
+ if (
206
+ itemToken.type === 'paragraph' ||
207
+ itemToken.type === 'list' ||
208
+ itemToken.type === 'blockquote' ||
209
+ itemToken.type === 'code'
210
+ ) {
211
+ content.push(...helpers.parseChildren([itemToken]))
212
+ } else if (itemToken.type === 'text' && itemToken.tokens) {
213
+ // If it's inline text tokens, wrap them in a paragraph
214
+ const inlineContent = helpers.parseChildren([itemToken])
215
+ content.push({
216
+ type: 'paragraph',
217
+ content: inlineContent,
218
+ })
219
+ } else {
220
+ // For any other content, try to parse it
221
+ const parsed = helpers.parseChildren([itemToken])
222
+ if (parsed.length > 0) {
223
+ content.push(...parsed)
224
+ }
225
+ }
226
+ })
227
+ }
228
+
229
+ return {
230
+ type: 'listItem',
231
+ content,
232
+ }
233
+ })
234
+ }
@@ -1,5 +1,11 @@
1
1
  import type { KeyboardShortcutCommand } from '@tiptap/core'
2
- import { mergeAttributes, Node, wrappingInputRule } from '@tiptap/core'
2
+ import {
3
+ getRenderedAttributes,
4
+ mergeAttributes,
5
+ Node,
6
+ renderNestedMarkdownContent,
7
+ wrappingInputRule,
8
+ } from '@tiptap/core'
3
9
  import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
4
10
 
5
11
  export interface TaskItemOptions {
@@ -120,6 +126,38 @@ export const TaskItem = Node.create<TaskItemOptions>({
120
126
  ]
121
127
  },
122
128
 
129
+ parseMarkdown: (token, h) => {
130
+ // Parse the task item's text content into paragraph content
131
+ const content = []
132
+
133
+ // First, add the main paragraph content
134
+ if (token.tokens && token.tokens.length > 0) {
135
+ // If we have tokens, create a paragraph with the inline content
136
+ content.push(h.createNode('paragraph', {}, h.parseInline(token.tokens)))
137
+ } else if (token.text) {
138
+ // If we have raw text, create a paragraph with text node
139
+ content.push(h.createNode('paragraph', {}, [h.createNode('text', { text: token.text })]))
140
+ } else {
141
+ // Fallback: empty paragraph
142
+ content.push(h.createNode('paragraph', {}, []))
143
+ }
144
+
145
+ // Then, add any nested content (like nested task lists)
146
+ if (token.nestedTokens && token.nestedTokens.length > 0) {
147
+ const nestedContent = h.parseChildren(token.nestedTokens)
148
+ content.push(...nestedContent)
149
+ }
150
+
151
+ return h.createNode('taskItem', { checked: token.checked || false }, content)
152
+ },
153
+
154
+ renderMarkdown: (node, h) => {
155
+ const checkedChar = node.attrs?.checked ? 'x' : ' '
156
+ const prefix = `- [${checkedChar}] `
157
+
158
+ return renderNestedMarkdownContent(node, h, prefix)
159
+ },
160
+
123
161
  addKeyboardShortcuts() {
124
162
  const shortcuts: {
125
163
  [key: string]: KeyboardShortcutCommand
@@ -146,16 +184,17 @@ export const TaskItem = Node.create<TaskItemOptions>({
146
184
  const checkbox = document.createElement('input')
147
185
  const content = document.createElement('div')
148
186
 
149
- const updateA11Y = () => {
187
+ const updateA11Y = (currentNode: ProseMirrorNode) => {
150
188
  checkbox.ariaLabel =
151
- this.options.a11y?.checkboxLabel?.(node, checkbox.checked) ||
152
- `Task item checkbox for ${node.textContent || 'empty task item'}`
189
+ this.options.a11y?.checkboxLabel?.(currentNode, checkbox.checked) ||
190
+ `Task item checkbox for ${currentNode.textContent || 'empty task item'}`
153
191
  }
154
192
 
155
- updateA11Y()
193
+ updateA11Y(node)
156
194
 
157
195
  checkboxWrapper.contentEditable = 'false'
158
196
  checkbox.type = 'checkbox'
197
+ checkbox.contentEditable = 'false' // TODO test whether this may help with mobile stuff
159
198
  checkbox.addEventListener('mousedown', event => event.preventDefault())
160
199
  checkbox.addEventListener('change', event => {
161
200
  // if the editor isn’t editable and we don't have a handler for
@@ -220,6 +259,7 @@ export const TaskItem = Node.create<TaskItemOptions>({
220
259
  }
221
260
  })
222
261
 
262
+ // TODO get changes from upstream regarding updating attributes properly so attributes may be removed
223
263
  Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
224
264
  listItem.setAttribute(key, value)
225
265
  })
@@ -234,6 +274,9 @@ export const TaskItem = Node.create<TaskItemOptions>({
234
274
  listItem.setAttribute(key, value)
235
275
  })
236
276
 
277
+ // Track the keys of previously rendered HTML attributes for proper removal
278
+ let prevRenderedAttributeKeys = new Set(Object.keys(HTMLAttributes))
279
+
237
280
  return {
238
281
  dom: listItem,
239
282
  contentDOM: content,
@@ -244,7 +287,43 @@ export const TaskItem = Node.create<TaskItemOptions>({
244
287
 
245
288
  listItem.dataset.checked = updatedNode.attrs.checked
246
289
  checkbox.checked = updatedNode.attrs.checked
247
- updateA11Y()
290
+ updateA11Y(updatedNode)
291
+
292
+ // Sync all HTML attributes from the updated node
293
+ const extensionAttributes = editor.extensionManager.attributes
294
+ const newHTMLAttributes = getRenderedAttributes(updatedNode, extensionAttributes)
295
+ const newKeys = new Set(Object.keys(newHTMLAttributes))
296
+
297
+ // Remove attributes that were previously rendered but are no longer present
298
+ // If the attribute exists in static options, restore it instead of removing
299
+ const staticAttrs = this.options.HTMLAttributes
300
+
301
+ prevRenderedAttributeKeys.forEach(key => {
302
+ if (!newKeys.has(key)) {
303
+ if (key in staticAttrs) {
304
+ listItem.setAttribute(key, staticAttrs[key])
305
+ } else {
306
+ listItem.removeAttribute(key)
307
+ }
308
+ }
309
+ })
310
+
311
+ // Update or add new attributes
312
+ Object.entries(newHTMLAttributes).forEach(([key, value]) => {
313
+ if (value === null || value === undefined) {
314
+ // If the attribute exists in static options, restore it instead of removing
315
+ if (key in staticAttrs) {
316
+ listItem.setAttribute(key, staticAttrs[key])
317
+ } else {
318
+ listItem.removeAttribute(key)
319
+ }
320
+ } else {
321
+ listItem.setAttribute(key, value)
322
+ }
323
+ })
324
+
325
+ // Update the tracked keys for next update
326
+ prevRenderedAttributeKeys = newKeys
248
327
 
249
328
  return true
250
329
  },
@@ -1,4 +1,4 @@
1
- import { mergeAttributes, Node } from '@tiptap/core'
1
+ import { mergeAttributes, Node, parseIndentedBlocks } from '@tiptap/core'
2
2
 
3
3
  export interface TaskListOptions {
4
4
  /**
@@ -61,6 +61,110 @@ export const TaskList = Node.create<TaskListOptions>({
61
61
  return ['ul', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': this.name }), 0]
62
62
  },
63
63
 
64
+ parseMarkdown: (token, h) => {
65
+ return h.createNode('taskList', {}, h.parseChildren(token.items || []))
66
+ },
67
+
68
+ renderMarkdown: (node, h) => {
69
+ if (!node.content) {
70
+ return ''
71
+ }
72
+
73
+ return h.renderChildren(node.content, '\n')
74
+ },
75
+
76
+ markdownTokenizer: {
77
+ name: 'taskList',
78
+ level: 'block',
79
+ start(src) {
80
+ // Look for the start of a task list item
81
+ const index = src.match(/^\s*[-+*]\s+\[([ xX])\]\s+/)?.index
82
+ return index !== undefined ? index : -1
83
+ },
84
+ tokenize(src, tokens, lexer) {
85
+ // Helper function to recursively parse task lists
86
+ const parseTaskListContent = (content: string): any[] | undefined => {
87
+ const nestedResult = parseIndentedBlocks(
88
+ content,
89
+ {
90
+ itemPattern: /^(\s*)([-+*])\s+\[([ xX])\]\s+(.*)$/,
91
+ extractItemData: match => ({
92
+ indentLevel: match[1].length,
93
+ mainContent: match[4],
94
+ checked: match[3].toLowerCase() === 'x',
95
+ }),
96
+ createToken: (data, nestedTokens) => ({
97
+ type: 'taskItem',
98
+ raw: '',
99
+ mainContent: data.mainContent,
100
+ indentLevel: data.indentLevel,
101
+ checked: data.checked,
102
+ text: data.mainContent,
103
+ tokens: lexer.inlineTokens(data.mainContent),
104
+ nestedTokens,
105
+ }),
106
+ // Allow recursive nesting
107
+ customNestedParser: parseTaskListContent,
108
+ },
109
+ lexer,
110
+ )
111
+
112
+ if (nestedResult) {
113
+ // Return as task list token
114
+ return [
115
+ {
116
+ type: 'taskList',
117
+ raw: nestedResult.raw,
118
+ items: nestedResult.items,
119
+ },
120
+ ]
121
+ }
122
+
123
+ // Fall back to regular markdown parsing if not a task list
124
+ return lexer.blockTokens(content)
125
+ }
126
+
127
+ const result = parseIndentedBlocks(
128
+ src,
129
+ {
130
+ itemPattern: /^(\s*)([-+*])\s+\[([ xX])\]\s+(.*)$/,
131
+ extractItemData: match => ({
132
+ indentLevel: match[1].length,
133
+ mainContent: match[4],
134
+ checked: match[3].toLowerCase() === 'x',
135
+ }),
136
+ createToken: (data, nestedTokens) => ({
137
+ type: 'taskItem',
138
+ raw: '',
139
+ mainContent: data.mainContent,
140
+ indentLevel: data.indentLevel,
141
+ checked: data.checked,
142
+ text: data.mainContent,
143
+ tokens: lexer.inlineTokens(data.mainContent),
144
+ nestedTokens,
145
+ }),
146
+ // Use the recursive parser for nested content
147
+ customNestedParser: parseTaskListContent,
148
+ },
149
+ lexer,
150
+ )
151
+
152
+ if (!result) {
153
+ return undefined
154
+ }
155
+
156
+ return {
157
+ type: 'taskList',
158
+ raw: result.raw,
159
+ items: result.items,
160
+ }
161
+ },
162
+ },
163
+
164
+ markdownOptions: {
165
+ indentsContent: true,
166
+ },
167
+
64
168
  addCommands() {
65
169
  return {
66
170
  toggleTaskList: