@bagelink/vue 0.0.1025 → 0.0.1031

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 (70) hide show
  1. package/README.md +1 -0
  2. package/dist/components/Image.vue.d.ts.map +1 -1
  3. package/dist/components/ToolBar.vue.d.ts +3 -0
  4. package/dist/components/ToolBar.vue.d.ts.map +1 -0
  5. package/dist/components/form/inputs/NumberInput.vue.d.ts.map +1 -1
  6. package/dist/components/form/inputs/RangeInput.vue.d.ts +2 -2
  7. package/dist/components/form/inputs/RangeInput.vue.d.ts.map +1 -1
  8. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts +12 -0
  9. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -0
  10. package/dist/components/form/inputs/RichText/composables/useCommands.d.ts +9 -0
  11. package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -0
  12. package/dist/components/form/inputs/RichText/composables/useEditor.d.ts +40 -20
  13. package/dist/components/form/inputs/RichText/composables/useEditor.d.ts.map +1 -1
  14. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
  15. package/dist/components/form/inputs/RichText/config.d.ts.map +1 -1
  16. package/dist/components/form/inputs/RichText/index.vue.d.ts +1 -0
  17. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  18. package/dist/components/form/inputs/RichText/utils/commands.d.ts +17 -0
  19. package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -0
  20. package/dist/components/form/inputs/RichText/utils/debug.d.ts +48 -0
  21. package/dist/components/form/inputs/RichText/utils/debug.d.ts.map +1 -0
  22. package/dist/components/form/inputs/RichText/utils/formatting.d.ts +3 -1
  23. package/dist/components/form/inputs/RichText/utils/formatting.d.ts.map +1 -1
  24. package/dist/components/form/inputs/RichText/utils/selection.d.ts +13 -0
  25. package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
  26. package/dist/components/form/inputs/RichText/utils/table.d.ts +4 -0
  27. package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
  28. package/dist/components/form/inputs/SignaturePad.vue.d.ts +2 -2
  29. package/dist/components/form/inputs/SignaturePad.vue.d.ts.map +1 -1
  30. package/dist/components/form/inputs/Upload/UploadFile.vue.d.ts +86 -0
  31. package/dist/components/form/inputs/Upload/UploadFile.vue.d.ts.map +1 -0
  32. package/dist/components/form/inputs/Upload/upload.d.ts +13 -0
  33. package/dist/components/form/inputs/Upload/upload.d.ts.map +1 -0
  34. package/dist/components/form/inputs/index.d.ts +1 -0
  35. package/dist/components/form/inputs/index.d.ts.map +1 -1
  36. package/dist/components/index.d.ts +1 -0
  37. package/dist/components/index.d.ts.map +1 -1
  38. package/dist/editor-B3mMCQSg.cjs +4 -0
  39. package/dist/editor-BKPRpAjr.js +4 -0
  40. package/dist/index.cjs +10059 -6970
  41. package/dist/index.mjs +10065 -6976
  42. package/dist/plugins/bagel.d.ts +1 -0
  43. package/dist/plugins/bagel.d.ts.map +1 -1
  44. package/dist/style.css +666 -458
  45. package/package.json +1 -1
  46. package/src/components/Image.vue +1 -2
  47. package/src/components/ToolBar.vue +9 -0
  48. package/src/components/form/inputs/NumberInput.vue +8 -1
  49. package/src/components/form/inputs/RangeInput.vue +6 -5
  50. package/src/components/form/inputs/RichText/composables/useCommands.ts +114 -0
  51. package/src/components/form/inputs/RichText/composables/useEditor.ts +257 -129
  52. package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +64 -19
  53. package/src/components/form/inputs/RichText/config.ts +18 -2
  54. package/src/components/form/inputs/RichText/editor.css +17 -15
  55. package/src/components/form/inputs/RichText/index.vue +67 -13
  56. package/src/components/form/inputs/RichText/richTextTypes.d.ts +2 -0
  57. package/src/components/form/inputs/RichText/utils/commands.ts +37 -0
  58. package/src/components/form/inputs/RichText/utils/debug.ts +196 -0
  59. package/src/components/form/inputs/RichText/utils/formatting.ts +168 -288
  60. package/src/components/form/inputs/RichText/utils/selection.ts +77 -0
  61. package/src/components/form/inputs/RichText/utils/table.ts +66 -0
  62. package/src/components/form/inputs/SignaturePad.vue +2 -2
  63. package/src/components/form/inputs/Upload/UploadFile.vue +357 -0
  64. package/src/components/form/inputs/Upload/upload.css +232 -0
  65. package/src/components/form/inputs/Upload/upload.ts +22 -0
  66. package/src/components/form/inputs/Upload/upload.types.d.ts +43 -0
  67. package/src/components/form/inputs/index.ts +1 -0
  68. package/src/components/index.ts +2 -0
  69. package/src/plugins/bagel.ts +2 -2
  70. /package/src/components/form/inputs/RichText/components/{Toolbar.vue → EditorToolbar.vue} +0 -0
