@bagelink/vue 1.16.0 → 1.17.6

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 (128) hide show
  1. package/dist/assetTransport.d.ts +28 -0
  2. package/dist/assetTransport.d.ts.map +1 -0
  3. package/dist/assetTransport.test.d.ts +2 -0
  4. package/dist/assetTransport.test.d.ts.map +1 -0
  5. package/dist/assets.d.ts +65 -0
  6. package/dist/assets.d.ts.map +1 -0
  7. package/dist/assets.test.d.ts +2 -0
  8. package/dist/assets.test.d.ts.map +1 -0
  9. package/dist/components/AddressSearch.vue.d.ts +40 -36
  10. package/dist/components/AddressSearch.vue.d.ts.map +1 -1
  11. package/dist/components/ContextMenu.vue.d.ts +518 -0
  12. package/dist/components/ContextMenu.vue.d.ts.map +1 -0
  13. package/dist/components/Dropdown.types.d.ts +25 -0
  14. package/dist/components/Dropdown.types.d.ts.map +1 -0
  15. package/dist/components/Dropdown.vue.d.ts +20 -15
  16. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  17. package/dist/components/FilterQuery.vue.d.ts +1 -1
  18. package/dist/components/FilterQuery.vue.d.ts.map +1 -1
  19. package/dist/components/Icon/Icon.vue.d.ts +0 -1
  20. package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
  21. package/dist/components/Icon/constants.d.ts +0 -1
  22. package/dist/components/Icon/constants.d.ts.map +1 -1
  23. package/dist/components/Icon/types.d.ts +2 -3
  24. package/dist/components/Icon/types.d.ts.map +1 -1
  25. package/dist/components/Image.vue.d.ts +7 -1
  26. package/dist/components/Image.vue.d.ts.map +1 -1
  27. package/dist/components/Swiper.vue.d.ts +1 -1
  28. package/dist/components/draggable/useDraggable.d.ts.map +1 -1
  29. package/dist/components/form/inputs/IconPickerInput.vue.d.ts +48 -37
  30. package/dist/components/form/inputs/IconPickerInput.vue.d.ts.map +1 -1
  31. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
  32. package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
  33. package/dist/components/form/inputs/RichText/composables/useEditor.d.ts +3 -0
  34. package/dist/components/form/inputs/RichText/composables/useEditor.d.ts.map +1 -1
  35. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts +1 -1
  36. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
  37. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  38. package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
  39. package/dist/components/form/inputs/RichText/utils/selection.d.ts +2 -0
  40. package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
  41. package/dist/components/form/inputs/RichText/utils/table.d.ts +34 -8
  42. package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
  43. package/dist/components/form/inputs/SelectInput.vue.d.ts +71 -63
  44. package/dist/components/form/inputs/SelectInput.vue.d.ts.map +1 -1
  45. package/dist/components/form/inputs/TelInput.vue.d.ts +2 -2
  46. package/dist/components/form/inputs/Upload/AssetUploadInput.vue.d.ts +11 -0
  47. package/dist/components/form/inputs/Upload/AssetUploadInput.vue.d.ts.map +1 -0
  48. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -2
  49. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
  50. package/dist/components/index.d.ts +3 -1
  51. package/dist/components/index.d.ts.map +1 -1
  52. package/dist/components/lightbox/AssetPreview.vue.d.ts +9 -0
  53. package/dist/components/lightbox/AssetPreview.vue.d.ts.map +1 -0
  54. package/dist/components/lightbox/Lightbox.vue.d.ts.map +1 -1
  55. package/dist/components/lightbox/LightboxImage.vue.d.ts +2 -1
  56. package/dist/components/lightbox/LightboxImage.vue.d.ts.map +1 -1
  57. package/dist/components/lightbox/index.d.ts.map +1 -1
  58. package/dist/components/lightbox/lightbox.types.d.ts +7 -0
  59. package/dist/components/lightbox/lightbox.types.d.ts.map +1 -1
  60. package/dist/composables/index.d.ts +1 -1
  61. package/dist/composables/index.d.ts.map +1 -1
  62. package/dist/composables/useBoundedRectDrag.d.ts.map +1 -1
  63. package/dist/composables/useImageSrc.d.ts +8 -4
  64. package/dist/composables/useImageSrc.d.ts.map +1 -1
  65. package/dist/composables/usePointerSession.d.ts +14 -3
  66. package/dist/composables/usePointerSession.d.ts.map +1 -1
  67. package/dist/core.css +3 -3
  68. package/dist/directives/vResize.d.ts.map +1 -1
  69. package/dist/form-flow/FormFlowPlayer.vue.d.ts +3 -3
  70. package/dist/form-flow/FormFlowPlayer.vue.d.ts.map +1 -1
  71. package/dist/form-flow/form-flow.d.ts +13 -6
  72. package/dist/form-flow/form-flow.d.ts.map +1 -1
  73. package/dist/form-flow/schema-fields.d.ts +6 -0
  74. package/dist/form-flow/schema-fields.d.ts.map +1 -1
  75. package/dist/index.cjs +240 -153
  76. package/dist/index.css +1 -1
  77. package/dist/index.d.ts +3 -0
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.mjs +24167 -23417
  80. package/dist/style.css +3 -3
  81. package/dist/types/BagelForm.d.ts +5 -1
  82. package/dist/types/BagelForm.d.ts.map +1 -1
  83. package/package.json +1 -1
  84. package/src/assetTransport.test.ts +75 -0
  85. package/src/assetTransport.ts +98 -0
  86. package/src/assets.test.ts +82 -0
  87. package/src/assets.ts +162 -0
  88. package/src/components/ContextMenu.vue +113 -0
  89. package/src/components/Dropdown.types.ts +27 -0
  90. package/src/components/Dropdown.vue +129 -64
  91. package/src/components/FilterQuery.vue +1 -1
  92. package/src/components/Icon/Icon.vue +8 -30
  93. package/src/components/Icon/constants.ts +0 -166
  94. package/src/components/Icon/types.ts +2 -3
  95. package/src/components/Image.vue +19 -3
  96. package/src/components/draggable/useDraggable.ts +67 -100
  97. package/src/components/form/inputs/IconPickerInput.vue +14 -9
  98. package/src/components/form/inputs/RichText/components/EditorToolbar.vue +43 -111
  99. package/src/components/form/inputs/RichText/composables/useCommands.ts +32 -54
  100. package/src/components/form/inputs/RichText/composables/useEditor.ts +45 -171
  101. package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +90 -14
  102. package/src/components/form/inputs/RichText/index.vue +1195 -1210
  103. package/src/components/form/inputs/RichText/utils/commands.ts +689 -1028
  104. package/src/components/form/inputs/RichText/utils/selection.ts +66 -41
  105. package/src/components/form/inputs/RichText/utils/table.ts +798 -136
  106. package/src/components/form/inputs/SelectInput.vue +1 -1
  107. package/src/components/form/inputs/Upload/AssetUploadInput.vue +67 -0
  108. package/src/components/form/inputs/Upload/UploadInput.vue +5 -1
  109. package/src/components/index.ts +3 -1
  110. package/src/components/lightbox/AssetPreview.vue +16 -0
  111. package/src/components/lightbox/Lightbox.vue +8 -3
  112. package/src/components/lightbox/LightboxImage.vue +4 -3
  113. package/src/components/lightbox/index.ts +29 -4
  114. package/src/components/lightbox/lightbox.types.ts +5 -1
  115. package/src/composables/index.ts +1 -1
  116. package/src/composables/useBoundedRectDrag.ts +9 -12
  117. package/src/composables/useImageSrc.ts +47 -37
  118. package/src/composables/usePointerSession.ts +57 -12
  119. package/src/directives/vResize.ts +6 -7
  120. package/src/form-flow/form-flow.ts +31 -7
  121. package/src/form-flow/schema-fields.ts +28 -1
  122. package/src/index.ts +3 -0
  123. package/src/styles/core.css +1 -18
  124. package/src/types/BagelForm.ts +5 -1
  125. package/dist/components/form/inputs/RichText/utils/formatting.d.ts +0 -10
  126. package/dist/components/form/inputs/RichText/utils/formatting.d.ts.map +0 -1
  127. package/src/components/form/inputs/RichText/components/gridBox.vue +0 -58
  128. package/src/components/form/inputs/RichText/utils/formatting.ts +0 -331
@@ -1,6 +1,19 @@
1
1
  import type { EditorState } from '../richTextTypes'
2
+ import { pushHistorySnapshot, restoreHistoryNodes } from '../composables/useEditor'
2
3
  import { insertLink } from './media'
3
- import { addRow, deleteRow, mergeCells, splitCell, insertTable, deleteTable, insertColumn, deleteColumn, alignColumn } from './table'
4
+ import { isStyleActive } from './selection'
5
+ import {
6
+ addRow,
7
+ alignColumn,
8
+ deleteColumn,
9
+ deleteRow,
10
+ deleteTable,
11
+ focusTableCell,
12
+ insertColumn,
13
+ insertTable,
14
+ mergeCells,
15
+ splitCell
16
+ } from './table'
4
17
 
