@bagelink/vue 1.18.7 → 1.19.3

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 (63) hide show
  1. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  2. package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
  3. package/dist/components/dataTable/DataTable.vue.d.ts.map +1 -1
  4. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts +1 -0
  5. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
  6. package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
  7. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
  8. package/dist/components/form/inputs/RichText/config.d.ts.map +1 -1
  9. package/dist/components/form/inputs/RichText/index.vue.d.ts +6 -0
  10. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  11. package/dist/components/form/inputs/RichText/richTextTypes.d.ts +1 -0
  12. package/dist/components/form/inputs/RichText/richTextTypes.d.ts.map +1 -1
  13. package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
  14. package/dist/components/form/inputs/RichText/utils/contentMetrics.d.ts +7 -0
  15. package/dist/components/form/inputs/RichText/utils/contentMetrics.d.ts.map +1 -0
  16. package/dist/components/form/inputs/RichText/utils/image.d.ts +17 -0
  17. package/dist/components/form/inputs/RichText/utils/image.d.ts.map +1 -0
  18. package/dist/components/form/inputs/RichText/utils/sanitizePaste.d.ts +15 -0
  19. package/dist/components/form/inputs/RichText/utils/sanitizePaste.d.ts.map +1 -0
  20. package/dist/components/form/inputs/RichText/utils/selection.d.ts +1 -1
  21. package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
  22. package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
  23. package/dist/components/form/inputs/RichText/utils/video.d.ts +23 -0
  24. package/dist/components/form/inputs/RichText/utils/video.d.ts.map +1 -0
  25. package/dist/components/form/inputs/SelectBtn.vue.d.ts +28 -1
  26. package/dist/components/form/inputs/SelectBtn.vue.d.ts.map +1 -1
  27. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -0
  28. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
  29. package/dist/components/form/inputs/Upload/useFileUpload.d.ts +1 -0
  30. package/dist/components/form/inputs/Upload/useFileUpload.d.ts.map +1 -1
  31. package/dist/core.css +2 -2
  32. package/dist/index.cjs +218 -130
  33. package/dist/index.css +1 -1
  34. package/dist/index.mjs +18662 -17635
  35. package/dist/style.css +2 -2
  36. package/package.json +1 -1
  37. package/src/components/Dropdown.vue +30 -0
  38. package/src/components/FilterQuery.vue +1 -1
  39. package/src/components/Icon/Icon.vue +4 -0
  40. package/src/components/dataTable/DataTable.vue +6 -3
  41. package/src/components/form/inputs/RichText/components/EditorToolbar.vue +238 -8
  42. package/src/components/form/inputs/RichText/composables/useCommands.ts +23 -5
  43. package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +28 -0
  44. package/src/components/form/inputs/RichText/config.ts +17 -14
  45. package/src/components/form/inputs/RichText/index.vue +381 -478
  46. package/src/components/form/inputs/RichText/richTextTypes.ts +1 -0
  47. package/src/components/form/inputs/RichText/utils/commands.ts +437 -24
  48. package/src/components/form/inputs/RichText/utils/contentMetrics.ts +24 -0
  49. package/src/components/form/inputs/RichText/utils/image.ts +136 -0
  50. package/src/components/form/inputs/RichText/utils/sanitizePaste.ts +110 -0
  51. package/src/components/form/inputs/RichText/utils/selection.ts +32 -0
  52. package/src/components/form/inputs/RichText/utils/table.ts +77 -8
  53. package/src/components/form/inputs/RichText/utils/video.ts +270 -0
  54. package/src/components/form/inputs/SelectBtn.vue +126 -15
  55. package/src/components/form/inputs/TelInput.vue +1 -1
  56. package/src/components/form/inputs/Upload/UploadInput.vue +3 -2
  57. package/src/components/form/inputs/Upload/useFileUpload.ts +2 -0
  58. package/src/styles/colors.css +5 -2
  59. package/src/styles/core.css +1 -0
  60. package/src/styles/rich-content.css +109 -0
  61. package/dist/components/form/inputs/RichText/utils/linkPolicy.d.ts +0 -11
  62. package/dist/components/form/inputs/RichText/utils/linkPolicy.d.ts.map +0 -1
  63. package/src/components/form/inputs/RichText/utils/linkPolicy.ts +0 -48