@@ -1,345 +1,225 @@
1
1
  import type { EditorState } from '../richTextTypes'
2
+ import { analyzeSelection, restoreSelection, getBlocksBetween } from './selection'
2
3
 
3
4
  export function formatting(state: EditorState) {
4
- const { doc, range, selection } = state
5
- function setRangeAndSelect(node: Node, start: number, end: number) {
6
- if (!range) return
7
- range.setStart(node, start)
8
- range.setEnd(node, end)
9
- }
10
- const wrapWithTag = (content: string, tag: string): HTMLElement => {
11
- const wrapper = doc!.createElement(tag)
12
- wrapper.textContent = content
13
- return wrapper
14
- }
5
+ const { doc } = state
6
+ if (!doc) return { text: () => {}, block: () => {}, list: () => {}, clear: () => {} }
15
7
 
16
- const createBlockElement = (tag: string, content: DocumentFragment): HTMLElement => {
17
- const block = doc!.createElement(tag)
18
- block.appendChild(content)
19
- return block
20
- }
21
- const clear = () => {
22
- if (!doc || !range || !selection) return
8
+ const text = (command: string) => {
9
+ const selection = doc.getSelection()
10
+ if (!selection || !selection.rangeCount) return
23
11
 
24
- const inlineTags = ['b', 'i', 'u', 'strong', 'em']
12
+ const range = selection.getRangeAt(0)
13
+ const container = range.commonAncestorContainer
14
+ const parentBlock = container.nodeType === 3 ? container.parentElement : container as HTMLElement
25
15
 
26
- const isInlineTag = (el: HTMLElement | null) => el && inlineTags.includes(el.tagName.toLowerCase())
16
+ // Preserve RTL direction when applying text formatting
17
+ const isRTL = parentBlock?.closest('[dir="rtl"]') !== null
27
18
 
28
- if (range.collapsed) {
29
- // No action if the selection is collapsed
30
- return
31
- }
19
+ // Don't apply inline styles directly to block elements
20
+ if (parentBlock?.tagName.match(/^H[1-6]|P|BLOCKQUOTE|LI$/)) {
21
+ if (!range.collapsed && range.toString().trim()) {
22
+ const span = doc.createElement('span') as HTMLSpanElement
23
+ if (command === 'underline') span.style.textDecoration = 'underline'
24
+ else if (command === 'bold') span.style.fontWeight = 'bold'
25
+ else if (command === 'italic') span.style.fontStyle = 'italic'
32
26
 
33
- // Extract the selected content
34
- const selectedContent = range.extractContents()
35
-
36
- // Helper function to recursively clean inline tags
37
- const cleanInlineTags = (node: Node): Node => {
38
- if (node.nodeType === 1 && isInlineTag(node as HTMLElement)) {
39
- // Inline element: replace it with its children
40
- const fragment = doc.createDocumentFragment()
41
- let child = node.firstChild // Start with the first child
42
- while (child) {
43
- const { nextSibling } = child // Store the next sibling before appending
44
- fragment.appendChild(cleanInlineTags(child))
45
- child = nextSibling // Move to the next sibling
46
- }
47
- return fragment
48
- } else if (node.nodeType === 1) {
49
- // Non-inline element: clean its children
50
- const element = node.cloneNode(false) as HTMLElement
51
- let child = node.firstChild // Start with the first child
52
- while (child) {
53
- const { nextSibling } = child // Store the next sibling before appending
54
- element.appendChild(cleanInlineTags(child))
55
- child = nextSibling // Move to the next sibling
56
- }
57
- return element
58
- } else if (node.nodeType === 3) {
59
- // Text node: return as is
60
- return node
27
+ if (isRTL) span.dir = 'rtl'
28
+ range.surroundContents(span)
61
29
  }
62
- return doc.createTextNode('') // Ignore other node types
30
+ } else {
31
+ doc.execCommand(command, false)
63
32
  }
33
+ }
64
34
 
65
- // Clean the extracted content
66
- const fragment = doc.createDocumentFragment()
67
- Array.from(selectedContent.childNodes).forEach((child) => {
68
- const cleanedChild = cleanInlineTags(child)
69
- if (cleanedChild.nodeType === 11) {
70
- // If it's a DocumentFragment, append its children directly
71
- fragment.appendChild(cleanedChild)
72
- } else {
73
- fragment.appendChild(cleanedChild)
74
- }
75
- })
76
-
77
- // Replace the range content with the cleaned content
78
- range.deleteContents()
79
- range.insertNode(fragment)
35
+ const block = (command: string, tag: string) => {
36
+ const selection = doc.getSelection()
37
+ if (!selection || !selection.rangeCount) return
80
38
 
81
- // Normalize the DOM (merge adjacent text nodes)
82
- const normalizeDom = (node: Node) => {
83
- if (node.nodeType === 3 && node.nextSibling?.nodeType === 3) {
84
- // Merge adjacent text nodes
85
- if (node.textContent && node.nextSibling.textContent) {
86
- node.textContent += node.nextSibling.textContent
39
+ const range = selection.getRangeAt(0)
40
+ const container = range.commonAncestorContainer
41
+ const parentBlock = container.nodeType === 3 ? container.parentElement : container as HTMLElement
42
+ const isRTL = parentBlock?.closest('[dir="rtl"]') !== null
43
+
44
+ // Remove any invalid inline style wrapping
45
+ if (parentBlock?.closest('u, b, i')) {
46
+ const wrapper = parentBlock.closest('u, b, i')
47
+ if (wrapper) {
48
+ const parent = wrapper.parentNode
49
+ while (wrapper.firstChild) {
50
+ parent?.insertBefore(wrapper.firstChild, wrapper)
87
51
  }
88
- node.parentNode?.removeChild(node.nextSibling)
89
- } else if (node.nodeType === 1) {
90
- // Recursively normalize child nodes
91
- Array.from(node.childNodes).forEach((child) => {
92
- normalizeDom(child)
93
- })
52
+ wrapper.remove()
94
53
  }
95
54
  }
96
55
 
97
- Array.from(fragment.childNodes).forEach((child) => {
98
- normalizeDom(child)
99
- })
56
+ doc.execCommand('formatBlock', false, tag)
100
57
 
101
- // Update the selection to cover the cleaned content
102
- selection.removeAllRanges()
103
- range.selectNodeContents(fragment)
104
- selection.addRange(range)
58
+ // Preserve RTL direction after block formatting
59
+ if (isRTL) {
60
+ const newBlock = selection.anchorNode?.parentElement?.closest(tag.toLowerCase()) as HTMLElement
61
+ if (newBlock) newBlock.dir = 'rtl'
62
+ }
105
63
  }
106
64
 
107
- const text = (command: string) => {
108
- if (!doc || !range || !selection) return
109
- const { startOffset, endOffset } = range
110
-
111
- let tag = ''
112
- if (command === 'bold') tag = 'b'
113
- if (command === 'italic') tag = 'i'
114
- if (command === 'underline') tag = 'u'
115
- if (command === 'emphasis') tag = 'em'
116
- if (command === 'strong') tag = 'strong'
117
-
118
- const relatedTags: { [key: string]: string[] } = {
119
- b: ['strong'],
120
- i: ['em'],
121
- u: ['u'],
122
- em: ['i'],
123
- strong: ['b'],
124
- }
65
+ const list = (command: string) => {
66
+ if (!state.doc || !state.range || !state.selection) return
125
67
 
126
- const isTagMatch = (el: HTMLElement | null) => el
127
- && (el.tagName.toLowerCase() === tag || relatedTags[tag].includes(el.tagName.toLowerCase()))
128
-
129
- if (range.collapsed) {
130
- // Handle collapsed range
131
- const container = range.commonAncestorContainer
132
- const parent = container.parentElement
133
- if (parent && isTagMatch(parent)) {
134
- const textNode = doc.createTextNode(parent.textContent || '')
135
- parent.parentNode?.replaceChild(textNode, parent)
136
- range.selectNodeContents(textNode)
137
- selection.removeAllRanges()
138
- setRangeAndSelect(textNode, startOffset, endOffset)
139
- selection.addRange(range)
140
- }
141
- return
142
- }
68
+ const listTag = command === 'orderedList' ? 'ol' : 'ul'
69
+ const selectionInfo = analyzeSelection(state.doc, state.range)
70
+ if (!selectionInfo) return
143
71
 
144
- const container = range.commonAncestorContainer
145
- const parent = container.parentElement
146
-
147
- if (parent && isTagMatch(parent)) {
148
- // Split text content into three parts: before, selected, and after
149
- const parentText = parent.textContent || ''
150
- const beforeText = parentText.substring(0, range.startOffset - parentText.indexOf(parent.textContent || ''))
151
- const selectedText = parentText.substring(range.startOffset, range.endOffset)
152
- const afterText = parentText.substring(range.endOffset)
153
-
154
- // Create text nodes for before, selected, and after
155
- if (beforeText) {
156
- const beforeNode = wrapWithTag(beforeText, tag)
157
- parent.parentNode?.insertBefore(beforeNode, parent)
158
- }
72
+ const isRTL = selectionInfo.parent.closest('[dir="rtl"]') !== null
159
73
 
160
- // Add unwrapped selected text
161
- const unwrappedTextNode = doc.createTextNode(selectedText)
162
- parent.parentNode?.insertBefore(unwrappedTextNode, parent)
74
+ // If we're inside a list item, handle toggling off or switching list type
75
+ const listItem = selectionInfo.parent.closest('li')
76
+ if (listItem) {
77
+ const listParent = listItem.parentElement
78
+ if (!listParent) return
163
79
 
164
- if (afterText) {
165
- const afterNode = wrapWithTag(afterText, tag)
166
- parent.parentNode?.insertBefore(afterNode, parent)
167
- }
80
+ // If it's the same list type, toggle off
81
+ if (listParent.tagName.toLowerCase() === listTag) {
82
+ // Create a paragraph from the list item content
83
+ const p = state.doc.createElement('p') as HTMLParagraphElement
84
+ if (isRTL) p.dir = 'rtl'
85
+ while (listItem.firstChild) {
86
+ p.appendChild(listItem.firstChild)
87
+ }
168
88
 
169
- // Remove the original parent node
170
- parent.parentNode?.removeChild(parent)
89
+ listParent.parentNode?.insertBefore(p, listParent)
90
+ listItem.remove()
171
91
 
172
- // Reselect the unwrapped text
173
- range.selectNode(unwrappedTextNode)
174
- } else {
175
- // Standard case: wrap the selection
176
- const selectedContent = range.extractContents()
177
- const wrapper = doc.createElement(tag)
178
- wrapper.appendChild(selectedContent)
179
- range.insertNode(wrapper)
180
-
181
- // Clean up nested tags
182
- const nestedTags = wrapper.querySelectorAll(`${tag},${(relatedTags[tag] || []).join(',')}`)
183
- nestedTags.forEach((nestedTag) => {
184
- while (nestedTag.firstChild) {
185
- nestedTag.parentNode?.insertBefore(nestedTag.firstChild, nestedTag)
92
+ if (!listParent.querySelector('li')) {
93
+ listParent.remove()
186
94
  }
187
- nestedTag.parentNode?.removeChild(nestedTag)
188
- })
189
95
 
190
- // Reselect the wrapped content
191
- range.selectNodeContents(wrapper)
192
- }
96
+ restoreSelection(state.doc, state.range, state.selection, selectionInfo, p)
97
+ return
98
+ }
193
99
 
194
- selection.removeAllRanges()
195
- selection.addRange(range)
196
- }
197
- const block = (command: string, value: string) => {
198
- if (!doc || !range || !selection) return
199
- const container = range.commonAncestorContainer
200
- const parent = container.nodeType === 3 ? container.parentElement : (container as Element)
201
- if (!parent) return
100
+ // Convert to the other list type
101
+ const newList = state.doc.createElement(listTag) as HTMLElement
102
+ if (isRTL) newList.dir = 'rtl'
103
+ const allItems = Array.from(listParent.children)
104
+ allItems.forEach(item => newList.appendChild(item.cloneNode(true)))
105
+ listParent.parentNode?.replaceChild(newList, listParent)
106
+
107
+ const newListItem = newList.children[Array.from(allItems).indexOf(listItem)]
108
+ restoreSelection(state.doc, state.range, state.selection, selectionInfo, newListItem)
109
+ return
110
+ }
202
111
 
203
- const currentHeading = parent.closest('p,h1,h2,h3,h4,h5,h6,blockquote')
204
- const { startOffset, endOffset } = range
205
- const blockEl = currentHeading || parent.closest('p,h1,h2,h3,h4,h5,h6') || parent
206
- range.selectNodeContents(blockEl)
112
+ // For single block conversion
113
+ if (!selectionInfo.isMultiBlock) {
114
+ const li = state.doc.createElement('li')
115
+ const listEl = state.doc.createElement(listTag) as HTMLElement
116
+ if (isRTL) listEl.dir = 'rtl'
207
117
 
208
- const content = range.extractContents()
209
- const newElement = createBlockElement(value, content)
118
+ while (selectionInfo.startBlock.firstChild) {
119
+ li.appendChild(selectionInfo.startBlock.firstChild)
120
+ }
210
121
 
211
- if (currentHeading) {
212
- currentHeading.parentNode?.replaceChild(newElement, currentHeading)
213
- } else {
214
- range.insertNode(newElement)
122
+ listEl.appendChild(li)
123
+ selectionInfo.startBlock.parentNode?.replaceChild(listEl, selectionInfo.startBlock)
124
+ restoreSelection(state.doc, state.range, state.selection, selectionInfo, li)
125
+ return
215
126
  }
216
127
 
217
- selection.removeAllRanges()
218
- setRangeAndSelect(container, startOffset, endOffset)
219
- selection.addRange(range)
220
- }
221
- const list = (command: string) => {
222
- if (!doc || !range || !selection) return
223
-
224
- const listTag = command === 'orderedList' ? 'ol' : 'ul'
225
- const container = range.commonAncestorContainer
226
- const parent = container.nodeType === 3 ? container.parentElement : (container as Element)
227
- const currentList = parent?.closest('ul,ol')
128
+ // Handle multi-block selection
129
+ const blocks = getBlocksBetween(selectionInfo.startBlock, selectionInfo.endBlock)
130
+ const listEl = state.doc.createElement(listTag) as HTMLElement
131
+ if (isRTL) listEl.dir = 'rtl'
228
132
 
229
- if (!parent) return
133
+ blocks.forEach((block) => {
134
+ if (!state.doc) return
135
+ const li = state.doc.createElement('li')
136
+ while (block.firstChild) {
137
+ li.appendChild(block.firstChild)
138
+ }
139
+ listEl.appendChild(li)
140
+ block.remove()
141
+ })
230
142
 
231
- // Expand range to include the entire list item if the caret is inside one
232
- const listItem = parent.closest('li')
233
- if (listItem) {
234
- const listParent = listItem.parentElement // The <ol> or <ul>
235
- if (!listParent) return
143
+ selectionInfo.startBlock.parentNode?.insertBefore(listEl, selectionInfo.startBlock)
144
+ restoreSelection(state.doc, state.range, state.selection, selectionInfo, listEl)
145
+ }
236
146
 
237
- // Create new fragments for before and after the selected list item
238
- const beforeFragment = doc.createDocumentFragment()
239
- const afterFragment = doc.createDocumentFragment()
147
+ const clear = () => {
148
+ if (!state.doc || !state.range || !state.selection) return
240
149
 
241
- let currentNode = listParent.firstChild
242
- let isBefore = true
150
+ const selectionInfo = analyzeSelection(state.doc, state.range)
151
+ if (!selectionInfo) return
243
152
 
244
- while (currentNode) {
245
- if (currentNode === listItem) {
246
- isBefore = false // Mark as "after" once we reach the selected item
247
- } else if (isBefore) {
248
- beforeFragment.appendChild(currentNode.cloneNode(true))
249
- } else {
250
- afterFragment.appendChild(currentNode.cloneNode(true))
251
- }
252
- currentNode = currentNode.nextSibling
253
- }
153
+ const inlineTags = ['b', 'i', 'u', 'strong', 'em', 'span']
154
+ const fragment = state.range.cloneContents()
155
+ const tempDiv = state.doc.createElement('div')
156
+ tempDiv.appendChild(fragment)
254
157
 
255
- // Convert the selected list item to a standalone block (e.g., <p>)
256
- const standaloneBlock = doc.createElement('p')
257
- while (listItem.firstChild) {
258
- standaloneBlock.appendChild(listItem.firstChild)
158
+ // Function to clean a node and its children
159
+ const cleanNode = (node: Node): Node => {
160
+ if (!state.doc) return node
161
+ if (node.nodeType === 3) { // Text node
162
+ return node.cloneNode(true)
259
163
  }
260
164
 
261
- // Replace the original list with the split fragments and the standalone block
262
- const grandParent = listParent.parentNode
263
- if (!grandParent) return
165
+ if (node.nodeType === 1) { // Element node
166
+ const el = node as HTMLElement
167
+ const nodeName = el.nodeName.toLowerCase()
264
168
 
265
- if (beforeFragment.childNodes.length > 0) {
266
- const newListBefore = doc.createElement(listParent.tagName.toLowerCase())
267
- newListBefore.appendChild(beforeFragment)
268
- grandParent.insertBefore(newListBefore, listParent)
269
- }
169
+ // If it's an inline formatting element, just return its text content
170
+ if (inlineTags.includes(nodeName)) {
171
+ return state.doc.createTextNode(el.textContent || '')
172
+ }
270
173
 
271
- grandParent.insertBefore(standaloneBlock, listParent)
174
+ // For block elements, create a new clean element
175
+ const newEl = state.doc.createElement(nodeName)
272
176
 
273
- if (afterFragment.childNodes.length > 0) {
274
- const newListAfter = doc.createElement(listParent.tagName.toLowerCase())
275
- newListAfter.appendChild(afterFragment)
276
- grandParent.insertBefore(newListAfter, listParent.nextSibling)
277
- }
177
+ // Remove all styles and classes
178
+ newEl.removeAttribute('style')
179
+ newEl.removeAttribute('class')
278
180
 
279
- // Remove the original list
280
- grandParent.removeChild(listParent)
181
+ // Clean and append all child nodes
182
+ Array.from(el.childNodes).forEach((child) => {
183
+ newEl.appendChild(cleanNode(child))
184
+ })
281
185
 
282
- // Update the selection
283
- selection.removeAllRanges()
284
- range.selectNodeContents(standaloneBlock)
285
- selection.addRange(range)
186
+ return newEl
187
+ }
286
188
 
287
- return
189
+ return node.cloneNode(true)
288
190
  }
289
191
 
290
- // Fallback for toggling list behavior (same as previous implementation)
291
- const startBlock = parent.closest('p,h1,h2,h3,h4,h5,h6,blockquote') || parent
292
- const endBlock = range.endContainer.nodeType === 3
293
- ? (range.endContainer.parentElement?.closest('p,h1,h2,h3,h4,h5,h6,blockquote') || range.endContainer.parentElement)
294
- : (range.endContainer as Element)
295
-
296
- const blocks = []
297
- let currentBlock = startBlock
298
- while (currentBlock) {
299
- blocks.push(currentBlock)
300
- if (currentBlock === endBlock) break
301
- currentBlock = currentBlock.nextElementSibling as Element
302
- }
192
+ // Clean all nodes in the fragment
193
+ const cleanedFragment = state.doc.createDocumentFragment()
194
+ Array.from(tempDiv.childNodes).forEach((node) => {
195
+ cleanedFragment.appendChild(cleanNode(node))
196
+ })
303
197
 
304
- if (currentList) {
305
- const fragment = doc.createDocumentFragment()
306
- blocks.forEach((block) => {
307
- const paragraph = doc.createElement('p')
308
- while (block.firstChild) {
309
- paragraph.appendChild(block.firstChild)
310
- }
311
- fragment.appendChild(paragraph)
312
- })
198
+ // Replace the content
199
+ state.range.deleteContents()
200
+ state.range.insertNode(cleanedFragment)
201
+ restoreSelection(state.doc, state.range, state.selection, selectionInfo)
202
+ }
313
203
 
314
- const listParent = currentList.parentNode
315
- if (listParent) {
316
- listParent.insertBefore(fragment, currentList.nextSibling)
204
+ return { text, block, list, clear }
205
+ }
317
206
 
318
- if (!currentList.querySelector('li')) {
319
- listParent.removeChild(currentList)
320
- }
321
- }
207
+ export function getBlockElement(node: Node): HTMLElement | null {
208
+ const element = node.nodeType === 3 ? node.parentElement : node as HTMLElement
209
+ if (!element) return null
210
+ return element.closest('p,h1,h2,h3,h4,h5,h6,blockquote,li') || element
211
+ }
322
212
 
323
- selection.removeAllRanges()
324
- range.selectNodeContents(fragment)
325
- selection.addRange(range)
326
- } else {
327
- const listEl = doc.createElement(listTag)
213
+ export function adjustIndentation(state: EditorState, increase: boolean) {
214
+ const { doc, range } = state
215
+ if (!doc || !range) return
328
216
 
329
- blocks.forEach((block) => {
330
- const li = doc.createElement('li')
331
- while (block.firstChild) {
332
- li.appendChild(block.firstChild)
333
- }
334
- listEl.appendChild(li)
335
- })
217
+ const blockElement = getBlockElement(range.commonAncestorContainer)
218
+ if (!blockElement) return
336
219
 
337
- range.insertNode(listEl)
220
+ const currentMargin = Number.parseInt(blockElement.style.marginLeft || '0', 10)
221
+ const step = 20 // pixels to indent/outdent
222
+ const newMargin = increase ? currentMargin + step : Math.max(0, currentMargin - step)
338
223
 
339
- selection.removeAllRanges()
340
- range.selectNodeContents(listEl)
341
- selection.addRange(range)
342
- }
343
- }
344
- return { text, block, list, clear }
224
+ blockElement.style.marginLeft = `${newMargin}px`
345
225
  }
@@ -30,3 +30,80 @@ export function isStyleActive(style: string, doc: Document) {
30
30
 
31
31
  return checkParent(parent, styleTags[style] ?? [style])
32
32
  }
33
+
34
+ export interface SelectionInfo {
35
+ startBlock: Element
36
+ endBlock: Element
37
+ isMultiBlock: boolean
38
+ originalStart: Node
39
+ originalEnd: Node
40
+ originalStartOffset: number
41
+ originalEndOffset: number
42
+ parent: Element
43
+ }
44
+
45
+ export function analyzeSelection(doc: Document, range: Range): SelectionInfo | null {
46
+ const container = range.commonAncestorContainer
47
+ const parent = container.nodeType === 3 ? container.parentElement : container as Element
48
+ if (!parent) return null
49
+
50
+ const startBlock = parent.closest('p,h1,h2,h3,h4,h5,h6,blockquote,li') || parent
51
+
52
+ // Analyze if selection spans multiple blocks
53
+ const isMultiBlock = (() => {
54
+ if (!range.collapsed) {
55
+ const { endContainer } = range
56
+ const endParent = endContainer.nodeType === 3 ? endContainer.parentElement : endContainer as Element
57
+ const endBlock = endParent?.closest('p,h1,h2,h3,h4,h5,h6,blockquote,li') || endParent
58
+ return startBlock !== endBlock
59
+ }
60
+ return false
61
+ })()
62
+
63
+ const { endContainer } = range
64
+ const endParent = endContainer.nodeType === 3 ? endContainer.parentElement : endContainer as Element
65
+ const endBlock = endParent?.closest('p,h1,h2,h3,h4,h5,h6,blockquote,li') || endParent
66
+
67
+ if (!endBlock) return null
68
+
69
+ return {
70
+ startBlock,
71
+ endBlock,
72
+ isMultiBlock,
73
+ originalStart: range.startContainer,
74
+ originalEnd: range.endContainer,
75
+ originalStartOffset: range.startOffset,
76
+ originalEndOffset: range.endOffset,
77
+ parent
78
+ }
79
+ }
80
+
81
+ export function restoreSelection(
82
+ doc: Document,
83
+ range: Range,
84
+ selection: Selection,
85
+ info: SelectionInfo,
86
+ fallbackNode?: Node
87
+ ) {
88
+ try {
89
+ range.setStart(info.originalStart, info.originalStartOffset)
90
+ range.setEnd(info.originalEnd, info.originalEndOffset)
91
+ } catch (e) {
92
+ if (fallbackNode) {
93
+ range.selectNodeContents(fallbackNode)
94
+ }
95
+ }
96
+ selection.removeAllRanges()
97
+ selection.addRange(range)
98
+ }
99
+
100
+ export function getBlocksBetween(startBlock: Element, endBlock: Element): Element[] {
101
+ const blocks = []
102
+ let currentBlock = startBlock
103
+ while (currentBlock) {
104
+ blocks.push(currentBlock)
105
+ if (currentBlock === endBlock) break
106
+ currentBlock = currentBlock.nextElementSibling as Element
107
+ }
108
+ return blocks
109
+ }
@@ -79,3 +79,69 @@ export function deleteRow(range: Range) {
79
79
 
80
80
  row.remove()
81
81
  }
82
+
83
+ export function deleteTable(range: Range) {
84
+ const cell = range.startContainer.parentElement?.closest('td')
85
+ if (!cell) return
86
+
87
+ const table = cell.closest('table')
88
+ if (!table) return
89
+
90
+ table.remove()
91
+ }
92
+
93
+ export function insertColumn(position: 'before' | 'after', range: Range) {
94
+ const cell = range.startContainer.parentElement?.closest('td')
95
+ if (!cell) return
96
+
97
+ const table = cell.closest('table')
98
+ if (!table) return
99
+
100
+ const columnIndex = cell.cellIndex
101
+ const { rows } = table
102
+
103
+ for (let i = 0; i < rows.length; i++) {
104
+ const newCell = rows[i].insertCell(position === 'after' ? columnIndex + 1 : columnIndex)
105
+ newCell.innerHTML = '&nbsp;'
106
+ newCell.style.border = '1px solid var(--border-color)'
107
+ newCell.style.padding = '8px'
108
+ }
109
+ }
110
+
111
+ export function deleteColumn(range: Range) {
112
+ const cell = range.startContainer.parentElement?.closest('td')
113
+ if (!cell) return
114
+
115
+ const table = cell.closest('table')
116
+ if (!table) return
117
+
118
+ const columnIndex = cell.cellIndex
119
+ const { rows } = table
120
+
121
+ for (let i = 0; i < rows.length; i++) {
122
+ rows[i].deleteCell(columnIndex)
123
+ }
124
+
125
+ // If no columns left, remove the table
126
+ if (rows[0].cells.length === 0) {
127
+ table.remove()
128
+ }
129
+ }
130
+
131
+ export function alignColumn(range: Range, alignment: 'left' | 'center' | 'right' | 'justify') {
132
+ const cell = range.startContainer.parentElement?.closest('td')
133
+ if (!cell) return
134
+
135
+ const table = cell.closest('table')
136
+ if (!table) return
137
+
138
+ const columnIndex = cell.cellIndex
139
+ const { rows } = table
140
+
141
+ for (let i = 0; i < rows.length; i++) {
142
+ const cell = rows[i].cells[columnIndex]
143
+ if (cell) {
144
+ cell.style.textAlign = alignment
145
+ }
146
+ }
147
+ }