5
18
  export interface Command {
6
19
  name: string
@@ -19,7 +32,6 @@ export interface CommandExecutor {
19
32
  getValue: (command: string) => string | null
20
33
  }
21
34
 
22
- // Helper functions
23
35
  function getCurrentSelection(state: EditorState): { selection: Selection, range: Range } | null {
24
36
  if (!state.doc) { return null }
25
37
 
@@ -30,14 +42,12 @@ function getCurrentSelection(state: EditorState): { selection: Selection, range:
30
42
  return { selection, range }
31
43
  }
32
44
 
33
- // Alternative helper function to apply formatting to word at cursor
34
- function applyFormattingToWordAtCursor(doc: Document, range: Range, tagName: string): boolean {
45
+ function applyFormattingToWordAtCursor(doc: Document, range: Range, tagSelector: string, createTag: string): boolean {
35
46
  if (!range.collapsed) { return false }
36
47
 
37
48
  let textNode = range.startContainer
38
49
  let offset = range.startOffset
39
50
 
40
- // If we're in an element node, try to find a text node
41
51
  if (textNode.nodeType !== Node.TEXT_NODE) {
42
52
  const element = textNode as Element
43
53
  if (element.childNodes.length > 0 && offset < element.childNodes.length) {
@@ -53,24 +63,20 @@ function applyFormattingToWordAtCursor(doc: Document, range: Range, tagName: str
53
63
  }
54
64
  }
55
65
 
56
- // Check if we're already inside a formatting element
57
66
  const { parentElement } = textNode
58
- const existingFormatElement = parentElement?.closest(tagName.toLowerCase())
67
+ const existingFormatElement = parentElement?.closest(tagSelector)
59
68
 
60
69
  if (existingFormatElement) {
61
- // Remove existing formatting
62
70
  try {
63
71
  const parent = existingFormatElement.parentNode
64
72
  if (parent) {
65
- // Move all children of the format element to its parent
66
73
  while (existingFormatElement.firstChild) {
67
74
  parent.insertBefore(existingFormatElement.firstChild, existingFormatElement)
68
75
  }
69
76
  parent.removeChild(existingFormatElement)
70
77
 
71
- // Position cursor at the same relative position
72
78
  const newRange = doc.createRange()
73
- newRange.setStart(textNode, offset)
79
+ newRange.setStart(textNode, Math.min(offset, textNode.textContent?.length ?? 0))
74
80
  newRange.collapse(true)
75
81
 
76
82
  const selection = doc.getSelection()
@@ -90,64 +96,48 @@ function applyFormattingToWordAtCursor(doc: Document, range: Range, tagName: str
90
96
  const text = textNode.textContent || ''
91
97
  if (!text) { return false }
92
98
 
93
- // Find word boundaries - support Hebrew, English, and numbers
94
99
  let start = offset
95
100
  let end = offset
96
-
97
- // Regular expression for word characters (Hebrew, English, numbers)
98
101
  const wordChar = /[\u0590-\u05FF\u0600-\u06FF\w]/
99
102
 
100
- // If we're at the end of a word (cursor right after the last character)
101
- // move back one position to include that word
102
103
  if (offset > 0 && wordChar.test(text[offset - 1])
103
104
  && (offset >= text.length || !wordChar.test(text[offset]))) {
104
105
  start = offset - 1
105
106
  end = offset - 1
106
107
  }
107
108
 
108
- // Move start backwards to find word beginning
109
109
  while (start > 0 && wordChar.test(text[start - 1])) {
110
110
  start--
111
111
  }
112
112
 
113
- // Move end forwards to find word ending
114
113
  while (end < text.length && wordChar.test(text[end])) {
115
114
  end++
116
115
  }
117
116
 
118
- // If we found a word, apply formatting
119
- if (start < end && end > start) {
117
+ if (start < end) {
120
118
  try {
121
- // Split the text node into three parts: before, word, after
122
119
  const beforeText = text.substring(0, start)
123
120
  const wordText = text.substring(start, end)
124
121
  const afterText = text.substring(end)
122
+ const relativeCursorOffset = Math.max(0, Math.min(wordText.length, offset - start))
125
123
 
126
- // Create the formatting element
127
- const formatElement = doc.createElement(tagName)
128
- formatElement.textContent = wordText
124
+ const formatElement = doc.createElement(createTag)
125
+ const innerTextNode = doc.createTextNode(wordText)
126
+ formatElement.appendChild(innerTextNode)
129
127
 
130
- // Replace the text node with the new structure
131
128
  const parent = textNode.parentNode
132
129
  if (parent) {
133
- // Create text nodes for before and after
134
130
  if (beforeText) {
135
- const beforeNode = doc.createTextNode(beforeText)
136
- parent.insertBefore(beforeNode, textNode)
131
+ parent.insertBefore(doc.createTextNode(beforeText), textNode)
137
132
  }
138
-
139
133
  parent.insertBefore(formatElement, textNode)
140
-
141
134
  if (afterText) {
142
- const afterNode = doc.createTextNode(afterText)
143
- parent.insertBefore(afterNode, textNode)
135
+ parent.insertBefore(doc.createTextNode(afterText), textNode)
144
136
  }
145
-
146
137
  parent.removeChild(textNode)
147
138
 
148
- // Position cursor after the formatted word
149
139
  const newRange = doc.createRange()
150
- newRange.setStartAfter(formatElement)
140
+ newRange.setStart(innerTextNode, relativeCursorOffset)
151
141
  newRange.collapse(true)
152
142
 
153
143
  const selection = doc.getSelection()
@@ -166,13 +156,134 @@ function applyFormattingToWordAtCursor(doc: Document, range: Range, tagName: str
166
156
  return false
167
157
  }
168
158
 
169
- function updateStateAfterCommand(state: EditorState) {
159
+ function toggleInlineFormatting(
160
+ state: EditorState,
161
+ tagName: 'b' | 'i' | 'u',
162
+ matchSelector: string
163
+ ) {
164
+ const selectionInfo = getCurrentSelection(state)
165
+ if (!selectionInfo || !state.doc) { return }
166
+
167
+ const { doc } = state
168
+ const { range, selection } = selectionInfo
169
+
170
+ if (range.collapsed) {
171
+ applyFormattingToWordAtCursor(doc, range, matchSelector, tagName)
172
+ return
173
+ }
174
+
175
+ const commonAncestor = range.commonAncestorContainer
176
+ const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
177
+ ? commonAncestor.parentElement
178
+ : commonAncestor as Element
179
+
180
+ const activeFormatEl = parentElement?.closest(matchSelector) as HTMLElement | null
181
+
182
+ if (activeFormatEl) {
183
+ // Unwrap only the selected portion if the selection is strictly inside the formatted element
184
+ const selectedText = range.toString()
185
+ const fullText = activeFormatEl.textContent || ''
186
+
187
+ if (selectedText === fullText || range.startContainer !== range.endContainer || range.startContainer.parentElement !== activeFormatEl) {
188
+ const parent = activeFormatEl.parentNode
189
+ const firstMoved = activeFormatEl.firstChild
190
+ const lastMoved = activeFormatEl.lastChild
191
+ while (activeFormatEl.firstChild) {
192
+ parent?.insertBefore(activeFormatEl.firstChild, activeFormatEl)
193
+ }
194
+ parent?.removeChild(activeFormatEl)
195
+
196
+ if (firstMoved && lastMoved) {
197
+ const nextRange = doc.createRange()
198
+ nextRange.setStartBefore(firstMoved)
199
+ nextRange.setEndAfter(lastMoved)
200
+ selection.removeAllRanges()
201
+ selection.addRange(nextRange)
202
+ }
203
+ } else {
204
+ // Selection is a substring of a single text node inside activeFormatEl: split into [before, unwrapped, after]
205
+ const textNode = range.startContainer as Text
206
+ const text = textNode.textContent || ''
207
+ const before = text.slice(0, range.startOffset)
208
+ const middle = text.slice(range.startOffset, range.endOffset)
209
+ const after = text.slice(range.endOffset)
210
+ const parent = activeFormatEl.parentNode
211
+
212
+ if (parent) {
213
+ const middleNode = doc.createTextNode(middle)
214
+ if (before) {
215
+ const beforeEl = activeFormatEl.cloneNode(false) as HTMLElement
216
+ beforeEl.textContent = before
217
+ parent.insertBefore(beforeEl, activeFormatEl)
218
+ }
219
+ parent.insertBefore(middleNode, activeFormatEl)
220
+ if (after) {
221
+ const afterEl = activeFormatEl.cloneNode(false) as HTMLElement
222
+ afterEl.textContent = after
223
+ parent.insertBefore(afterEl, activeFormatEl)
224
+ }
225
+ parent.removeChild(activeFormatEl)
226
+
227
+ const nextRange = doc.createRange()
228
+ nextRange.selectNodeContents(middleNode)
229
+ selection.removeAllRanges()
230
+ selection.addRange(nextRange)
231
+ }
232
+ }
233
+ return
234
+ }
235
+
236
+ // Check if selection spans across multiple block elements
237
+ const blockSelector = 'p,h1,h2,h3,h4,h5,h6,blockquote,li,td,th'
238
+ const blocks = Array.from(doc.body.querySelectorAll<HTMLElement>(blockSelector))
239
+ .filter(b => !b.querySelector(blockSelector) && range.intersectsNode(b))
240
+
241
+ if (blocks.length > 1) {
242
+ blocks.forEach((block) => {
243
+ const wrapper = doc.createElement(tagName)
244
+ while (block.firstChild) {
245
+ wrapper.appendChild(block.firstChild)
246
+ }
247
+ block.appendChild(wrapper)
248
+ })
249
+ return
250
+ }
251
+
252
+ // Single-block inline wrap
253
+ try {
254
+ const wrapper = doc.createElement(tagName)
255
+ try {
256
+ range.surroundContents(wrapper)
257
+ } catch {
258
+ const fragment = range.extractContents()
259
+ // Unwrap redundant nested tags of the same type inside the fragment
260
+ fragment.querySelectorAll(matchSelector).forEach((nested) => {
261
+ const p = nested.parentNode
262
+ while (nested.firstChild) {
263
+ p?.insertBefore(nested.firstChild, nested)
264
+ }
265
+ nested.remove()
266
+ })
267
+ wrapper.appendChild(fragment)
268
+ range.insertNode(wrapper)
269
+ }
270
+ range.selectNodeContents(wrapper)
271
+ selection.removeAllRanges()
272
+ selection.addRange(range)
273
+ } catch (error) {
274
+ console.error(`Error applying ${tagName}:`, error)
275
+ }
276
+ }
277
+
278
+ function updateStateAfterCommand(state: EditorState, preCommandContent?: string) {
170
279
  if (!state.doc) { return }
171
280
 
172
- // Update content
173
- state.content = state.doc.body.innerHTML
281
+ const newContent = state.doc.body.innerHTML
282
+ if (preCommandContent !== undefined && newContent !== preCommandContent) {
283
+ pushHistorySnapshot(state, preCommandContent)
284
+ }
285
+ state.content = newContent
174
286
 
175
- // Update selection state
176
287
  const selection = state.doc.getSelection()
177
288
  if (selection && selection.rangeCount > 0) {
178
289
  state.selection = selection
@@ -181,8 +292,8 @@ function updateStateAfterCommand(state: EditorState) {
181
292
  }
182
293
  }
183
294
 
184
- function findBlockElement(node: Node): Element | null {
185
- let current = node.nodeType === Node.TEXT_NODE ? node.parentElement : node as Element
295
+ function findBlockElement(node: Node): HTMLElement | null {
296
+ let current = node.nodeType === Node.TEXT_NODE ? node.parentElement : node as HTMLElement | null
186
297
  while (current) {
187
298
  if (current.matches && current.matches('p,h1,h2,h3,h4,h5,h6,blockquote,li,div')) {
188
299
  return current
@@ -192,7 +303,41 @@ function findBlockElement(node: Node): Element | null {
192
303
  return null
193
304
  }
194
305
 
195
- const LIST_BLOCK_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,blockquote'
306
+ const CONVERTIBLE_BLOCK_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,blockquote'
307
+
308
+ function getSelectedBlocks(doc: Document, range: Range): HTMLElement[] {
309
+ if (!range.collapsed) {
310
+ const blocks = Array.from(doc.body.querySelectorAll<HTMLElement>(CONVERTIBLE_BLOCK_SELECTOR))
311
+ .filter(block => !block.closest('li') && range.intersectsNode(block))
312
+ .filter(block => !block.parentElement?.closest(CONVERTIBLE_BLOCK_SELECTOR))
313
+ if (blocks.length > 0) {
314
+ return blocks
315
+ }
316
+ }
317
+ const single = findBlockElement(range.commonAncestorContainer)
318
+ if (single && single !== doc.body) {
319
+ return [single]
320
+ }
321
+ return []
322
+ }
323
+
324
+ function convertBlockTag(doc: Document, currentBlock: HTMLElement, newTag: string): HTMLElement {
325
+ const newBlock = doc.createElement(newTag)
326
+ if (currentBlock.dir) {
327
+ newBlock.dir = currentBlock.dir
328
+ }
329
+ if (currentBlock.hasAttribute('data-dir-explicit')) {
330
+ newBlock.setAttribute('data-dir-explicit', 'true')
331
+ }
332
+ if (currentBlock.style.textAlign) {
333
+ newBlock.style.textAlign = currentBlock.style.textAlign
334
+ }
335
+ while (currentBlock.firstChild) {
336
+ newBlock.appendChild(currentBlock.firstChild)
337
+ }
338
+ currentBlock.parentNode?.replaceChild(newBlock, currentBlock)
339
+ return newBlock
340
+ }
196
341
 
197
342
  function convertSelectedBlocksToList(
198
343
  state: EditorState,
@@ -202,24 +347,20 @@ function convertSelectedBlocksToList(
202
347
  ): boolean {
203
348
  if (!state.doc || range.collapsed) { return false }
204
349
 
205
- const blocks = Array.from(state.doc.body.querySelectorAll(LIST_BLOCK_SELECTOR))
350
+ const blocks = Array.from(state.doc.body.querySelectorAll<HTMLElement>(CONVERTIBLE_BLOCK_SELECTOR))
206
351
  .filter(block => !block.closest('li') && range.intersectsNode(block))
207
- .filter(block => !blocksContainAncestor(block))
208
-
209
- function blocksContainAncestor(block: Element) {
210
- return block.parentElement?.closest(LIST_BLOCK_SELECTOR) !== null
211
- }
352
+ .filter(block => !block.parentElement?.closest(CONVERTIBLE_BLOCK_SELECTOR))
212
353
 
213
354
  if (!blocks.length) { return false }
214
355
 
215
356
  const list = state.doc.createElement(listTag)
216
- const direction = (blocks[0] as HTMLElement).dir
357
+ const direction = blocks[0].dir
217
358
  if (direction === 'rtl' || direction === 'ltr') { list.dir = direction }
218
359
 
219
360
  blocks[0].parentNode?.insertBefore(list, blocks[0])
220
361
  blocks.forEach((block) => {
221
362
  const item = state.doc!.createElement('li')
222
- const blockDirection = (block as HTMLElement).dir
363
+ const blockDirection = block.dir
223
364
  if (blockDirection === 'rtl' || blockDirection === 'ltr') { item.dir = blockDirection }
224
365
  while (block.firstChild) {
225
366
  item.appendChild(block.firstChild)
@@ -235,21 +376,136 @@ function convertSelectedBlocksToList(
235
376
  return true
236
377
  }
237
378
 
238
- // Centralized command creation helper
239
- function createCommand(name: string, execute: Command['execute'], isActive?: Command['isActive']): Command {
379
+ function toggleListCommand(state: EditorState, listTag: 'ol' | 'ul') {
380
+ const selectionInfo = getCurrentSelection(state)
381
+ if (!selectionInfo || !state.doc) { return }
382
+
383
+ const { doc } = state
384
+ const { range, selection } = selectionInfo
385
+ if (convertSelectedBlocksToList(state, selection, range, listTag)) { return }
386
+
387
+ const currentBlock = findBlockElement(range.commonAncestorContainer)
388
+ if (!currentBlock) { return }
389
+
390
+ const listItem = currentBlock.closest('li') as HTMLLIElement | null
391
+ if (listItem) {
392
+ const listParent = listItem.parentElement as HTMLElement | null
393
+ if (!listParent) { return }
394
+
395
+ if (listParent.tagName.toLowerCase() === listTag) {
396
+ // Toggle off this list item: if it's in the middle of a list, split the list around the new paragraph
397
+ const p = doc.createElement('p')
398
+ if (listItem.dir || listParent.dir) {
399
+ p.dir = listItem.dir || listParent.dir
400
+ }
401
+ while (listItem.firstChild) {
402
+ p.appendChild(listItem.firstChild)
403
+ }
404
+
405
+ const allItems = Array.from(listParent.children).filter(el => el.tagName === 'LI')
406
+ const itemIndex = allItems.indexOf(listItem)
407
+ const trailingItems = allItems.slice(itemIndex + 1)
408
+
409
+ if (itemIndex === 0) {
410
+ listParent.parentNode?.insertBefore(p, listParent)
411
+ listItem.remove()
412
+ if (!listParent.querySelector('li')) {
413
+ listParent.remove()
414
+ }
415
+ } else if (trailingItems.length === 0) {
416
+ listParent.insertAdjacentElement('afterend', p)
417
+ listItem.remove()
418
+ if (!listParent.querySelector('li')) {
419
+ listParent.remove()
420
+ }
421
+ } else {
422
+ // Split list into [beforeList, p, afterList]
423
+ const afterList = doc.createElement(listTag)
424
+ if (listParent.dir) { afterList.dir = listParent.dir }
425
+ trailingItems.forEach(item => afterList.appendChild(item))
426
+ listItem.remove()
427
+ listParent.insertAdjacentElement('afterend', p)
428
+ p.insertAdjacentElement('afterend', afterList)
429
+ }
430
+
431
+ const nextRange = doc.createRange()
432
+ nextRange.selectNodeContents(p)
433
+ nextRange.collapse(false)
434
+ selection.removeAllRanges()
435
+ selection.addRange(nextRange)
436
+ return
437
+ }
438
+
439
+ // Convert between ul and ol
440
+ const convertedList = doc.createElement(listTag)
441
+ if (listParent.dir) { convertedList.dir = listParent.dir }
442
+ const currentOffset = range.startOffset
443
+ const currentContainer = range.startContainer
444
+
445
+ while (listParent.firstChild) {
446
+ convertedList.appendChild(listParent.firstChild)
447
+ }
448
+ listParent.parentNode?.replaceChild(convertedList, listParent)
449
+
450
+ try {
451
+ const newRange = doc.createRange()
452
+ newRange.setStart(currentContainer, currentOffset)
453
+ newRange.collapse(true)
454
+ selection.removeAllRanges()
455
+ selection.addRange(newRange)
456
+ } catch {
457
+ const firstLi = convertedList.querySelector('li')
458
+ if (firstLi) {
459
+ const newRange = doc.createRange()
460
+ newRange.selectNodeContents(firstLi)
461
+ newRange.collapse(true)
462
+ selection.removeAllRanges()
463
+ selection.addRange(newRange)
464
+ }
465
+ }
466
+ return
467
+ }
468
+
469
+ // Convert single block to list
470
+ const listEl = doc.createElement(listTag)
471
+ const li = doc.createElement('li')
472
+ if (currentBlock.dir) {
473
+ listEl.dir = currentBlock.dir
474
+ li.dir = currentBlock.dir
475
+ }
476
+
477
+ while (currentBlock.firstChild) {
478
+ li.appendChild(currentBlock.firstChild)
479
+ }
480
+
481
+ listEl.appendChild(li)
482
+ currentBlock.parentNode?.replaceChild(listEl, currentBlock)
483
+
484
+ range.selectNodeContents(li)
485
+ range.collapse(false)
486
+ selection.removeAllRanges()
487
+ selection.addRange(range)
488
+ }
489
+
490
+ function createCommand(
491
+ name: string,
492
+ execute: Command['execute'],
493
+ isActive?: Command['isActive'],
494
+ options?: { skipHistory?: boolean }
495
+ ): Command {
240
496
  return {
241
497
  name,
242
498
  execute: (state: EditorState, value?: string) => {
243
499
  if (!state.doc) { return }
500
+ const preCommandContent = options?.skipHistory ? undefined : state.doc.body.innerHTML
244
501
  execute(state, value)
245
- updateStateAfterCommand(state)
502
+ updateStateAfterCommand(state, preCommandContent)
246
503
  },
247
504
  isActive
248
505
  }
249
506
  }
250
507
 
251
508
  export function createCommandRegistry(state: EditorState): CommandRegistry {
252
- // History commands
253
509
  const historyCommands = {
254
510
  undo: createCommand('Undo', () => {
255
511
  if (state.undoStack.length > 0 && state.doc) {
@@ -257,1084 +513,489 @@ export function createCommandRegistry(state: EditorState): CommandRegistry {
257
513
  if (lastContent !== undefined) {
258
514
  state.redoStack.push(state.content)
259
515
  state.content = lastContent
260
- state.doc.body.innerHTML = lastContent
516
+ restoreHistoryNodes(state.doc, lastContent)
261
517
  }
262
518
  }
263
- }),
519
+ }, undefined, { skipHistory: true }),
264
520
  redo: createCommand('Redo', () => {
265
521
  if (state.redoStack.length > 0 && state.doc) {
266
522
  const nextContent = state.redoStack.pop()
267
523
  if (nextContent !== undefined) {
268
524
  state.undoStack.push(state.content)
269
525
  state.content = nextContent
270
- state.doc.body.innerHTML = nextContent
526
+ restoreHistoryNodes(state.doc, nextContent)
271
527
  }
272
528
  }
273
- })
529
+ }, undefined, { skipHistory: true })
274
530
  }
275
531
 
276
- // Basic text formatting commands with toggle functionality
277
532
  const textCommands = {
278
- bold: createCommand('Bold', (state) => {
279
- const selectionInfo = getCurrentSelection(state)
280
- if (!selectionInfo) { return }
281
-
282
- const { range, selection } = selectionInfo
283
-
284
- // If no text is selected, try to apply formatting to word at cursor
285
- if (range.collapsed) {
286
- const success = applyFormattingToWordAtCursor(state.doc!, range, 'b')
287
- if (success) { return }
288
- return
289
- }
290
-
291
- // Check if the selection is already bold
292
- const commonAncestor = range.commonAncestorContainer
293
- const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
294
- ? commonAncestor.parentElement
295
- : commonAncestor as Element
296
-
297
- const boldElement = parentElement?.closest('b, strong')
298
-
299
- if (boldElement) {
300
- // Remove bold formatting
301
- try {
302
- const parent = boldElement.parentNode
303
- while (boldElement.firstChild) {
304
- parent?.insertBefore(boldElement.firstChild, boldElement)
305
- }
306
- parent?.removeChild(boldElement)
307
-
308
- // Restore selection
309
- selection.removeAllRanges()
310
- selection.addRange(range)
311
- } catch (error) {
312
- console.error('Error removing bold:', error)
313
- }
314
- } else {
315
- // Apply bold formatting
316
- try {
317
- const b = state.doc!.createElement('b')
318
-
319
- try {
320
- range.surroundContents(b)
321
- } catch {
322
- const fragment = range.extractContents()
323
- b.appendChild(fragment)
324
- range.insertNode(b)
325
- }
326
-
327
- range.selectNodeContents(b)
328
- selection.removeAllRanges()
329
- selection.addRange(range)
330
- } catch (error) {
331
- console.error('Error applying bold:', error)
332
- }
333
- }
334
- }, (state) => {
335
- // isActive function for bold
336
- const selectionInfo = getCurrentSelection(state)
337
- if (!selectionInfo) { return false }
338
-
339
- const commonAncestor = selectionInfo.range.commonAncestorContainer
340
- const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
341
- ? commonAncestor.parentElement
342
- : commonAncestor as Element
343
-
344
- return !!parentElement?.closest('b, strong')
345
- }),
346
-
347
- italic: createCommand('Italic', (state) => {
348
- const selectionInfo = getCurrentSelection(state)
349
- if (!selectionInfo) { return }
350
-
351
- const { range, selection } = selectionInfo
352
-
353
- // If no text is selected, try to apply formatting to word at cursor
354
- if (range.collapsed) {
355
- const success = applyFormattingToWordAtCursor(state.doc!, range, 'i')
356
- if (success) { return }
357
- return
358
- }
359
-
360
- // Check if the selection is already italic
361
- const commonAncestor = range.commonAncestorContainer
362
- const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
363
- ? commonAncestor.parentElement
364
- : commonAncestor as Element
365
-
366
- const italicElement = parentElement?.closest('i, em')
367
-
368
- if (italicElement) {
369
- // Remove italic formatting
370
- try {
371
- const parent = italicElement.parentNode
372
- while (italicElement.firstChild) {
373
- parent?.insertBefore(italicElement.firstChild, italicElement)
374
- }
375
- parent?.removeChild(italicElement)
376
-
377
- // Restore selection
378
- selection.removeAllRanges()
379
- selection.addRange(range)
380
- } catch (error) {
381
- console.error('Error removing italic:', error)
382
- }
383
- } else {
384
- // Apply italic formatting
385
- try {
386
- const i = state.doc!.createElement('i')
387
-
388
- try {
389
- range.surroundContents(i)
390
- } catch {
391
- const fragment = range.extractContents()
392
- i.appendChild(fragment)
393
- range.insertNode(i)
394
- }
395
-
396
- range.selectNodeContents(i)
397
- selection.removeAllRanges()
398
- selection.addRange(range)
399
- } catch (error) {
400
- console.error('Error applying italic:', error)
401
- }
402
- }
403
- }, (state) => {
404
- // isActive function for italic
405
- const selectionInfo = getCurrentSelection(state)
406
- if (!selectionInfo) { return false }
407
-
408
- const commonAncestor = selectionInfo.range.commonAncestorContainer
409
- const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
410
- ? commonAncestor.parentElement
411
- : commonAncestor as Element
412
-
413
- return !!parentElement?.closest('i, em')
414
- }),
415
-
416
- underline: createCommand('Underline', (state) => {
417
- const selectionInfo = getCurrentSelection(state)
418
- if (!selectionInfo) { return }
419
-
420
- const { range, selection } = selectionInfo
421
-
422
- // If no text is selected, try to apply formatting to word at cursor
423
- if (range.collapsed) {
424
- const success = applyFormattingToWordAtCursor(state.doc!, range, 'u')
425
- if (success) { return }
426
- return
427
- }
428
-
429
- // Check if the selection is already underlined
430
- const commonAncestor = range.commonAncestorContainer
431
- const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
432
- ? commonAncestor.parentElement
433
- : commonAncestor as Element
434
-
435
- const underlineElement = parentElement?.closest('u')
436
-
437
- if (underlineElement) {
438
- // Remove underline formatting
439
- try {
440
- const parent = underlineElement.parentNode
441
- while (underlineElement.firstChild) {
442
- parent?.insertBefore(underlineElement.firstChild, underlineElement)
443
- }
444
- parent?.removeChild(underlineElement)
445
-
446
- // Restore selection
447
- selection.removeAllRanges()
448
- selection.addRange(range)
449
- } catch (error) {
450
- console.error('Error removing underline:', error)
451
- }
452
- } else {
453
- // Apply underline formatting
454
- try {
455
- const u = state.doc!.createElement('u')
456
-
457
- try {
458
- range.surroundContents(u)
459
- } catch {
460
- const fragment = range.extractContents()
461
- u.appendChild(fragment)
462
- range.insertNode(u)
463
- }
464
-
465
- range.selectNodeContents(u)
466
- selection.removeAllRanges()
467
- selection.addRange(range)
468
- } catch (error) {
469
- console.error('Error applying underline:', error)
470
- }
471
- }
472
- }, (state) => {
473
- // isActive function for underline
474
- const selectionInfo = getCurrentSelection(state)
475
- if (!selectionInfo) { return false }
476
-
477
- const commonAncestor = selectionInfo.range.commonAncestorContainer
478
- const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
479
- ? commonAncestor.parentElement
480
- : commonAncestor as Element
481
-
482
- return !!parentElement?.closest('u')
483
- }),
484
-
485
- link: createCommand('Link', (state) => {
486
- const { openLinkModal } = state as any
533
+ bold: createCommand(
534
+ 'Bold',
535
+ s => toggleInlineFormatting(s, 'b', 'b, strong'),
536
+ s => Boolean(s.doc && isStyleActive('bold', s.doc))
537
+ ),
538
+ italic: createCommand(
539
+ 'Italic',
540
+ s => toggleInlineFormatting(s, 'i', 'i, em'),
541
+ s => Boolean(s.doc && isStyleActive('italic', s.doc))
542
+ ),
543
+ underline: createCommand(
544
+ 'Underline',
545
+ s => toggleInlineFormatting(s, 'u', 'u'),
546
+ s => Boolean(s.doc && isStyleActive('underline', s.doc))
547
+ ),
548
+ link: createCommand('Link', (s) => {
549
+ const { openLinkModal } = s as any
487
550
  if (openLinkModal) {
488
- insertLink(openLinkModal, state)
551
+ insertLink(openLinkModal, s)
489
552
  }
490
- }, (state) => {
491
- // isActive function for link
492
- const selectionInfo = getCurrentSelection(state)
493
- if (!selectionInfo) { return false }
494
-
495
- const commonAncestor = selectionInfo.range.commonAncestorContainer
496
- const parentElement = commonAncestor.nodeType === Node.TEXT_NODE
497
- ? commonAncestor.parentElement
498
- : commonAncestor as Element
499
-
500
- return !!parentElement?.closest('a')
501
- })
553
+ }, s => Boolean(s.doc && isStyleActive('link', s.doc)), { skipHistory: true })
502
554
  }
503
555
 
504
- // Heading commands
505
- const headingCommands = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].reduce((acc, cmd) => ({
556
+ const headingCommands = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].reduce<CommandRegistry>((acc, cmd) => ({
506
557
  ...acc,
507
- [cmd]: createCommand(`Heading ${cmd}`, (state) => {
508
- const selectionInfo = getCurrentSelection(state)
509
- if (!selectionInfo) { return }
558
+ [cmd]: createCommand(`Heading ${cmd}`, (s) => {
559
+ const selectionInfo = getCurrentSelection(s)
560
+ if (!selectionInfo || !s.doc) { return }
510
561
 
511
562
  const { range, selection } = selectionInfo
512
- const currentBlock = findBlockElement(range.commonAncestorContainer)
513
- if (!currentBlock) { return }
514
-
515
- // Store the original selection information before modification
516
- const { startContainer } = range
517
- const { endContainer } = range
518
- const { startOffset } = range
519
- const { endOffset } = range
520
- const isCollapsed = range.collapsed
521
-
522
- // Check if we need to toggle off (already in the same heading)
523
- const isToggleOff = currentBlock.tagName.toLowerCase() === cmd.toLowerCase()
524
- const newTag = isToggleOff ? 'p' : cmd
525
-
526
- try {
527
- const newBlock = state.doc!.createElement(newTag)
528
-
529
- // Copy content from current block to new block
530
- while (currentBlock.firstChild) {
531
- newBlock.appendChild(currentBlock.firstChild)
532
- }
563
+ const blocks = getSelectedBlocks(s.doc, range)
564
+ if (!blocks.length) { return }
533
565
 
534
- // Replace the old block with the new one
535
- currentBlock.parentNode?.replaceChild(newBlock, currentBlock)
566
+ const allMatch = blocks.every(b => b.tagName.toLowerCase() === cmd.toLowerCase())
567
+ const newTag = allMatch ? 'p' : cmd
536
568
 
537
- // Restore selection within the new block
538
- try {
539
- const newRange = state.doc!.createRange()
540
-
541
- // Find corresponding nodes in the new block
542
- const findCorrespondingNode = (originalNode: Node, originalBlock: Element, newBlock: Element): Node | null => {
543
- if (originalNode === originalBlock) { return newBlock }
544
-
545
- // If it's a text node, find it by traversing the tree
546
- if (originalNode.nodeType === Node.TEXT_NODE) {
547
- const walker = state.doc!.createTreeWalker(
548
- newBlock,
549
- NodeFilter.SHOW_TEXT,
550
- null
551
- )
552
-
553
- let textNodeIndex = 0
554
- const originalWalker = state.doc!.createTreeWalker(
555
- originalBlock,
556
- NodeFilter.SHOW_TEXT,
557
- null
558
- )
559
-
560
- let currentOriginalNode = originalWalker.nextNode()
561
- while (currentOriginalNode && currentOriginalNode !== originalNode) {
562
- textNodeIndex++
563
- currentOriginalNode = originalWalker.nextNode()
564
- }
569
+ const converted = blocks.map(block => convertBlockTag(s.doc!, block, newTag))
570
+ const lastConverted = converted[converted.length - 1]
565
571
 
566
- let currentNewNode = walker.nextNode()
567
- let currentIndex = 0
568
- while (currentNewNode && currentIndex < textNodeIndex) {
569
- currentIndex++
570
- currentNewNode = walker.nextNode()
571
- }
572
-
573
- return currentNewNode
574
- }
575
-
576
- return newBlock.firstChild
577
- }
578
-
579
- const newStartContainer = findCorrespondingNode(startContainer, currentBlock, newBlock)
580
- const newEndContainer = findCorrespondingNode(endContainer, currentBlock, newBlock)
581
-
582
- if (newStartContainer && newEndContainer) {
583
- // Set start position
584
- if (newStartContainer.nodeType === Node.TEXT_NODE) {
585
- const maxOffset = Math.min(startOffset, newStartContainer.textContent?.length || 0)
586
- newRange.setStart(newStartContainer, maxOffset)
587
- } else {
588
- newRange.setStart(newStartContainer, 0)
589
- }
590
-
591
- // Set end position
592
- if (!isCollapsed) {
593
- if (newEndContainer.nodeType === Node.TEXT_NODE) {
594
- const maxOffset = Math.min(endOffset, newEndContainer.textContent?.length || 0)
595
- newRange.setEnd(newEndContainer, maxOffset)
596
- } else {
597
- newRange.setEnd(newEndContainer, 0)
598
- }
599
- } else {
600
- newRange.collapse(true)
601
- }
602
-
603
- // Apply the restored selection
604
- selection.removeAllRanges()
605
- selection.addRange(newRange)
606
- } else {
607
- // Fallback: select all content in the new block
608
- newRange.selectNodeContents(newBlock)
609
- if (isCollapsed) {
610
- newRange.collapse(true)
611
- }
612
- selection.removeAllRanges()
613
- selection.addRange(newRange)
614
- }
615
- } catch (selectionError) {
616
- console.warn('Error restoring selection after heading change:', selectionError)
617
- // Fallback: place cursor at start of the new block
618
- const fallbackRange = state.doc!.createRange()
619
- fallbackRange.selectNodeContents(newBlock)
620
- fallbackRange.collapse(true)
621
- selection.removeAllRanges()
622
- selection.addRange(fallbackRange)
623
- }
572
+ if (!allMatch && lastConverted && !lastConverted.nextElementSibling) {
573
+ const p = s.doc.createElement('p')
574
+ p.dir = lastConverted.dir || s.doc.body.dir || 'ltr'
575
+ p.innerHTML = '<br>'
576
+ lastConverted.parentNode?.insertBefore(p, lastConverted.nextSibling)
577
+ }
624
578
 
625
- // If we created a heading (not toggling off), ensure there's a paragraph after it
626
- if (!isToggleOff) {
627
- const nextSibling = newBlock.nextElementSibling
628
- if (!nextSibling) {
629
- // No element after the heading, create a paragraph
630
- const p = state.doc!.createElement('p')
631
- p.dir = newBlock.dir || state.doc!.body.dir
632
- newBlock.parentNode?.insertBefore(p, newBlock.nextSibling)
633
- }
634
- }
635
- } catch (error) {
636
- console.error(`Error applying ${cmd} heading:`, error)
579
+ if (lastConverted) {
580
+ const nextRange = s.doc.createRange()
581
+ nextRange.selectNodeContents(lastConverted)
582
+ nextRange.collapse(false)
583
+ selection.removeAllRanges()
584
+ selection.addRange(nextRange)
637
585
  }
638
- }),
586
+ }, s => Boolean(s.doc && isStyleActive(cmd, s.doc)))
639
587
  }), {})
640
588
 
641
- // Block commands
642
- const blockCommands = {
643
- p: createCommand('Paragraph', (state) => {
644
- const selectionInfo = getCurrentSelection(state)
645
- if (!selectionInfo) { return }
646
-
647
- const { range } = selectionInfo
648
- const currentBlock = findBlockElement(range.commonAncestorContainer)
649
- if (!currentBlock) { return }
589
+ const blockCommands: CommandRegistry = {
590
+ p: createCommand('Paragraph', (s) => {
591
+ const selectionInfo = getCurrentSelection(s)
592
+ if (!selectionInfo || !s.doc) { return }
650
593
 
651
- // Check if already a paragraph - if so, nothing to do
652
- if (currentBlock.tagName.toLowerCase() === 'p') {
653
- return
594
+ const { range, selection } = selectionInfo
595
+ const blocks = getSelectedBlocks(s.doc, range)
596
+ if (!blocks.length) { return }
597
+
598
+ const converted = blocks.map(block =>
599
+ block.tagName.toLowerCase() === 'p' ? block : convertBlockTag(s.doc!, block, 'p')
600
+ )
601
+ const lastBlock = converted[converted.length - 1]
602
+ if (lastBlock) {
603
+ const nextRange = s.doc.createRange()
604
+ nextRange.selectNodeContents(lastBlock)
605
+ nextRange.collapse(false)
606
+ selection.removeAllRanges()
607
+ selection.addRange(nextRange)
654
608
  }
609
+ }, s => Boolean(s.doc && isStyleActive('p', s.doc))),
655
610
 
656
- try {
657
- const newParagraph = state.doc!.createElement('p')
611
+ blockquote: createCommand('Blockquote', (s) => {
612
+ const selectionInfo = getCurrentSelection(s)
613
+ if (!selectionInfo || !s.doc) { return }
658
614
 
659
- // Copy content from current block to paragraph
660
- while (currentBlock.firstChild) {
661
- newParagraph.appendChild(currentBlock.firstChild)
662
- }
663
-
664
- // Replace the old block with the paragraph
665
- currentBlock.parentNode?.replaceChild(newParagraph, currentBlock)
666
-
667
- // Move cursor into the new paragraph
668
- range.selectNodeContents(newParagraph)
669
- range.collapse(false)
670
- selectionInfo.selection.removeAllRanges()
671
- selectionInfo.selection.addRange(range)
672
- } catch (error) {
673
- console.error('Error applying paragraph:', error)
615
+ const { range, selection } = selectionInfo
616
+ const blocks = getSelectedBlocks(s.doc, range)
617
+ if (!blocks.length) { return }
618
+
619
+ const allBlockquotes = blocks.every(b => b.tagName.toLowerCase() === 'blockquote')
620
+ const newTag = allBlockquotes ? 'p' : 'blockquote'
621
+
622
+ const converted = blocks.map(block => convertBlockTag(s.doc!, block, newTag))
623
+ const lastBlock = converted[converted.length - 1]
624
+ if (lastBlock) {
625
+ const nextRange = s.doc.createRange()
626
+ nextRange.selectNodeContents(lastBlock)
627
+ nextRange.collapse(false)
628
+ selection.removeAllRanges()
629
+ selection.addRange(nextRange)
674
630
  }
675
- }),
631
+ }, s => Boolean(s.doc && isStyleActive('blockquote', s.doc))),
676
632
 
677
- blockquote: createCommand('Blockquote', (state) => {
678
- const selectionInfo = getCurrentSelection(state)
633
+ indent: createCommand('Indent', (s) => {
634
+ const selectionInfo = getCurrentSelection(s)
679
635
  if (!selectionInfo) { return }
636
+ const block = findBlockElement(selectionInfo.range.commonAncestorContainer)
637
+ if (!block) { return }
638
+ const isRTL = (block.dir || s.doc?.body.dir) === 'rtl'
639
+ const prop = isRTL ? 'marginRight' : 'marginLeft'
640
+ const current = Number.parseInt(block.style[prop] || '0', 10) || 0
641
+ block.style[prop] = `${current + 24}px`
642
+ }),
680
643
 
681
- const { range } = selectionInfo
682
- const currentBlock = findBlockElement(range.commonAncestorContainer)
683
- if (!currentBlock) { return }
684
-
685
- // Check if already a blockquote - if so, toggle to paragraph
686
- const isToggleOff = currentBlock.tagName.toLowerCase() === 'blockquote'
687
- const newTag = isToggleOff ? 'p' : 'blockquote'
688
-
689
- try {
690
- const newBlock = state.doc!.createElement(newTag)
691
-
692
- // Copy content from current block to new block
693
- while (currentBlock.firstChild) {
694
- newBlock.appendChild(currentBlock.firstChild)
695
- }
696
-
697
- // Replace the old block with the new one
698
- currentBlock.parentNode?.replaceChild(newBlock, currentBlock)
699
-
700
- // Move cursor into the new block
701
- range.selectNodeContents(newBlock)
702
- range.collapse(false)
703
- selectionInfo.selection.removeAllRanges()
704
- selectionInfo.selection.addRange(range)
705
- } catch (error) {
706
- console.error('Error applying blockquote:', error)
707
- }
644
+ outdent: createCommand('Outdent', (s) => {
645
+ const selectionInfo = getCurrentSelection(s)
646
+ if (!selectionInfo) { return }
647
+ const block = findBlockElement(selectionInfo.range.commonAncestorContainer)
648
+ if (!block) { return }
649
+ const isRTL = (block.dir || s.doc?.body.dir) === 'rtl'
650
+ const prop = isRTL ? 'marginRight' : 'marginLeft'
651
+ const current = Number.parseInt(block.style[prop] || '0', 10) || 0
652
+ const next = Math.max(0, current - 24)
653
+ block.style[prop] = next > 0 ? `${next}px` : ''
708
654
  })
709
655
  }
710
656
 
711
- // List commands - simplified implementation
712
- const listCommands = {
713
- orderedList: createCommand('Ordered List', (state) => {
714
- const selectionInfo = getCurrentSelection(state)
715
- if (!selectionInfo) { return }
716
-
717
- const { range } = selectionInfo
718
- if (convertSelectedBlocksToList(state, selectionInfo.selection, range, 'ol')) { return }
719
- const currentBlock = findBlockElement(range.commonAncestorContainer)
720
- if (!currentBlock) { return }
721
-
722
- // Check if we're inside a list item
723
- const listItem = currentBlock.closest('li')
724
- if (listItem) {
725
- const listParent = listItem.parentElement as HTMLElement
726
- if (!listParent) { return }
727
-
728
- // If it's already an ordered list, toggle off to paragraph
729
- if (listParent.tagName.toLowerCase() === 'ol') {
730
- const p = state.doc!.createElement('p')
731
- while (listItem.firstChild) {
732
- p.appendChild(listItem.firstChild)
733
- }
734
- listParent.parentNode?.insertBefore(p, listParent)
735
- listItem.remove()
736
-
737
- if (!listParent.querySelector('li')) {
738
- listParent.remove()
739
- }
740
-
741
- range.selectNodeContents(p)
742
- range.collapse(false)
743
- selectionInfo.selection.removeAllRanges()
744
- selectionInfo.selection.addRange(range)
745
- return
746
- }
657
+ const listCommands: CommandRegistry = {
658
+ orderedList: createCommand(
659
+ 'Ordered List',
660
+ s => toggleListCommand(s, 'ol'),
661
+ s => Boolean(s.doc && isStyleActive('orderedList', s.doc))
662
+ ),
663
+ unorderedList: createCommand(
664
+ 'Unordered List',
665
+ s => toggleListCommand(s, 'ul'),
666
+ s => Boolean(s.doc && isStyleActive('unorderedList', s.doc))
667
+ )
668
+ }
747
669
 
748
- // Convert unordered list to ordered list
749
- if (listParent.tagName.toLowerCase() === 'ul') {
750
- const ol = state.doc!.createElement('ol')
670
+ const formatCommands: CommandRegistry = {
671
+ clear: createCommand('Clear Formatting', (s) => {
672
+ const selectionInfo = getCurrentSelection(s)
673
+ if (!selectionInfo || !s.doc) { return }
751
674
 
752
- // Store current cursor position before conversion
753
- const currentOffset = range.startOffset
754
- const currentContainer = range.startContainer
675
+ const { doc } = s
676
+ const { range, selection } = selectionInfo
755
677
 
756
- while (listParent.firstChild) {
757
- ol.appendChild(listParent.firstChild)
758
- }
759
- listParent.parentNode?.replaceChild(ol, listParent)
760
-
761
- // Restore cursor position
762
- try {
763
- const newRange = state.doc!.createRange()
764
- newRange.setStart(currentContainer, currentOffset)
765
- newRange.collapse(true)
766
- selectionInfo.selection.removeAllRanges()
767
- selectionInfo.selection.addRange(newRange)
768
- } catch {
769
- // Fallback: put cursor at beginning of first list item
770
- const firstLi = ol.querySelector('li')
771
- if (firstLi) {
772
- const newRange = state.doc!.createRange()
773
- newRange.selectNodeContents(firstLi)
774
- newRange.collapse(true)
775
- selectionInfo.selection.removeAllRanges()
776
- selectionInfo.selection.addRange(newRange)
777
- }
778
- }
779
- return
780
- }
678
+ const formattingTags = new Set([
679
+ 'b', 'i', 'u', 'strong', 'em', 'span', 'font', 'strike', 's', 'sub', 'sup', 'mark', 'del', 'ins', 'small', 'big'
680
+ ])
681
+ const structuralTags = new Set([
682
+ 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'div',
683
+ 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'colgroup', 'col',
684
+ 'a', 'img', 'iframe', 'video', 'audio', 'figure', 'figcaption', 'br'
685
+ ])
686
+
687
+ function cleanText(text: string): string {
688
+ return text
689
+ .replace(/[\u200B-\u200D\uFEFF]/g, '')
690
+ .replace(/\u00A0/g, ' ')
781
691
  }
782
692
 
783
- // Convert current block to ordered list
784
- try {
785
- const ol = state.doc!.createElement('ol')
786
- const li = state.doc!.createElement('li')
787
-
788
- // Copy content to list item
789
- while (currentBlock.firstChild) {
790
- li.appendChild(currentBlock.firstChild)
693
+ function cleanNode(node: Node): Node {
694
+ if (node.nodeType === Node.TEXT_NODE) {
695
+ return doc!.createTextNode(cleanText(node.textContent || ''))
791
696
  }
792
697
 
793
- ol.appendChild(li)
794
- currentBlock.parentNode?.replaceChild(ol, currentBlock)
795
-
796
- // Move cursor into the list item
797
- range.selectNodeContents(li)
798
- range.collapse(false)
799
- selectionInfo.selection.removeAllRanges()
800
- selectionInfo.selection.addRange(range)
801
- } catch (error) {
802
- console.error('Error creating ordered list:', error)
803
- }
804
- }),
805
-
806
- unorderedList: createCommand('Unordered List', (state) => {
807
- const selectionInfo = getCurrentSelection(state)
808
- if (!selectionInfo) { return }
809
-
810
- const { range } = selectionInfo
811
- if (convertSelectedBlocksToList(state, selectionInfo.selection, range, 'ul')) { return }
812
- const currentBlock = findBlockElement(range.commonAncestorContainer)
813
- if (!currentBlock) { return }
814
-
815
- // Check if we're inside a list item
816
- const listItem = currentBlock.closest('li')
817
- if (listItem) {
818
- const listParent = listItem.parentElement as HTMLElement
819
- if (!listParent) { return }
820
-
821
- // If it's already an unordered list, toggle off to paragraph
822
- if (listParent.tagName.toLowerCase() === 'ul') {
823
- const p = state.doc!.createElement('p')
824
- while (listItem.firstChild) {
825
- p.appendChild(listItem.firstChild)
826
- }
827
- listParent.parentNode?.insertBefore(p, listParent)
828
- listItem.remove()
698
+ if (node.nodeType === Node.ELEMENT_NODE) {
699
+ const element = node as HTMLElement
700
+ const tagName = element.tagName.toLowerCase()
829
701
 
830
- if (!listParent.querySelector('li')) {
831
- listParent.remove()
702
+ if (formattingTags.has(tagName)) {
703
+ const frag = doc!.createDocumentFragment()
704
+ Array.from(element.childNodes).forEach((child) => {
705
+ frag.appendChild(cleanNode(child))
706
+ })
707
+ return frag
832
708
  }
833
709
 
834
- range.selectNodeContents(p)
835
- range.collapse(false)
836
- selectionInfo.selection.removeAllRanges()
837
- selectionInfo.selection.addRange(range)
838
- return
839
- }
840
-
841
- // Convert ordered list to unordered list
842
- if (listParent.tagName.toLowerCase() === 'ol') {
843
- const ul = state.doc!.createElement('ul')
844
-
845
- // Store current cursor position before conversion
846
- const currentOffset = range.startOffset
847
- const currentContainer = range.startContainer
848
-
849
- while (listParent.firstChild) {
850
- ul.appendChild(listParent.firstChild)
851
- }
852
- listParent.parentNode?.replaceChild(ul, listParent)
853
-
854
- // Restore cursor position
855
- try {
856
- const newRange = state.doc!.createRange()
857
- newRange.setStart(currentContainer, currentOffset)
858
- newRange.collapse(true)
859
- selectionInfo.selection.removeAllRanges()
860
- selectionInfo.selection.addRange(newRange)
861
- } catch (e) {
862
- // Fallback: put cursor at beginning of first list item
863
- console.warn('Could not restore cursor position:', e)
864
- const firstLi = ul.querySelector('li')
865
- if (firstLi) {
866
- const newRange = state.doc!.createRange()
867
- newRange.selectNodeContents(firstLi)
868
- newRange.collapse(true)
869
- selectionInfo.selection.removeAllRanges()
870
- selectionInfo.selection.addRange(newRange)
710
+ if (structuralTags.has(tagName)) {
711
+ // Convert headings/blockquotes inside cleared region to plain <p>
712
+ const targetTag = /^h[1-6]|blockquote$/.test(tagName) ? 'p' : tagName
713
+ const cleanElement = doc!.createElement(targetTag)
714
+ if (element.dir) {
715
+ cleanElement.dir = element.dir
871
716
  }
872
- }
873
- return
874
- }
875
- }
876
-
877
- // Convert current block to unordered list
878
- try {
879
- const ul = state.doc!.createElement('ul')
880
- const li = state.doc!.createElement('li')
881
-
882
- // Copy content to list item
883
- while (currentBlock.firstChild) {
884
- li.appendChild(currentBlock.firstChild)
885
- }
886
-
887
- ul.appendChild(li)
888
- currentBlock.parentNode?.replaceChild(ul, currentBlock)
889
-
890
- // Move cursor into the list item
891
- range.selectNodeContents(li)
892
- range.collapse(false)
893
- selectionInfo.selection.removeAllRanges()
894
- selectionInfo.selection.addRange(range)
895
- } catch (error) {
896
- console.error('Error creating unordered list:', error)
897
- }
898
- })
899
- }
900
-
901
- // Clear formatting command - enhanced
902
- const formatCommands = {
903
- clear: createCommand('Clear Formatting', (state) => {
904
- const selectionInfo = getCurrentSelection(state)
905
- if (!selectionInfo) { return }
906
-
907
- const { range, selection } = selectionInfo
908
-
909
- try {
910
- // Function to clean text from unwanted characters
911
- function cleanText(text: string): string {
912
- return text
913
- // Remove zero-width characters
914
- .replace(/[\u200B-\u200D\uFEFF]/g, '')
915
- // Remove extra whitespace
916
- .replace(/\s+/g, ' ')
917
- // Remove invisible characters
918
- .replace(/\u00A0/g, ' ') // non-breaking space to regular space
919
- .trim()
920
- }
921
-
922
- // Function to clean a node recursively
923
- function cleanNode(node: Node): Node {
924
- if (node.nodeType === Node.TEXT_NODE) {
925
- const cleanedText = cleanText(node.textContent || '')
926
- return state.doc!.createTextNode(cleanedText)
927
- }
928
717
 
929
- if (node.nodeType === Node.ELEMENT_NODE) {
930
- const element = node as Element
931
- const tagName = element.tagName.toLowerCase()
932
-
933
- // Preserve structural elements, links, and media
934
- const structuralTags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'div', 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'b', 'i', 'u', 'a', 'img', 'iframe', 'video', 'audio', 'figure', 'figcaption']
935
- // Remove formatting elements (excluding basic HTML formatting tags)
936
- const formattingTags = ['strong', 'em', 'span', 'font', 'strike', 'sub', 'sup', 'mark', 'del', 'ins', 'small', 'big']
937
-
938
- if (formattingTags.includes(tagName)) {
939
- // For formatting elements, just return the cleaned text content
940
- const cleanedText = cleanText(element.textContent || '')
941
- return state.doc!.createTextNode(cleanedText)
942
- } if (structuralTags.includes(tagName)) {
943
- // For structural elements, keep the element but remove all styling attributes
944
- const cleanElement = state.doc!.createElement(tagName)
945
-
946
- // Only preserve essential attributes
947
- if (tagName === 'a' && element.hasAttribute('href')) {
948
- cleanElement.setAttribute('href', element.getAttribute('href') || '')
949
- if (element.hasAttribute('target')) {
950
- cleanElement.setAttribute('target', element.getAttribute('target') || '')
718
+ if (tagName === 'a' && element.hasAttribute('href')) {
719
+ cleanElement.setAttribute('href', element.getAttribute('href') || '')
720
+ if (element.hasAttribute('target')) {
721
+ cleanElement.setAttribute('target', element.getAttribute('target') || '')
722
+ }
723
+ if (element.hasAttribute('rel')) {
724
+ cleanElement.setAttribute('rel', element.getAttribute('rel') || '')
725
+ }
726
+ } else if (tagName === 'img') {
727
+ for (const attr of ['src', 'alt', 'width', 'height']) {
728
+ if (element.hasAttribute(attr)) {
729
+ cleanElement.setAttribute(attr, element.getAttribute(attr) || '')
951
730
  }
952
- if (element.hasAttribute('rel')) {
953
- cleanElement.setAttribute('rel', element.getAttribute('rel') || '')
731
+ }
732
+ } else if (tagName === 'iframe') {
733
+ for (const attr of ['src', 'width', 'height', 'frameborder', 'allowfullscreen']) {
734
+ if (element.hasAttribute(attr)) {
735
+ cleanElement.setAttribute(attr, element.getAttribute(attr) || '')
954
736
  }
955
- } else if (tagName === 'img') {
956
- if (element.hasAttribute('src')) cleanElement.setAttribute('src', element.getAttribute('src') || '')
957
- if (element.hasAttribute('alt')) cleanElement.setAttribute('alt', element.getAttribute('alt') || '')
958
- if (element.hasAttribute('width')) cleanElement.setAttribute('width', element.getAttribute('width') || '')
959
- if (element.hasAttribute('height')) cleanElement.setAttribute('height', element.getAttribute('height') || '')
960
- } else if (tagName === 'iframe') {
961
- if (element.hasAttribute('src')) cleanElement.setAttribute('src', element.getAttribute('src') || '')
962
- if (element.hasAttribute('width')) cleanElement.setAttribute('width', element.getAttribute('width') || '')
963
- if (element.hasAttribute('height')) cleanElement.setAttribute('height', element.getAttribute('height') || '')
964
- if (element.hasAttribute('frameborder')) cleanElement.setAttribute('frameborder', element.getAttribute('frameborder') || '')
965
- if (element.hasAttribute('allowfullscreen')) cleanElement.setAttribute('allowfullscreen', element.getAttribute('allowfullscreen') || '')
966
- } else if (tagName === 'video' || tagName === 'audio') {
967
- if (element.hasAttribute('src')) cleanElement.setAttribute('src', element.getAttribute('src') || '')
968
- if (element.hasAttribute('controls')) cleanElement.setAttribute('controls', element.getAttribute('controls') || '')
969
- if (element.hasAttribute('width')) cleanElement.setAttribute('width', element.getAttribute('width') || '')
970
- if (element.hasAttribute('height')) cleanElement.setAttribute('height', element.getAttribute('height') || '')
971
737
  }
972
-
973
- // Clean and append child nodes
974
- Array.from(element.childNodes).forEach((child) => {
975
- const cleanedChild = cleanNode(child)
976
- if (cleanedChild.textContent?.trim() || cleanedChild.nodeType === Node.ELEMENT_NODE) {
977
- cleanElement.appendChild(cleanedChild)
738
+ } else if (tagName === 'video' || tagName === 'audio') {
739
+ for (const attr of ['src', 'controls', 'width', 'height']) {
740
+ if (element.hasAttribute(attr)) {
741
+ cleanElement.setAttribute(attr, element.getAttribute(attr) || '')
978
742
  }
979
- })
980
-
981
- return cleanElement
743
+ }
982
744
  }
983
- }
984
745
 
985
- return state.doc!.createTextNode('')
986
- }
746
+ Array.from(element.childNodes).forEach((child) => {
747
+ cleanElement.appendChild(cleanNode(child))
748
+ })
987
749
 
988
- if (range.collapsed) {
989
- // If no selection, clean the entire document
990
- const { body } = (state.doc!)
991
- const cleanedFragment = state.doc!.createDocumentFragment()
992
-
993
- Array.from(body.childNodes).forEach((node) => {
994
- const cleanedNode = cleanNode(node)
995
- if (cleanedNode.textContent?.trim() || cleanedNode.nodeType === Node.ELEMENT_NODE) {
996
- cleanedFragment.appendChild(cleanedNode)
997
- }
998
- })
999
-
1000
- // Clear body and append cleaned content
1001
- body.innerHTML = ''
1002
- body.appendChild(cleanedFragment)
1003
-
1004
- // If body is empty, add a paragraph
1005
- if (!body.firstElementChild) {
1006
- const p = state.doc!.createElement('p')
1007
- body.appendChild(p)
1008
-
1009
- // Set cursor in the paragraph
1010
- const newRange = state.doc!.createRange()
1011
- newRange.selectNodeContents(p)
1012
- newRange.collapse(true)
1013
- selection.removeAllRanges()
1014
- selection.addRange(newRange)
750
+ return cleanElement
1015
751
  }
1016
- } else {
1017
- // If there's a selection, clean only the selected content
1018
- const fragment = range.cloneContents()
1019
- const tempDiv = state.doc!.createElement('div')
1020
- tempDiv.appendChild(fragment)
1021
-
1022
- // Clean all nodes in the temp div
1023
- const cleanedFragment = state.doc!.createDocumentFragment()
1024
- Array.from(tempDiv.childNodes).forEach((node) => {
1025
- const cleanedNode = cleanNode(node)
1026
- if (cleanedNode.textContent?.trim() || cleanedNode.nodeType === Node.ELEMENT_NODE) {
1027
- cleanedFragment.appendChild(cleanedNode)
1028
- }
1029
- })
752
+ }
1030
753
 
1031
- // Replace the selection with cleaned content
1032
- range.deleteContents()
1033
- range.insertNode(cleanedFragment)
754
+ return doc!.createTextNode('')
755
+ }
1034
756
 
1035
- // Restore selection
1036
- range.collapse(false)
757
+ if (range.collapsed) {
758
+ // Clear formatting only on the current block or inline ancestors (never wipe the whole document)
759
+ const currentBlock = findBlockElement(range.commonAncestorContainer)
760
+ if (!currentBlock || currentBlock === doc.body) { return }
761
+ const cleanedBlock = cleanNode(currentBlock) as HTMLElement
762
+ if (cleanedBlock && cleanedBlock.nodeType === Node.ELEMENT_NODE) {
763
+ currentBlock.replaceWith(cleanedBlock)
764
+ const newRange = doc.createRange()
765
+ newRange.selectNodeContents(cleanedBlock)
766
+ newRange.collapse(false)
1037
767
  selection.removeAllRanges()
1038
- selection.addRange(range)
768
+ selection.addRange(newRange)
769
+ }
770
+ return
771
+ }
772
+
773
+ // If selection is inside an inline formatting ancestor, unwrap it first
774
+ const startEl = range.startContainer.nodeType === Node.TEXT_NODE
775
+ ? range.startContainer.parentElement
776
+ : range.startContainer as HTMLElement | null
777
+ const inlineAncestor = startEl?.closest('b, i, u, strong, em, span, font, strike, s, sub, sup, mark')
778
+ if (inlineAncestor && inlineAncestor.contains(range.endContainer)) {
779
+ const parent = inlineAncestor.parentNode
780
+ while (inlineAncestor.firstChild) {
781
+ parent?.insertBefore(inlineAncestor.firstChild, inlineAncestor)
1039
782
  }
1040
- } catch (error) {
1041
- console.error('Error clearing formatting:', error)
783
+ inlineAncestor.remove()
1042
784
  }
785
+
786
+ const fragment = range.extractContents()
787
+ const cleanedFragment = doc.createDocumentFragment()
788
+ Array.from(fragment.childNodes).forEach((node) => {
789
+ cleanedFragment.appendChild(cleanNode(node))
790
+ })
791
+
792
+ range.insertNode(cleanedFragment)
793
+ range.collapse(false)
794
+ selection.removeAllRanges()
795
+ selection.addRange(range)
1043
796
  })
1044
797
  }
1045
798
 
1046
- // Table commands
1047
- const tableCommands = {
1048
- insertTable: createCommand('Insert Table', (state, value) => {
1049
- console.log('insertTable command called with value:', value)
1050
-
1051
- // If we have an openTableEditor function in state, use it for new tables
1052
- if ((state as any).openTableEditor && !value) {
1053
- console.log('Opening table editor modal for new table')
1054
- ; (state as any).openTableEditor(null)
799
+ const tableCommands: CommandRegistry = {
800
+ insertTable: createCommand('Insert Table', (s, value) => {
801
+ if ((s as any).openTableEditor && !value) {
802
+ ;(s as any).openTableEditor(null)
1055
803
  return
1056
804
  }
1057
805
 
1058
806
  if (!value) {
1059
- // Default fallback
1060
- const [rows, cols] = [3, 3]
1061
- insertTable(rows, cols, state)
807
+ insertTable(3, 3, s)
1062
808
  return
1063
809
  }
1064
810
 
1065
- // Check if value is HTML string (from our advanced table editor)
1066
811
  if (value.includes('<table')) {
1067
- console.log('Inserting HTML table')
1068
- // Insert HTML table directly
1069
- const selectionInfo = getCurrentSelection(state)
1070
- if (!selectionInfo || !state.doc) {
1071
- console.error('No selection info or document')
1072
- return
1073
- }
1074
-
1075
- const { range } = selectionInfo
812
+ const selectionInfo = getCurrentSelection(s)
813
+ if (!s.doc) { return }
1076
814
 
1077
815
  try {
1078
- // Create a temporary div to parse the HTML
1079
- const tempDiv = state.doc.createElement('div')
1080
- tempDiv.innerHTML = value
1081
- const table = tempDiv.querySelector('table')
1082
-
1083
- if (table) {
1084
- console.log('Table parsed successfully, inserting...')
1085
- // Insert the table at the current selection
1086
- const insertedTable = table.cloneNode(true) as HTMLTableElement
1087
- range.insertNode(insertedTable)
1088
-
1089
- // Move cursor after the table
1090
- range.setStartAfter(insertedTable)
1091
- range.collapse(true)
1092
-
1093
- const selection = state.doc.getSelection()
1094
- if (selection) {
1095
- selection.removeAllRanges()
1096
- selection.addRange(range)
816
+ const parsedDoc = new DOMParser().parseFromString(value, 'text/html')
817
+ const table = parsedDoc.querySelector('table')
818
+ if (!table) { return }
819
+
820
+ const insertedTable = s.doc.importNode(table, true) as HTMLTableElement
821
+ insertedTable.dir = s.doc.body.dir || 'ltr'
822
+ if (!insertedTable.style.tableLayout) {
823
+ insertedTable.style.tableLayout = 'fixed'
824
+ }
825
+
826
+ const activeRange = selectionInfo?.range ?? s.range
827
+ if (activeRange) {
828
+ const hostBlock = (activeRange.startContainer.nodeType === Node.ELEMENT_NODE
829
+ ? activeRange.startContainer as Element
830
+ : activeRange.startContainer.parentElement)?.closest('p,h1,h2,h3,h4,h5,h6')
831
+ if (hostBlock && hostBlock.parentElement === s.doc.body && !(hostBlock.textContent || '').trim()) {
832
+ hostBlock.replaceWith(insertedTable)
833
+ } else if (hostBlock && hostBlock.parentElement === s.doc.body) {
834
+ hostBlock.insertAdjacentElement('afterend', insertedTable)
835
+ } else {
836
+ activeRange.insertNode(insertedTable)
1097
837
  }
1098
- console.log('Table inserted successfully')
1099
838
  } else {
1100
- console.error('Could not parse table from HTML')
839
+ s.doc.body.appendChild(insertedTable)
840
+ }
841
+
842
+ if (!insertedTable.nextElementSibling) {
843
+ const trailingP = s.doc.createElement('p')
844
+ trailingP.dir = s.doc.body.dir || 'ltr'
845
+ trailingP.innerHTML = '<br>'
846
+ insertedTable.insertAdjacentElement('afterend', trailingP)
847
+ }
848
+
849
+ const addButtons = (s.doc as any).__addEditButtonsToTables as (() => void) | undefined
850
+ addButtons?.()
851
+
852
+ const firstCell = insertedTable.querySelector('tbody td, td, th') as HTMLTableCellElement | null
853
+ if (firstCell) {
854
+ focusTableCell(firstCell, true)
1101
855
  }
1102
856
  } catch (error) {
1103
857
  console.error('Error inserting HTML table:', error)
1104
858
  }
1105
859
  } else {
1106
- // Original grid format (e.g., "3x3")
1107
860
  const [rows, cols] = value.split('x').map(Number) || [3, 3]
1108
- insertTable(rows, cols, state)
861
+ insertTable(rows || 3, cols || 3, s)
1109
862
  }
1110
863
  }),
1111
- deleteTable: createCommand('Delete Table', state => state.range && deleteTable(state.range)),
1112
- mergeCells: createCommand('Merge Cells', state => state.range && state.doc && mergeCells(state.range, state.doc)),
1113
- splitCells: createCommand('Split Cells', state => state.range && state.doc && splitCell(state.range, state.doc)),
1114
- addRowBefore: createCommand('Add Row Before', state => state.range && state.doc && addRow('before', state.range, state.doc)),
1115
- addRowAfter: createCommand('Add Row After', state => state.range && state.doc && addRow('after', state.range, state.doc)),
1116
- deleteRow: createCommand('Delete Row', state => state.range && deleteRow(state.range)),
1117
- insertColumnLeft: createCommand('Insert Column Left', state => state.range && insertColumn('before', state.range)),
1118
- insertColumnRight: createCommand('Insert Column Right', state => state.range && insertColumn('after', state.range)),
1119
- deleteColumn: createCommand('Delete Column', state => state.range && deleteColumn(state.range))
864
+ deleteTable: createCommand('Delete Table', s => deleteTable(s.range)),
865
+ mergeCells: createCommand('Merge Cells', s => s.doc && mergeCells(s.range, s.doc)),
866
+ splitCells: createCommand('Split Cells', s => s.doc && splitCell(s.range, s.doc)),
867
+ addRowBefore: createCommand('Add Row Before', s => s.doc && addRow('before', s.range, s.doc)),
868
+ addRowAfter: createCommand('Add Row After', s => s.doc && addRow('after', s.range, s.doc)),
869
+ deleteRow: createCommand('Delete Row', s => deleteRow(s.range)),
870
+ insertColumnLeft: createCommand('Insert Column Left', s => insertColumn('before', s.range)),
871
+ insertColumnRight: createCommand('Insert Column Right', s => insertColumn('after', s.range)),
872
+ deleteColumn: createCommand('Delete Column', s => deleteColumn(s.range))
1120
873
  }
1121
874
 
1122
- // Table alignment commands
1123
- const alignmentCommands = ['Left', 'Center', 'Right', 'Justify'].reduce((acc, align) => ({
875
+ const alignmentCommands = ['Left', 'Center', 'Right', 'Justify'].reduce<CommandRegistry>((acc, align) => ({
1124
876
  ...acc,
1125
- [`tableAlign${align}`]: createCommand(`Table Align ${align}`, (state) => {
1126
- if (state.range) {
1127
- const alignment = align === 'Left' ? 'start' : align === 'Right' ? 'end' : align.toLowerCase() as 'center' | 'justify'
1128
- alignColumn(state.range, alignment)
1129
- }
877
+ [`tableAlign${align}`]: createCommand(`Table Align ${align}`, (s) => {
878
+ const alignment = align === 'Left' ? 'start' : align === 'Right' ? 'end' : align.toLowerCase() as 'center' | 'justify'
879
+ alignColumn(s.range, alignment)
1130
880
  })
1131
881
  }), {})
1132
882
 
1133
- // Text alignment commands (for paragraphs)
1134
- const textAlignmentCommands = {
1135
- alignLeft: createCommand('Align Left', (state) => {
1136
- const selectionInfo = getCurrentSelection(state)
1137
- if (!selectionInfo) { return }
1138
-
1139
- const { range } = selectionInfo
1140
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1141
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1142
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
1143
-
1144
- if (paragraph) {
1145
- (paragraph as HTMLElement).style.textAlign = 'left'
1146
- }
1147
- }, (state) => {
1148
- const selectionInfo = getCurrentSelection(state)
1149
- if (!selectionInfo) { return false }
1150
-
1151
- const { range } = selectionInfo
1152
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1153
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1154
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
1155
-
1156
- return (paragraph as HTMLElement)?.style.textAlign === 'left'
1157
- }),
1158
-
1159
- alignCenter: createCommand('Align Center', (state) => {
1160
- const selectionInfo = getCurrentSelection(state)
1161
- if (!selectionInfo) { return }
1162
-
1163
- const { range } = selectionInfo
1164
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1165
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1166
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
1167
-
1168
- if (paragraph) {
1169
- (paragraph as HTMLElement).style.textAlign = 'center'
1170
- }
1171
- }, (state) => {
1172
- const selectionInfo = getCurrentSelection(state)
1173
- if (!selectionInfo) { return false }
1174
-
1175
- const { range } = selectionInfo
1176
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1177
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1178
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
1179
-
1180
- return (paragraph as HTMLElement)?.style.textAlign === 'center'
1181
- }),
1182
-
1183
- alignRight: createCommand('Align Right', (state) => {
1184
- const selectionInfo = getCurrentSelection(state)
1185
- if (!selectionInfo) { return }
1186
-
1187
- const { range } = selectionInfo
1188
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1189
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1190
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
1191
-
1192
- if (paragraph) {
1193
- (paragraph as HTMLElement).style.textAlign = 'right'
1194
- }
1195
- }, (state) => {
1196
- const selectionInfo = getCurrentSelection(state)
1197
- if (!selectionInfo) { return false }
1198
-
1199
- const { range } = selectionInfo
1200
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1201
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1202
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
1203
-
1204
- return (paragraph as HTMLElement)?.style.textAlign === 'right'
1205
- }),
1206
-
1207
- alignJustify: createCommand('Align Justify', (state) => {
1208
- const selectionInfo = getCurrentSelection(state)
1209
- if (!selectionInfo) { return }
1210
-
1211
- const { range } = selectionInfo
1212
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1213
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1214
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
1215
-
1216
- if (paragraph) {
1217
- (paragraph as HTMLElement).style.textAlign = 'justify'
1218
- }
1219
- }, (state) => {
1220
- const selectionInfo = getCurrentSelection(state)
1221
- if (!selectionInfo) { return false }
1222
-
1223
- const { range } = selectionInfo
1224
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1225
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1226
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
1227
-
1228
- return (paragraph as HTMLElement)?.style.textAlign === 'justify'
1229
- }),
1230
-
1231
- textDirection: createCommand('Toggle Text Direction', (state) => {
1232
- const selectionInfo = getCurrentSelection(state)
1233
- if (!selectionInfo) { return }
883
+ const ALIGN_TARGET_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, blockquote, li, td, th'
1234
884
 
1235
- const { range } = selectionInfo
1236
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1237
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1238
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
885
+ function applyBlockAlignment(s: EditorState, value: 'left' | 'center' | 'right' | 'justify') {
886
+ const selectionInfo = getCurrentSelection(s)
887
+ if (!selectionInfo || !s.doc) { return }
888
+ const { range } = selectionInfo
1239
889
 
1240
- if (paragraph) {
1241
- const currentDir = (paragraph as HTMLElement).dir || 'ltr'
1242
- ; (paragraph as HTMLElement).dir = currentDir === 'ltr' ? 'rtl' : 'ltr'
890
+ if (!range.collapsed) {
891
+ const blocks = Array.from(s.doc.body.querySelectorAll<HTMLElement>(ALIGN_TARGET_SELECTOR))
892
+ .filter(el => range.intersectsNode(el) && !el.querySelector(ALIGN_TARGET_SELECTOR))
893
+ if (blocks.length > 0) {
894
+ blocks.forEach((b) => { b.style.textAlign = value })
895
+ return
1243
896
  }
1244
- }, (state) => {
1245
- const selectionInfo = getCurrentSelection(state)
1246
- if (!selectionInfo) { return false }
1247
-
1248
- const { range } = selectionInfo
1249
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1250
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1251
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
1252
-
1253
- return (paragraph as HTMLElement)?.dir === 'rtl'
1254
- }),
1255
-
1256
- ltrDirection: createCommand('Set Left to Right Direction', (state) => {
1257
- const selectionInfo = getCurrentSelection(state)
1258
- if (!selectionInfo) { return }
897
+ }
1259
898
 
1260
- const { range } = selectionInfo
1261
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1262
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1263
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
899
+ const target = range.startContainer.nodeType === Node.TEXT_NODE
900
+ ? range.startContainer.parentElement?.closest(ALIGN_TARGET_SELECTOR)
901
+ : (range.startContainer as Element).closest(ALIGN_TARGET_SELECTOR)
902
+ if (target) {
903
+ (target as HTMLElement).style.textAlign = value
904
+ }
905
+ }
1264
906
 
1265
- if (paragraph) {
1266
- (paragraph as HTMLElement).dir = 'ltr'
907
+ function applyBlockDirection(s: EditorState, dir: 'ltr' | 'rtl' | 'toggle') {
908
+ const selectionInfo = getCurrentSelection(s)
909
+ if (!selectionInfo || !s.doc) { return }
910
+ const { range } = selectionInfo
911
+
912
+ const setDirOnEl = (el: HTMLElement) => {
913
+ const nextDir = dir === 'toggle'
914
+ ? ((el.dir || s.doc?.body.dir || 'ltr') === 'ltr' ? 'rtl' : 'ltr')
915
+ : dir
916
+ el.dir = nextDir
917
+ el.setAttribute('data-dir-explicit', 'true')
918
+ const listParent = el.closest('ul, ol') as HTMLElement | null
919
+ if (listParent) {
920
+ listParent.dir = nextDir
921
+ listParent.setAttribute('data-dir-explicit', 'true')
1267
922
  }
1268
- }, (state) => {
1269
- const selectionInfo = getCurrentSelection(state)
1270
- if (!selectionInfo) { return false }
1271
-
1272
- const { range } = selectionInfo
1273
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1274
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1275
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
1276
-
1277
- return (paragraph as HTMLElement)?.dir === 'ltr'
1278
- }),
1279
-
1280
- rtlDirection: createCommand('Set Right to Left Direction', (state) => {
1281
- const selectionInfo = getCurrentSelection(state)
1282
- if (!selectionInfo) { return }
1283
-
1284
- const { range } = selectionInfo
1285
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1286
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1287
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
923
+ }
1288
924
 
1289
- if (paragraph) {
1290
- (paragraph as HTMLElement).dir = 'rtl'
925
+ if (!range.collapsed) {
926
+ const blocks = Array.from(s.doc.body.querySelectorAll<HTMLElement>(ALIGN_TARGET_SELECTOR))
927
+ .filter(el => range.intersectsNode(el) && !el.querySelector(ALIGN_TARGET_SELECTOR))
928
+ if (blocks.length > 0) {
929
+ blocks.forEach(setDirOnEl)
930
+ return
1291
931
  }
1292
- }, (state) => {
1293
- const selectionInfo = getCurrentSelection(state)
1294
- if (!selectionInfo) { return false }
932
+ }
1295
933
 
1296
- const { range } = selectionInfo
1297
- const paragraph = range.startContainer.nodeType === Node.TEXT_NODE
1298
- ? range.startContainer.parentElement?.closest('p, h1, h2, h3, h4, h5, h6')
1299
- : (range.startContainer as Element).closest('p, h1, h2, h3, h4, h5, h6')
934
+ const target = range.startContainer.nodeType === Node.TEXT_NODE
935
+ ? range.startContainer.parentElement?.closest(ALIGN_TARGET_SELECTOR)
936
+ : (range.startContainer as Element).closest(ALIGN_TARGET_SELECTOR)
937
+ if (target) {
938
+ setDirOnEl(target as HTMLElement)
939
+ }
940
+ }
1300
941
 
1301
- return (paragraph as HTMLElement)?.dir === 'rtl'
1302
- })
942
+ const textAlignmentCommands: CommandRegistry = {
943
+ alignLeft: createCommand(
944
+ 'Align Left',
945
+ s => applyBlockAlignment(s, 'left'),
946
+ s => Boolean(s.doc && isStyleActive('alignLeft', s.doc))
947
+ ),
948
+ alignCenter: createCommand(
949
+ 'Align Center',
950
+ s => applyBlockAlignment(s, 'center'),
951
+ s => Boolean(s.doc && isStyleActive('alignCenter', s.doc))
952
+ ),
953
+ alignRight: createCommand(
954
+ 'Align Right',
955
+ s => applyBlockAlignment(s, 'right'),
956
+ s => Boolean(s.doc && isStyleActive('alignRight', s.doc))
957
+ ),
958
+ alignJustify: createCommand(
959
+ 'Align Justify',
960
+ s => applyBlockAlignment(s, 'justify'),
961
+ s => Boolean(s.doc && isStyleActive('alignJustify', s.doc))
962
+ ),
963
+ textDirection: createCommand(
964
+ 'Toggle Text Direction',
965
+ s => applyBlockDirection(s, 'toggle'),
966
+ s => Boolean(s.doc && isStyleActive('textDirection', s.doc))
967
+ ),
968
+ ltrDirection: createCommand(
969
+ 'Set Left to Right Direction',
970
+ s => applyBlockDirection(s, 'ltr'),
971
+ s => Boolean(s.doc && isStyleActive('ltrDirection', s.doc))
972
+ ),
973
+ rtlDirection: createCommand(
974
+ 'Set Right to Left Direction',
975
+ s => applyBlockDirection(s, 'rtl'),
976
+ s => Boolean(s.doc && isStyleActive('rtlDirection', s.doc))
977
+ )
1303
978
  }
1304
979
 
1305
- // View state commands
1306
- const viewCommands = {
1307
- fullScreen: createCommand('Full Screen', (state) => { state.isFullscreen = !state.isFullscreen }, state => state.isFullscreen),
1308
- splitView: createCommand('Split View', (state) => { state.isSplitView = !state.isSplitView }, state => state.isSplitView),
1309
- codeView: createCommand('Code View', (state) => { state.isCodeView = !state.isCodeView }, state => state.isCodeView)
980
+ const viewCommands: CommandRegistry = {
981
+ fullScreen: createCommand('Full Screen', (s) => { s.isFullscreen = !s.isFullscreen }, s => s.isFullscreen, { skipHistory: true }),
982
+ splitView: createCommand('Split View', (s) => { s.isSplitView = !s.isSplitView }, s => s.isSplitView, { skipHistory: true }),
983
+ codeView: createCommand('Code View', (s) => { s.isCodeView = !s.isCodeView }, s => s.isCodeView, { skipHistory: true })
1310
984
  }
1311
985
 
1312
- // Media commands
1313
- const mediaCommands = {
1314
- image: createCommand('Insert Image', (state) => {
1315
- const { openImageModal } = state as any
1316
- if (openImageModal) {
1317
- openImageModal()
1318
- } else {
1319
- console.warn('Image insertion requires modal implementation')
1320
- }
1321
- }),
1322
- video: createCommand('Insert Video', (state) => {
1323
- const { openVideoModal } = state as any
1324
- if (openVideoModal) {
1325
- openVideoModal()
1326
- } else {
1327
- console.warn('Video insertion requires modal implementation')
1328
- }
1329
- }),
1330
- embed: createCommand('Insert Embed', (state) => {
1331
- const { openEmbedModal } = state as any
1332
- if (openEmbedModal) {
1333
- openEmbedModal()
1334
- } else {
1335
- console.warn('Embed insertion requires modal implementation')
1336
- }
1337
- })
986
+ const mediaCommands: CommandRegistry = {
987
+ image: createCommand('Insert Image', (s) => {
988
+ const { openImageModal } = s as any
989
+ openImageModal?.()
990
+ }, undefined, { skipHistory: true }),
991
+ video: createCommand('Insert Video', (s) => {
992
+ const { openVideoModal } = s as any
993
+ openVideoModal?.()
994
+ }, undefined, { skipHistory: true }),
995
+ embed: createCommand('Insert Embed', (s) => {
996
+ const { openEmbedModal } = s as any
997
+ openEmbedModal?.()
998
+ }, undefined, { skipHistory: true })
1338
999
  }
1339
1000
 
1340
1001
  return {
@@ -1362,11 +1023,11 @@ export function createCommandExecutor(state: EditorState, commands: CommandRegis
1362
1023
  },
1363
1024
  isActive(command: string): boolean {
1364
1025
  const cmd = commands[command]
1365
- return cmd.isActive?.(state) || false
1026
+ return cmd?.isActive?.(state) || false
1366
1027
  },
1367
1028
  getValue(command: string): string | null {
1368
1029
  const cmd = commands[command]
1369
- return cmd.getValue?.(state) || null
1030
+ return cmd?.getValue?.(state) || null
1370
1031
  }
1371
1032
  }
1372
1033
  }