@@ -106,4 +106,5 @@ export interface ToolbarOption {
106
106
  label?: string
107
107
  icon?: string
108
108
  class?: string
109
+ shortcut?: string
109
110
  }
@@ -42,6 +42,99 @@ function getCurrentSelection(state: EditorState): { selection: Selection, range:
42
42
  return { selection, range }
43
43
  }
44
44
 
45
+ function rangeOverlapsNodeContent(range: Range, node: Node): boolean {
46
+ const nodeRange = node.ownerDocument?.createRange()
47
+ if (!nodeRange) { return false }
48
+ nodeRange.selectNodeContents(node)
49
+
50
+ // `intersectsNode()` treats touching boundaries as an intersection. A browser
51
+ // selection that ends at offset 0 of the next paragraph must not format it.
52
+ return range.compareBoundaryPoints(Range.END_TO_START, nodeRange) < 0
53
+ && range.compareBoundaryPoints(Range.START_TO_END, nodeRange) > 0
54
+ }
55
+
56
+ function getSelectedListItems(doc: Document, range: Range): HTMLLIElement[] {
57
+ const items = Array.from(doc.body.querySelectorAll<HTMLLIElement>('li'))
58
+ .filter(item => rangeOverlapsNodeContent(range, item))
59
+ const itemSet = new Set(items)
60
+ const deepestItems = items.filter(item =>
61
+ !Array.from(item.querySelectorAll<HTMLLIElement>('li')).some(descendant => itemSet.has(descendant))
62
+ )
63
+
64
+ if (deepestItems.length > 0) { return deepestItems }
65
+
66
+ const container = range.commonAncestorContainer.nodeType === Node.TEXT_NODE
67
+ ? range.commonAncestorContainer.parentElement
68
+ : range.commonAncestorContainer as Element
69
+ const currentItem = container?.closest('li') as HTMLLIElement | null
70
+ return currentItem ? [currentItem] : []
71
+ }
72
+
73
+ function selectNodes(doc: Document, selection: Selection, nodes: Node[]) {
74
+ if (!nodes.length) { return }
75
+ const nextRange = doc.createRange()
76
+ nextRange.setStartBefore(nodes[0])
77
+ nextRange.setEndAfter(nodes[nodes.length - 1])
78
+ selection.removeAllRanges()
79
+ selection.addRange(nextRange)
80
+ }
81
+
82
+ function indentSelectedListItems(doc: Document, selection: Selection, range: Range): boolean {
83
+ const items = getSelectedListItems(doc, range)
84
+ if (!items.length) { return false }
85
+
86
+ const parentList = items[0].parentElement
87
+ if (!parentList || !['UL', 'OL'].includes(parentList.tagName)
88
+ || !items.every(item => item.parentElement === parentList)) {
89
+ return false
90
+ }
91
+
92
+ const siblings = Array.from(parentList.children)
93
+ .filter((element): element is HTMLLIElement => element.tagName === 'LI')
94
+ const firstIndex = siblings.indexOf(items[0])
95
+ if (firstIndex <= 0) { return true }
96
+
97
+ const previousItem = siblings[firstIndex - 1]
98
+ const listTag = parentList.tagName.toLowerCase()
99
+ let nestedList = Array.from(previousItem.children)
100
+ .find(child => child.tagName.toLowerCase() === listTag) as HTMLElement | undefined
101
+ if (!nestedList) {
102
+ nestedList = doc.createElement(listTag)
103
+ if (parentList.dir) { nestedList.dir = parentList.dir }
104
+ previousItem.appendChild(nestedList)
105
+ }
106
+
107
+ items.forEach(item => nestedList!.appendChild(item))
108
+ selectNodes(doc, selection, items)
109
+ return true
110
+ }
111
+
112
+ function outdentSelectedListItems(doc: Document, selection: Selection, range: Range): boolean {
113
+ const items = getSelectedListItems(doc, range)
114
+ if (!items.length) { return false }
115
+
116
+ const parentList = items[0].parentElement
117
+ const ownerItem = parentList?.parentElement?.tagName === 'LI'
118
+ ? parentList.parentElement as HTMLLIElement
119
+ : parentList?.parentElement?.closest('li') as HTMLLIElement | null
120
+ const outerList = ownerItem?.parentElement
121
+ if (!parentList || !ownerItem || !outerList || !['UL', 'OL'].includes(outerList.tagName)
122
+ || !items.every(item => item.parentElement === parentList)) {
123
+ return false
124
+ }
125
+
126
+ let insertAfter: Element = ownerItem
127
+ items.forEach((item) => {
128
+ insertAfter.insertAdjacentElement('afterend', item)
129
+ insertAfter = item
130
+ })
131
+ if (!parentList.querySelector(':scope > li')) {
132
+ parentList.remove()
133
+ }
134
+ selectNodes(doc, selection, items)
135
+ return true
136
+ }
137
+
45
138
  function applyFormattingToWordAtCursor(doc: Document, range: Range, tagSelector: string, createTag: string): boolean {
46
139
  if (!range.collapsed) { return false }
47
140
 
@@ -236,16 +329,144 @@ function toggleInlineFormatting(
236
329
  // Check if selection spans across multiple block elements
237
330
  const blockSelector = 'p,h1,h2,h3,h4,h5,h6,blockquote,li,td,th'
238
331
  const blocks = Array.from(doc.body.querySelectorAll<HTMLElement>(blockSelector))
239
- .filter(b => !b.querySelector(blockSelector) && range.intersectsNode(b))
332
+ .filter(block => {
333
+ if (!rangeOverlapsNodeContent(range, block)) { return false }
334
+ return block.tagName === 'LI' || !block.querySelector(blockSelector)
335
+ })
336
+
337
+ const fullySelectedBlocks = blocks.filter((block) => {
338
+ const blockRange = doc.createRange()
339
+ blockRange.selectNodeContents(block)
340
+ return range.compareBoundaryPoints(Range.START_TO_START, blockRange) <= 0
341
+ && range.compareBoundaryPoints(Range.END_TO_END, blockRange) >= 0
342
+ })
343
+
344
+ // Browser line selections can extend just beyond an <li> boundary. Never pass
345
+ // such a range to extractContents(), because that can split the surrounding list
346
+ // and leave empty blocks before and after the selected item.
347
+ if (blocks.length > 0 && blocks.every(block => block.tagName === 'LI')) {
348
+ const allFormatted = blocks.every((block) => {
349
+ const meaningfulChildren = Array.from(block.childNodes)
350
+ .filter(node => node.nodeType !== Node.ELEMENT_NODE || !['UL', 'OL'].includes((node as Element).tagName))
351
+ .filter(node => node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()))
352
+ return meaningfulChildren.length > 0 && meaningfulChildren.every((node) => {
353
+ const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement
354
+ return Boolean(element?.closest(matchSelector))
355
+ })
356
+ })
357
+ const formattedNodes: HTMLElement[] = []
240
358
 
241
- if (blocks.length > 1) {
242
359
  blocks.forEach((block) => {
360
+ if (allFormatted) {
361
+ block.querySelectorAll<HTMLElement>(matchSelector).forEach((formatted) => {
362
+ const parent = formatted.parentNode
363
+ while (formatted.firstChild) {
364
+ parent?.insertBefore(formatted.firstChild, formatted)
365
+ }
366
+ formatted.remove()
367
+ })
368
+ formattedNodes.push(block)
369
+ return
370
+ }
371
+
372
+ const blockRange = doc.createRange()
373
+ const nestedListIndex = Array.from(block.childNodes)
374
+ .findIndex(node => node.nodeType === Node.ELEMENT_NODE && ['UL', 'OL'].includes((node as Element).tagName))
375
+ blockRange.setStart(block, 0)
376
+ blockRange.setEnd(block, nestedListIndex >= 0 ? nestedListIndex : block.childNodes.length)
377
+
378
+ const startItem = (range.startContainer.nodeType === Node.TEXT_NODE
379
+ ? range.startContainer.parentElement
380
+ : range.startContainer as Element)?.closest('li')
381
+ const endItem = (range.endContainer.nodeType === Node.TEXT_NODE
382
+ ? range.endContainer.parentElement
383
+ : range.endContainer as Element)?.closest('li')
384
+ if (startItem === block) {
385
+ blockRange.setStart(range.startContainer, range.startOffset)
386
+ }
387
+ if (endItem === block) {
388
+ blockRange.setEnd(range.endContainer, range.endOffset)
389
+ }
390
+ if (blockRange.collapsed || !blockRange.toString()) { return }
391
+
243
392
  const wrapper = doc.createElement(tagName)
244
- while (block.firstChild) {
245
- wrapper.appendChild(block.firstChild)
393
+ try {
394
+ blockRange.surroundContents(wrapper)
395
+ } catch {
396
+ const fragment = blockRange.extractContents()
397
+ fragment.querySelectorAll(matchSelector).forEach((nested) => {
398
+ const parent = nested.parentNode
399
+ while (nested.firstChild) {
400
+ parent?.insertBefore(nested.firstChild, nested)
401
+ }
402
+ nested.remove()
403
+ })
404
+ wrapper.appendChild(fragment)
405
+ blockRange.insertNode(wrapper)
246
406
  }
247
- block.appendChild(wrapper)
407
+ wrapper.querySelectorAll(matchSelector).forEach((nested) => {
408
+ const parent = nested.parentNode
409
+ while (nested.firstChild) {
410
+ parent?.insertBefore(nested.firstChild, nested)
411
+ }
412
+ nested.remove()
413
+ })
414
+ formattedNodes.push(wrapper)
415
+ })
416
+
417
+ if (formattedNodes.length > 0) {
418
+ const nextRange = doc.createRange()
419
+ nextRange.setStartBefore(formattedNodes[0])
420
+ nextRange.setEndAfter(formattedNodes[formattedNodes.length - 1])
421
+ selection.removeAllRanges()
422
+ selection.addRange(nextRange)
423
+ }
424
+ return
425
+ }
426
+
427
+ if (fullySelectedBlocks.length > 0 && fullySelectedBlocks.length === blocks.length) {
428
+ const allFormatted = fullySelectedBlocks.every((block) => {
429
+ const meaningfulChildren = Array.from(block.childNodes)
430
+ .filter(node => node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()))
431
+ return meaningfulChildren.length > 0 && meaningfulChildren.every((node) => {
432
+ const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement
433
+ return Boolean(element?.closest(matchSelector))
434
+ })
248
435
  })
436
+
437
+ if (allFormatted) {
438
+ fullySelectedBlocks.forEach((block) => {
439
+ block.querySelectorAll<HTMLElement>(matchSelector).forEach((formatted) => {
440
+ const parent = formatted.parentNode
441
+ while (formatted.firstChild) {
442
+ parent?.insertBefore(formatted.firstChild, formatted)
443
+ }
444
+ formatted.remove()
445
+ })
446
+ })
447
+ } else {
448
+ fullySelectedBlocks.forEach((block) => {
449
+ const wrapper = doc.createElement(tagName)
450
+ const children = Array.from(block.childNodes)
451
+ children.forEach((child) => {
452
+ if (child.nodeType === Node.ELEMENT_NODE && (child as Element).matches(matchSelector)) {
453
+ while (child.firstChild) {
454
+ wrapper.appendChild(child.firstChild)
455
+ }
456
+ child.remove()
457
+ } else {
458
+ wrapper.appendChild(child)
459
+ }
460
+ })
461
+ block.appendChild(wrapper)
462
+ })
463
+ }
464
+
465
+ const nextRange = doc.createRange()
466
+ nextRange.setStart(fullySelectedBlocks[0], 0)
467
+ nextRange.setEnd(fullySelectedBlocks[fullySelectedBlocks.length - 1], fullySelectedBlocks[fullySelectedBlocks.length - 1].childNodes.length)
468
+ selection.removeAllRanges()
469
+ selection.addRange(nextRange)
249
470
  return
250
471
  }
251
472
 
@@ -275,6 +496,66 @@ function toggleInlineFormatting(
275
496
  }
276
497
  }
277
498
 
499
+ function applyInlineStyle(state: EditorState, property: string, value: string) {
500
+ const selectionInfo = getCurrentSelection(state)
501
+ if (!selectionInfo || !state.doc || !value) { return }
502
+
503
+ const { doc } = state
504
+ const { range, selection } = selectionInfo
505
+ const styleSelector = `span[style*="${property}"]`
506
+
507
+ if (range.collapsed) {
508
+ const span = doc.createElement('span')
509
+ span.style.setProperty(property, value)
510
+ const marker = doc.createTextNode('\u200B')
511
+ span.appendChild(marker)
512
+ range.insertNode(span)
513
+
514
+ const nextRange = doc.createRange()
515
+ nextRange.setStart(marker, 1)
516
+ nextRange.collapse(true)
517
+ selection.removeAllRanges()
518
+ selection.addRange(nextRange)
519
+ return
520
+ }
521
+
522
+ const commonAncestor = range.commonAncestorContainer
523
+ const commonElement = commonAncestor.nodeType === Node.TEXT_NODE
524
+ ? commonAncestor.parentElement
525
+ : commonAncestor as Element
526
+ const activeStyleSpan = commonElement?.closest(styleSelector) as HTMLSpanElement | null
527
+ if (activeStyleSpan && activeStyleSpan.contains(range.startContainer) && activeStyleSpan.contains(range.endContainer)) {
528
+ activeStyleSpan.style.setProperty(property, value)
529
+ const nextRange = doc.createRange()
530
+ nextRange.selectNodeContents(activeStyleSpan)
531
+ selection.removeAllRanges()
532
+ selection.addRange(nextRange)
533
+ return
534
+ }
535
+
536
+ const span = doc.createElement('span')
537
+ span.style.setProperty(property, value)
538
+ try {
539
+ range.surroundContents(span)
540
+ } catch {
541
+ const fragment = range.extractContents()
542
+ fragment.querySelectorAll<HTMLSpanElement>(styleSelector).forEach((nested) => {
543
+ const parent = nested.parentNode
544
+ while (nested.firstChild) {
545
+ parent?.insertBefore(nested.firstChild, nested)
546
+ }
547
+ nested.remove()
548
+ })
549
+ span.appendChild(fragment)
550
+ range.insertNode(span)
551
+ }
552
+
553
+ const nextRange = doc.createRange()
554
+ nextRange.selectNodeContents(span)
555
+ selection.removeAllRanges()
556
+ selection.addRange(nextRange)
557
+ }
558
+
278
559
  function updateStateAfterCommand(state: EditorState, preCommandContent?: string) {
279
560
  if (!state.doc) { return }
280
561
 
@@ -308,7 +589,7 @@ const CONVERTIBLE_BLOCK_SELECTOR = 'p,h1,h2,h3,h4,h5,h6,blockquote'
308
589
  function getSelectedBlocks(doc: Document, range: Range): HTMLElement[] {
309
590
  if (!range.collapsed) {
310
591
  const blocks = Array.from(doc.body.querySelectorAll<HTMLElement>(CONVERTIBLE_BLOCK_SELECTOR))
311
- .filter(block => !block.closest('li') && range.intersectsNode(block))
592
+ .filter(block => !block.closest('li') && rangeOverlapsNodeContent(range, block))
312
593
  .filter(block => !block.parentElement?.closest(CONVERTIBLE_BLOCK_SELECTOR))
313
594
  if (blocks.length > 0) {
314
595
  return blocks
@@ -348,7 +629,7 @@ function convertSelectedBlocksToList(
348
629
  if (!state.doc || range.collapsed) { return false }
349
630
 
350
631
  const blocks = Array.from(state.doc.body.querySelectorAll<HTMLElement>(CONVERTIBLE_BLOCK_SELECTOR))
351
- .filter(block => !block.closest('li') && range.intersectsNode(block))
632
+ .filter(block => !block.closest('li') && rangeOverlapsNodeContent(range, block))
352
633
  .filter(block => !block.parentElement?.closest(CONVERTIBLE_BLOCK_SELECTOR))
353
634
 
354
635
  if (!blocks.length) { return false }
@@ -376,12 +657,124 @@ function convertSelectedBlocksToList(
376
657
  return true
377
658
  }
378
659
 
660
+ function replaceListTag(doc: Document, list: HTMLElement, listTag: 'ol' | 'ul'): HTMLElement {
661
+ const convertedList = doc.createElement(listTag)
662
+ for (const attribute of Array.from(list.attributes)) {
663
+ convertedList.setAttribute(attribute.name, attribute.value)
664
+ }
665
+ while (list.firstChild) {
666
+ convertedList.appendChild(list.firstChild)
667
+ }
668
+ list.replaceWith(convertedList)
669
+ return convertedList
670
+ }
671
+
672
+ function convertSelectedListItems(
673
+ doc: Document,
674
+ selection: Selection,
675
+ range: Range,
676
+ listTag: 'ol' | 'ul'
677
+ ): boolean {
678
+ const selectedItems = getSelectedListItems(doc, range)
679
+ if (!selectedItems.length) { return false }
680
+
681
+ const listParent = selectedItems[0].parentElement
682
+ if (!listParent || !['UL', 'OL'].includes(listParent.tagName)
683
+ || !selectedItems.every(item => item.parentElement === listParent)) {
684
+ return false
685
+ }
686
+
687
+ const allItems = Array.from(listParent.children)
688
+ .filter((element): element is HTMLLIElement => element.tagName === 'LI')
689
+ const selectedSet = new Set(selectedItems)
690
+ const selectedIndexes = allItems
691
+ .map((item, index) => selectedSet.has(item) ? index : -1)
692
+ .filter(index => index >= 0)
693
+ if (!selectedIndexes.length) { return false }
694
+
695
+ const firstIndex = selectedIndexes[0]
696
+ const lastIndex = selectedIndexes[selectedIndexes.length - 1]
697
+ const selectedAreConsecutive = selectedIndexes.every((index, offset) => index === firstIndex + offset)
698
+ if (!selectedAreConsecutive) { return false }
699
+
700
+ const parentTag = listParent.tagName.toLowerCase()
701
+ if (firstIndex === 0 && lastIndex === allItems.length - 1) {
702
+ if (parentTag === listTag) {
703
+ const paragraphs = selectedItems.map((item) => {
704
+ const paragraph = doc.createElement('p')
705
+ if (item.dir || listParent.dir) { paragraph.dir = item.dir || listParent.dir }
706
+ while (item.firstChild) { paragraph.appendChild(item.firstChild) }
707
+ return paragraph
708
+ })
709
+ paragraphs.forEach(paragraph => listParent.parentNode?.insertBefore(paragraph, listParent))
710
+ listParent.remove()
711
+ const nextRange = doc.createRange()
712
+ nextRange.setStartBefore(paragraphs[0])
713
+ nextRange.setEndAfter(paragraphs[paragraphs.length - 1])
714
+ selection.removeAllRanges()
715
+ selection.addRange(nextRange)
716
+ return true
717
+ }
718
+
719
+ const convertedList = replaceListTag(doc, listParent, listTag)
720
+ const nextRange = doc.createRange()
721
+ nextRange.selectNodeContents(convertedList)
722
+ selection.removeAllRanges()
723
+ selection.addRange(nextRange)
724
+ return true
725
+ }
726
+
727
+ const beforeItems = allItems.slice(0, firstIndex)
728
+ const convertedItems = allItems.slice(firstIndex, lastIndex + 1)
729
+ const afterItems = allItems.slice(lastIndex + 1)
730
+ const fragment = doc.createDocumentFragment()
731
+ const createList = (tag: 'ol' | 'ul', items: HTMLLIElement[]) => {
732
+ const nextList = doc.createElement(tag)
733
+ for (const attribute of Array.from(listParent.attributes)) {
734
+ nextList.setAttribute(attribute.name, attribute.value)
735
+ }
736
+ items.forEach(item => nextList.appendChild(item))
737
+ return nextList
738
+ }
739
+
740
+ if (beforeItems.length) { fragment.appendChild(createList(parentTag as 'ol' | 'ul', beforeItems)) }
741
+ if (parentTag === listTag) {
742
+ convertedItems.forEach((item) => {
743
+ const paragraph = doc.createElement('p')
744
+ if (item.dir || listParent.dir) { paragraph.dir = item.dir || listParent.dir }
745
+ while (item.firstChild) { paragraph.appendChild(item.firstChild) }
746
+ fragment.appendChild(paragraph)
747
+ })
748
+ } else {
749
+ fragment.appendChild(createList(listTag, convertedItems))
750
+ }
751
+ if (afterItems.length) { fragment.appendChild(createList(parentTag as 'ol' | 'ul', afterItems)) }
752
+
753
+ const firstSelectedNode = parentTag === listTag
754
+ ? fragment.childNodes[beforeItems.length ? 1 : 0]
755
+ : convertedItems[0]
756
+ const lastSelectedNode = parentTag === listTag
757
+ ? fragment.childNodes[(beforeItems.length ? 1 : 0) + convertedItems.length - 1]
758
+ : convertedItems[convertedItems.length - 1]
759
+ listParent.replaceWith(fragment)
760
+
761
+ if (firstSelectedNode && lastSelectedNode) {
762
+ const nextRange = doc.createRange()
763
+ nextRange.setStartBefore(firstSelectedNode)
764
+ nextRange.setEndAfter(lastSelectedNode)
765
+ selection.removeAllRanges()
766
+ selection.addRange(nextRange)
767
+ }
768
+ return true
769
+ }
770
+
379
771
  function toggleListCommand(state: EditorState, listTag: 'ol' | 'ul') {
380
772
  const selectionInfo = getCurrentSelection(state)
381
773
  if (!selectionInfo || !state.doc) { return }
382
774
 
383
775
  const { doc } = state
384
776
  const { range, selection } = selectionInfo
777
+ if (!range.collapsed && convertSelectedListItems(doc, selection, range, listTag)) { return }
385
778
  if (convertSelectedBlocksToList(state, selection, range, listTag)) { return }
386
779
 
387
780
  const currentBlock = findBlockElement(range.commonAncestorContainer)
@@ -632,25 +1025,35 @@ export function createCommandRegistry(state: EditorState): CommandRegistry {
632
1025
 
633
1026
  indent: createCommand('Indent', (s) => {
634
1027
  const selectionInfo = getCurrentSelection(s)
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`
1028
+ if (!selectionInfo || !s.doc) { return }
1029
+ if (indentSelectedListItems(s.doc, selectionInfo.selection, selectionInfo.range)) { return }
1030
+ const listItems = getSelectedListItems(s.doc, selectionInfo.range)
1031
+ const blocks = listItems.length
1032
+ ? listItems
1033
+ : [findBlockElement(selectionInfo.range.commonAncestorContainer)].filter(Boolean) as HTMLElement[]
1034
+ blocks.forEach((block) => {
1035
+ const isRTL = (block.dir || s.doc?.body.dir) === 'rtl'
1036
+ const prop = isRTL ? 'marginRight' : 'marginLeft'
1037
+ const current = Number.parseInt(block.style[prop] || '0', 10) || 0
1038
+ block.style[prop] = `${current + 24}px`
1039
+ })
642
1040
  }),
643
1041
 
644
1042
  outdent: createCommand('Outdent', (s) => {
645
1043
  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` : ''
1044
+ if (!selectionInfo || !s.doc) { return }
1045
+ if (outdentSelectedListItems(s.doc, selectionInfo.selection, selectionInfo.range)) { return }
1046
+ const listItems = getSelectedListItems(s.doc, selectionInfo.range)
1047
+ const blocks = listItems.length
1048
+ ? listItems
1049
+ : [findBlockElement(selectionInfo.range.commonAncestorContainer)].filter(Boolean) as HTMLElement[]
1050
+ blocks.forEach((block) => {
1051
+ const isRTL = (block.dir || s.doc?.body.dir) === 'rtl'
1052
+ const prop = isRTL ? 'marginRight' : 'marginLeft'
1053
+ const current = Number.parseInt(block.style[prop] || '0', 10) || 0
1054
+ const next = Math.max(0, current - 24)
1055
+ block.style[prop] = next > 0 ? `${next}px` : ''
1056
+ })
654
1057
  })
655
1058
  }
656
1059
 
@@ -668,6 +1071,16 @@ export function createCommandRegistry(state: EditorState): CommandRegistry {
668
1071
  }
669
1072
 
670
1073
  const formatCommands: CommandRegistry = {
1074
+ fontColor: createCommand('Font Color', (s, value) => {
1075
+ if (value) {
1076
+ applyInlineStyle(s, 'color', value)
1077
+ }
1078
+ }),
1079
+ bgColor: createCommand('Background Color', (s, value) => {
1080
+ if (value) {
1081
+ applyInlineStyle(s, 'background-color', value)
1082
+ }
1083
+ }),
671
1084
  clear: createCommand('Clear Formatting', (s) => {
672
1085
  const selectionInfo = getCurrentSelection(s)
673
1086
  if (!selectionInfo || !s.doc) { return }
@@ -889,7 +1302,7 @@ export function createCommandRegistry(state: EditorState): CommandRegistry {
889
1302
 
890
1303
  if (!range.collapsed) {
891
1304
  const blocks = Array.from(s.doc.body.querySelectorAll<HTMLElement>(ALIGN_TARGET_SELECTOR))
892
- .filter(el => range.intersectsNode(el) && !el.querySelector(ALIGN_TARGET_SELECTOR))
1305
+ .filter(el => rangeOverlapsNodeContent(range, el) && !el.querySelector(ALIGN_TARGET_SELECTOR))
893
1306
  if (blocks.length > 0) {
894
1307
  blocks.forEach((b) => { b.style.textAlign = value })
895
1308
  return
@@ -924,7 +1337,7 @@ export function createCommandRegistry(state: EditorState): CommandRegistry {
924
1337
 
925
1338
  if (!range.collapsed) {
926
1339
  const blocks = Array.from(s.doc.body.querySelectorAll<HTMLElement>(ALIGN_TARGET_SELECTOR))
927
- .filter(el => range.intersectsNode(el) && !el.querySelector(ALIGN_TARGET_SELECTOR))
1340
+ .filter(el => rangeOverlapsNodeContent(range, el) && !el.querySelector(ALIGN_TARGET_SELECTOR))
928
1341
  if (blocks.length > 0) {
929
1342
  blocks.forEach(setDirOnEl)
930
1343
  return
@@ -0,0 +1,24 @@
1
+ export interface RichTextMetrics {
2
+ text: string
3
+ characters: number
4
+ words: number
5
+ }
6
+
7
+ export function getRichTextMetrics(html: string, doc?: Document): RichTextMetrics {
8
+ let text = ''
9
+ if (doc?.body) {
10
+ text = doc.body.innerText || doc.body.textContent || ''
11
+ } else if (typeof DOMParser !== 'undefined') {
12
+ const body = new DOMParser().parseFromString(html, 'text/html').body
13
+ text = body.innerText || body.textContent || ''
14
+ } else {
15
+ text = html.replace(/<[^>]*>/g, ' ')
16
+ }
17
+
18
+ const normalizedText = text.replace(/\u200B/g, '').replace(/\s+/g, ' ').trim()
19
+ return {
20
+ text: normalizedText,
21
+ characters: normalizedText.length,
22
+ words: normalizedText ? normalizedText.split(/\s+/).length : 0,
23
+ }
24
+ }