@bagelink/vue 1.9.83 โ†’ 1.9.86

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 (37) hide show
  1. package/dist/components/Avatar.vue.d.ts +1 -0
  2. package/dist/components/Avatar.vue.d.ts.map +1 -1
  3. package/dist/components/Badge.vue.d.ts +0 -1
  4. package/dist/components/Badge.vue.d.ts.map +1 -1
  5. package/dist/components/Btn.vue.d.ts.map +1 -1
  6. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  7. package/dist/components/Loading.vue.d.ts +2 -1
  8. package/dist/components/Loading.vue.d.ts.map +1 -1
  9. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  10. package/dist/form-flow/FormFlow.vue.d.ts.map +1 -1
  11. package/dist/form-flow/form-flow.d.ts +9 -9
  12. package/dist/form-flow/form-flow.d.ts.map +1 -1
  13. package/dist/index.cjs +163 -85
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.mjs +25737 -25545
  17. package/dist/plugins/useToast.d.ts.map +1 -1
  18. package/dist/style.css +1 -1
  19. package/dist/utils/filterRef.d.ts +15 -0
  20. package/dist/utils/filterRef.d.ts.map +1 -0
  21. package/package.json +1 -1
  22. package/src/components/Avatar.vue +6 -2
  23. package/src/components/Badge.vue +14 -1084
  24. package/src/components/Btn.vue +37 -37
  25. package/src/components/Dropdown.vue +1 -1
  26. package/src/components/Loading.vue +15 -6
  27. package/src/components/form/inputs/RichText/index.vue +325 -221
  28. package/src/form-flow/FormFlow.vue +9 -0
  29. package/src/form-flow/form-flow.ts +13 -3
  30. package/src/index.ts +1 -1
  31. package/src/plugins/useToast.ts +14 -0
  32. package/src/styles/bagel.css +1 -0
  33. package/src/styles/base-colors.css +1429 -46
  34. package/src/styles/text.css +1755 -1755
  35. package/src/styles/toast-overrides.css +10 -0
  36. package/src/utils/filterRef.ts +133 -0
  37. package/src/styles/btnColors.css +0 -847
@@ -1,11 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import type { ToolbarConfig } from './richTextTypes'
3
- import { CodeEditor, copyText, Btn, Modal, BglVideo, Icon, Card, ColorInput } from '@bagelink/vue'
3
+ import { CodeEditor, copyText, Btn, Modal, BglVideo, Icon, Card, ColorInput, pathKeyToURL } from '@bagelink/vue'
4
4
  import { watch, onUnmounted, onBeforeUnmount, onMounted, ref, computed, useAttrs } from 'vue'
5
5
  import CheckInput from '../CheckInput.vue'
6
6
  import NumberInput from '../NumberInput.vue'
7
7
  import SelectInput from '../SelectInput.vue'
8
8
  import TextInput from '../TextInput.vue'
9
+ import UploadInput from '../Upload/UploadInput.vue'
9
10
  import EditorToolbar from './components/EditorToolbar.vue'
10
11
  import { useCommands } from './composables/useCommands'
11
12
  import { useEditor } from './composables/useEditor'
@@ -64,7 +65,6 @@ const currentInputColor = computed(() => {
64
65
  if (typeof document !== 'undefined') {
65
66
  const computedStyle = getComputedStyle(document.documentElement)
66
67
  const color = computedStyle.getPropertyValue('--input-color').trim()
67
- console.log('๐ŸŽจ currentInputColor computed:', color, 'forceUpdate:', forceUpdate.value)
68
68
  return color || '#000'
69
69
  }
70
70
  return '#000'
@@ -73,7 +73,6 @@ const currentInputColor = computed(() => {
73
73
  const currentTextColor = computed(() => {
74
74
  const inputColor = currentInputColor.value
75
75
  const textColor = props.textColor || (inputColor && inputColor.length > 0 && inputColor !== '#000' ? inputColor : 'inherit')
76
- console.log('๐ŸŽจ currentTextColor computed:', textColor, 'from inputColor:', inputColor)
77
76
  return textColor
78
77
  })
79
78
 
@@ -96,7 +95,6 @@ onMounted(() => {
96
95
  })
97
96
 
98
97
  if (shouldUpdate) {
99
- console.log('๐Ÿ”„ Theme change detected, forcing update')
100
98
  forceUpdate.value++
101
99
 
102
100
  // Also directly update iframe if initialized
@@ -128,26 +126,10 @@ onBeforeUnmount(() => {
128
126
  themeObserver?.disconnect()
129
127
  })
130
128
 
131
- // Global testing functions
132
- if (typeof window !== 'undefined') {
133
- (window as any).testRichTextTheme = () => {
134
- console.log('๐Ÿงช Testing RichText theme:', {
135
- currentInputColor: currentInputColor.value,
136
- currentTextColor: currentTextColor.value,
137
- hasInitialized: hasInitialized.value,
138
- hasIframe: !!iframe.value,
139
- cssVar: getComputedStyle(document.documentElement).getPropertyValue('--input-color')
140
- })
141
-
142
- if (hasInitialized.value) {
143
- updateIframeColors()
144
- }
145
- }
146
-
147
- (window as any).forceRichTextUpdate = () => {
148
- console.log('๐Ÿ”„ Forcing RichText update')
149
- forceUpdate.value++
150
- }
129
+ // Debug helpers โ€” only registered when debug prop is set
130
+ if (props.debug && typeof window !== 'undefined') {
131
+ ; (window as any).forceRichTextUpdate = () => { forceUpdate.value++ }
132
+ ; (window as any).debugRichText = debugShowContent
151
133
  }
152
134
 
153
135
  // Computed properties for UI control
@@ -353,59 +335,34 @@ const tablePreviewHtml = computed(() => {
353
335
  // Initialize content from modelValue
354
336
  editor.state.content = props.modelValue
355
337
 
356
- // Function to detect Hebrew text and set direction
338
+ // Matches Hebrew, Arabic, Syriac, Thaana, Arabic Supplement, Arabic Presentation Forms
339
+ const RTL_REGEX = /[\u0590-\u05FF\u0600-\u06FF\u0700-\u074F\u0750-\u077F\uFB1D-\uFDFD\uFE70-\uFEFC]/
340
+
357
341
  function detectAndSetDirection() {
358
342
  const { doc } = editor.state
359
- if (!doc?.body) {
360
- console.log('No doc.body found')
361
- return
362
- }
343
+ if (!doc?.body) { return }
363
344
 
364
- // Get all text content from body
365
345
  const allText = doc.body.textContent || ''
366
- const firstChars = allText.trim().substring(0, 10) // Check first 10 characters
367
-
368
- console.log('Checking text:', firstChars, 'Length:', firstChars.length)
369
-
370
- // Hebrew regex
371
- const hebrewRegex = /[\u0590-\u05FF]/
372
- const hasHebrew = hebrewRegex.test(firstChars)
373
-
374
- console.log('Has Hebrew:', hasHebrew)
375
-
376
- // Only change direction if it's different from current
346
+ const firstChars = allText.trim().substring(0, 20)
377
347
  const currentDir = doc.body.dir || 'ltr'
378
- const shouldBeRtl = hasHebrew
379
- const newDirection = shouldBeRtl ? 'rtl' : 'ltr'
380
-
381
- console.log('Current dir:', currentDir, 'Should be RTL:', shouldBeRtl)
348
+ const newDirection = RTL_REGEX.test(firstChars) ? 'rtl' : 'ltr'
382
349
 
383
350
  if (newDirection !== currentDir) {
384
351
  doc.body.dir = newDirection
385
352
  doc.body.style.direction = newDirection
386
-
387
- // Update all paragraphs to match the new direction
388
- const paragraphs = doc.querySelectorAll('p')
389
- paragraphs.forEach((p) => {
353
+ doc.querySelectorAll('p').forEach((p) => {
390
354
  if (!p.classList.contains('placeholder')) {
391
355
  p.setAttribute('dir', newDirection)
392
356
  }
393
357
  })
394
-
395
- console.log(`โœ… Switched to ${newDirection.toUpperCase()}`)
396
358
  }
397
359
  }
398
360
 
399
- // Function to get current direction based on content
400
361
  function getCurrentDirection() {
401
362
  const { doc } = editor.state
402
363
  if (!doc?.body) { return 'ltr' }
403
-
404
- const allText = doc.body.textContent || ''
405
- const firstChars = allText.trim().substring(0, 10)
406
- const hebrewRegex = /[\u0590-\u05FF]/
407
-
408
- return hebrewRegex.test(firstChars) ? 'rtl' : 'ltr'
364
+ const firstChars = (doc.body.textContent || '').trim().substring(0, 20)
365
+ return RTL_REGEX.test(firstChars) ? 'rtl' : 'ltr'
409
366
  }
410
367
 
411
368
  // Helper function to update content with history tracking
@@ -437,33 +394,15 @@ function updateContentWithHistory(doc: Document, skipHistory = false) {
437
394
  function debugShowContent() {
438
395
  const { doc } = editor.state
439
396
  if (doc) {
440
- console.log('=== Current Editor Content ===')
441
- console.log('HTML:', doc.body.innerHTML)
442
- console.log('=== All iframes ===')
443
397
  const iframes = doc.querySelectorAll('iframe')
444
398
  iframes.forEach((iframe, index) => {
445
- console.log(`Iframe ${index}:`, {
446
- src: iframe.src,
447
- width: iframe.width,
448
- height: iframe.height,
449
- className: iframe.className,
450
- style: iframe.style.cssText
451
- })
452
399
  })
453
- console.log('=== All figures ===')
454
400
  const figures = doc.querySelectorAll('figure')
455
401
  figures.forEach((figure, index) => {
456
- console.log(`Figure ${index}:`, {
457
- className: figure.className,
458
- innerHTML: figure.innerHTML
459
- })
460
402
  })
461
403
  }
462
404
  }
463
405
 
464
- // Make debug function available globally
465
- ; (window as any).debugRichText = debugShowContent
466
-
467
406
  // Function to show inline toolbar
468
407
  function showInlineToolbarForSelection() {
469
408
  // Check if inline toolbar should be shown
@@ -723,9 +662,10 @@ function openImageModal(existingImage: HTMLElement | null = null) {
723
662
  }
724
663
 
725
664
  if (existingImage) {
726
- // Populate form with existing image data
727
- // All images are now in figures
728
- const img = existingImage.querySelector('img')
665
+ // Handle both <figure><img></figure> (editor-created) and bare <img> (loaded content)
666
+ const img = existingImage.tagName === 'IMG'
667
+ ? existingImage as HTMLImageElement
668
+ : existingImage.querySelector('img')
729
669
  const figcaption = existingImage.querySelector('figcaption')
730
670
  const credit = existingImage.getAttribute('data-credit') || img?.getAttribute('data-credit') || ''
731
671
 
@@ -894,9 +834,13 @@ function openEmbedModal(existingEmbed: HTMLElement | null = null) {
894
834
  }
895
835
 
896
836
  if (existingEmbed) {
897
- // Populate form with existing embed data
898
- const iframe = existingEmbed.querySelector('iframe')
899
- const caption = existingEmbed.querySelector('figcaption')
837
+ // Handle both figure wrappers and bare <iframe> elements
838
+ const iframe = existingEmbed.tagName === 'IFRAME'
839
+ ? existingEmbed as HTMLIFrameElement
840
+ : existingEmbed.querySelector('iframe')
841
+ const caption = existingEmbed.tagName !== 'IFRAME'
842
+ ? existingEmbed.querySelector('figcaption')
843
+ : null
900
844
  embedForm.value = {
901
845
  src: iframe?.src || '',
902
846
  width: iframe?.width || iframe?.style.width?.replace('px', '') || '560',
@@ -951,14 +895,6 @@ function submitEmbed() {
951
895
  iframe.setAttribute('data-media-type', 'embed')
952
896
  iframe.className = 'embed-iframe'
953
897
 
954
- // Debug: Log the iframe details
955
- console.log('Creating embed iframe:', {
956
- originalSrc: embedForm.value.src,
957
- cleanUrl,
958
- width: embedForm.value.width,
959
- height: embedForm.value.height
960
- })
961
-
962
898
  // Set width and height - always set them
963
899
  iframe.width = embedForm.value.width || '560'
964
900
  iframe.height = embedForm.value.height || '315'
@@ -1003,10 +939,6 @@ function submitEmbed() {
1003
939
  selection.addRange(range)
1004
940
  }
1005
941
 
1006
- // Debug: Log the final HTML
1007
- console.log('Embed figure created:', figure.outerHTML)
1008
- console.log('Editor content updated:', doc.body.innerHTML.includes('embed-figure'))
1009
-
1010
942
  updateContentWithHistory(doc)
1011
943
  showEmbedModal.value = false
1012
944
  pendingEmbedData = null
@@ -1033,21 +965,27 @@ function openVideoModal(existingVideo: HTMLElement | null = null) {
1033
965
  }
1034
966
 
1035
967
  if (existingVideo) {
1036
- // Populate form with existing video data
968
+ // Handle both editor-internal figures (with .video-container) and bare <video>/<iframe> elements
1037
969
  const container = existingVideo.querySelector('.video-container')
970
+ const bareVideo = existingVideo.tagName === 'VIDEO' ? existingVideo as HTMLVideoElement : null
971
+ const bareIframe = existingVideo.tagName === 'IFRAME' ? existingVideo as HTMLIFrameElement : null
1038
972
  const isCustom = container?.getAttribute('data-custom-aspect-ratio') === 'true'
973
+
1039
974
  videoForm.value = {
1040
- src: container?.getAttribute('data-video-src') || '',
1041
- width: container?.getAttribute('data-width') || '',
1042
- autoplay: container?.getAttribute('data-autoplay') === 'true',
1043
- mute: container?.getAttribute('data-mute') === 'true',
1044
- controls: container?.getAttribute('data-controls') === 'true',
1045
- loop: container?.getAttribute('data-loop') === 'true',
975
+ src: container?.getAttribute('data-video-src')
976
+ || bareVideo?.src
977
+ || bareIframe?.src
978
+ || '',
979
+ width: container?.getAttribute('data-width') || (bareVideo?.style.width ?? '') || '',
980
+ autoplay: container ? container.getAttribute('data-autoplay') === 'true' : (bareVideo?.autoplay ?? false),
981
+ mute: container ? container.getAttribute('data-mute') === 'true' : (bareVideo?.muted ?? false),
982
+ controls: container ? container.getAttribute('data-controls') === 'true' : (bareVideo?.controls ?? true),
983
+ loop: container ? container.getAttribute('data-loop') === 'true' : (bareVideo?.loop ?? false),
1046
984
  aspectRatio: isCustom ? 'custom' : (container?.getAttribute('data-aspect-ratio') || '16:9'),
1047
985
  customWidth: container?.getAttribute('data-custom-width') || '',
1048
986
  customHeight: container?.getAttribute('data-custom-height') || '',
1049
- caption: existingVideo.querySelector('figcaption')?.textContent || '',
1050
- showCaption: !!existingVideo.querySelector('figcaption')
987
+ caption: existingVideo.querySelector?.('figcaption')?.textContent || '',
988
+ showCaption: !!existingVideo.querySelector?.('figcaption')
1051
989
  }
1052
990
  } else {
1053
991
  // Reset form for new video
@@ -1277,8 +1215,6 @@ function detectTableAlignment(table: HTMLTableElement): 'left' | 'center' | 'rig
1277
1215
  const { marginLeft } = table.style
1278
1216
  const { marginRight } = table.style
1279
1217
 
1280
- console.log('Table margins:', { marginLeft, marginRight })
1281
-
1282
1218
  if (marginLeft === 'auto' && marginRight === 'auto') {
1283
1219
  return 'center'
1284
1220
  } if (marginLeft === 'auto' && marginRight === '0') {
@@ -1571,21 +1507,7 @@ function createNewTable(doc: Document) {
1571
1507
  }
1572
1508
 
1573
1509
  updateContentWithHistory(doc)
1574
-
1575
- // Add edit button to the new table immediately
1576
1510
  setupTableEditButtons(doc)
1577
-
1578
- // Add edit button to the new table
1579
- setTimeout(() => {
1580
- console.log('Trying to add edit button to new table...')
1581
- if (doc && (doc as any).__addEditButtonsToTables) {
1582
- console.log('Calling __addEditButtonsToTables...')
1583
- ; (doc as any).__addEditButtonsToTables()
1584
- } else {
1585
- console.log('__addEditButtonsToTables not found, calling setupTableEditButtons...')
1586
- setupTableEditButtons(doc)
1587
- }
1588
- }, 10)
1589
1511
  }
1590
1512
 
1591
1513
  // Function to delete table
@@ -1602,8 +1524,6 @@ function deleteTable() {
1602
1524
 
1603
1525
  // Function to apply default table settings to externally created tables
1604
1526
  function applyDefaultTableSettings(table: HTMLTableElement) {
1605
- console.log('Applying default settings to table:', table)
1606
-
1607
1527
  // Apply default styles from tableForm
1608
1528
  const defaultSettings = {
1609
1529
  fixedLayout: true,
@@ -1652,8 +1572,6 @@ function applyDefaultTableSettings(table: HTMLTableElement) {
1652
1572
 
1653
1573
  // Set table direction
1654
1574
  table.dir = defaultSettings.direction
1655
-
1656
- console.log('Default settings applied successfully')
1657
1575
  }
1658
1576
 
1659
1577
  // Function to delete image
@@ -1680,6 +1598,136 @@ function deleteEmbed() {
1680
1598
  pendingEmbedData = null
1681
1599
  }
1682
1600
 
1601
+ // Serialize editor DOM to clean, vanilla HTML โ€” strips all editor chrome and converts
1602
+ // internal video placeholders to proper <video> / <iframe> tags.
1603
+ function getCleanHTML(doc: Document | null): string {
1604
+ if (!doc?.body) { return '' }
1605
+
1606
+ const clone = doc.body.cloneNode(true) as HTMLElement
1607
+
1608
+ // Strip editor-UI elements that must never appear in output
1609
+ clone.querySelectorAll('.table-edit-btn').forEach((btn) => { btn.remove() })
1610
+ clone.querySelectorAll('.media-selected').forEach((el) => { el.classList.remove('media-selected') })
1611
+ clone.querySelectorAll('.table-cell-selected').forEach((el) => { el.classList.remove('table-cell-selected') })
1612
+
1613
+ // Unwrap .table-wrapper divs: restore original table margin-based alignment
1614
+ clone.querySelectorAll('.table-wrapper').forEach((wrapper) => {
1615
+ const wrapperEl = wrapper as HTMLElement
1616
+ const parent = wrapper.parentNode
1617
+ if (!parent) { return }
1618
+
1619
+ const table = wrapper.querySelector('table') as HTMLElement | null
1620
+ if (table) {
1621
+ const { alignItems } = wrapperEl.style
1622
+ if (alignItems === 'center') {
1623
+ table.style.marginLeft = 'auto'
1624
+ table.style.marginRight = 'auto'
1625
+ } else if (alignItems === 'flex-end') {
1626
+ table.style.marginLeft = 'auto'
1627
+ table.style.marginRight = '0'
1628
+ } else {
1629
+ table.style.marginLeft = '0'
1630
+ table.style.marginRight = 'auto'
1631
+ }
1632
+ table.style.marginBottom = '1rem'
1633
+ }
1634
+
1635
+ Array.from(wrapper.childNodes).forEach(child => parent.insertBefore(child, wrapper))
1636
+ wrapper.remove()
1637
+ })
1638
+
1639
+ // Clean editor-only attributes from tables
1640
+ clone.querySelectorAll('table').forEach((table) => {
1641
+ table.removeAttribute('data-edit-button-added')
1642
+ })
1643
+
1644
+ // Convert internal video placeholder divs โ†’ real <video> or <iframe> HTML
1645
+ clone.querySelectorAll('.video-container[data-component="BglVideo"]').forEach((container) => {
1646
+ const el = container as HTMLElement
1647
+ const src = el.getAttribute('data-video-src') || ''
1648
+ if (!src) { return }
1649
+
1650
+ const autoplay = el.getAttribute('data-autoplay') === 'true'
1651
+ const muted = el.getAttribute('data-mute') === 'true'
1652
+ const controls = el.getAttribute('data-controls') !== 'false'
1653
+ const loop = el.getAttribute('data-loop') === 'true'
1654
+ const aspectRatio = el.getAttribute('data-aspect-ratio') || '16:9'
1655
+ const width = el.getAttribute('data-width') || ''
1656
+
1657
+ const widthStyle = width
1658
+ ? `width: ${/[%pxvwemr]/.test(width) ? width : `${width}px`};`
1659
+ : 'width: 100%;'
1660
+
1661
+ const [ratioW, ratioH] = aspectRatio.split(':').map(Number)
1662
+ const paddingBottom = ratioW && ratioH ? `${(ratioH / ratioW) * 100}%` : '56.25%'
1663
+
1664
+ const makeResponsiveIframe = (iframeSrc: string): HTMLElement => {
1665
+ const wrapper = document.createElement('div')
1666
+ wrapper.style.cssText = `${widthStyle} position: relative; padding-bottom: ${paddingBottom}; height: 0; overflow: hidden;`
1667
+ const iframe = document.createElement('iframe')
1668
+ iframe.src = iframeSrc
1669
+ iframe.frameBorder = '0'
1670
+ iframe.setAttribute('allowfullscreen', '')
1671
+ iframe.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%;'
1672
+ wrapper.appendChild(iframe)
1673
+ return wrapper
1674
+ }
1675
+
1676
+ let newEl: HTMLElement | null = null
1677
+
1678
+ if (src.includes('youtube.com') || src.includes('youtu.be')) {
1679
+ let embedUrl = src
1680
+ if (src.includes('youtube.com/watch?v=')) {
1681
+ const videoId = src.split('watch?v=')[1]?.split('&')[0]
1682
+ const params = []
1683
+ if (autoplay) { params.push('autoplay=1') }
1684
+ if (muted) { params.push('mute=1') }
1685
+ if (loop) { params.push(`loop=1&playlist=${videoId}`) }
1686
+ if (!controls) { params.push('controls=0') }
1687
+ embedUrl = `https://www.youtube.com/embed/${videoId}${params.length ? `?${params.join('&')}` : ''}`
1688
+ } else if (src.includes('youtu.be/')) {
1689
+ const videoId = src.split('youtu.be/')[1]?.split('?')[0]
1690
+ embedUrl = `https://www.youtube.com/embed/${videoId}`
1691
+ } else if (src.includes('youtube.com/shorts/')) {
1692
+ const videoId = src.split('shorts/')[1]?.split('?')[0]
1693
+ embedUrl = `https://www.youtube.com/embed/${videoId}`
1694
+ }
1695
+ newEl = makeResponsiveIframe(embedUrl)
1696
+ } else if (src.includes('vimeo.com')) {
1697
+ const match = src.match(/vimeo\.com\/(\d+)/)
1698
+ const embedUrl = match ? `https://player.vimeo.com/video/${match[1]}` : src
1699
+ newEl = makeResponsiveIframe(embedUrl)
1700
+ } else if (src.includes('<iframe')) {
1701
+ const srcMatch = src.match(/src=["']([^"']+)["']/)
1702
+ if (srcMatch) { newEl = makeResponsiveIframe(srcMatch[1]) }
1703
+ } else {
1704
+ // Direct video file
1705
+ const video = document.createElement('video')
1706
+ video.src = src
1707
+ if (controls) { video.setAttribute('controls', '') }
1708
+ if (autoplay) { video.setAttribute('autoplay', '') }
1709
+ if (muted) { video.setAttribute('muted', '') }
1710
+ if (loop) { video.setAttribute('loop', '') }
1711
+ video.style.cssText = `${widthStyle} height: auto; display: block;`
1712
+ newEl = video
1713
+ }
1714
+
1715
+ if (newEl) { el.parentNode?.replaceChild(newEl, el) }
1716
+ })
1717
+
1718
+ // Strip internal placeholder content from video figures (the bgl_vid wrapper)
1719
+ // that remains after video-container conversion
1720
+ clone.querySelectorAll('.bgl_vid').forEach((el) => { el.remove() })
1721
+
1722
+ // Strip editor-only classes from <figure> elements so output is pure semantic HTML
1723
+ clone.querySelectorAll('figure').forEach((fig) => {
1724
+ fig.classList.remove('image-figure', 'video-figure', 'embed-figure')
1725
+ if (fig.classList.length === 0) { fig.removeAttribute('class') }
1726
+ })
1727
+
1728
+ return clone.innerHTML
1729
+ }
1730
+
1683
1731
  // Expose openLinkModal to editor state
1684
1732
  ; (editor.state as any).openLinkModal = openLinkModal
1685
1733
  ; (editor.state as any).showTooltipMessage = showTooltipMessage
@@ -1886,10 +1934,7 @@ function deleteRow() {
1886
1934
  const tbody = table.querySelector('tbody') || table
1887
1935
  const rows = tbody.querySelectorAll('tr')
1888
1936
 
1889
- if (rows.length <= 1) {
1890
- alert('Cannot delete the last row')
1891
- return
1892
- }
1937
+ if (rows.length <= 1) { return }
1893
1938
 
1894
1939
  row.remove()
1895
1940
  updateContentWithHistory(doc)
@@ -1958,10 +2003,7 @@ function deleteColumn() {
1958
2003
 
1959
2004
  // Check if this is the only column
1960
2005
  const firstRow = table.querySelector('tr')
1961
- if (firstRow && firstRow.cells.length <= 1) {
1962
- alert('Cannot delete the last column')
1963
- return
1964
- }
2006
+ if (firstRow && firstRow.cells.length <= 1) { return }
1965
2007
 
1966
2008
  // Remove cell from each row at the same index
1967
2009
  const rows = table.querySelectorAll('tr')
@@ -2029,7 +2071,7 @@ function handleTableContextMenu(event: MouseEvent) {
2029
2071
 
2030
2072
  // Expose debug methods if debug mode is enabled
2031
2073
  const debugMethods = computed(() => editor.state.debug)
2032
- const hasRTL = computed(() => /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/.test(props.modelValue))
2074
+ const hasRTL = computed(() => RTL_REGEX.test(props.modelValue))
2033
2075
 
2034
2076
  // Computed property to handle height prop
2035
2077
  const editorHeight = computed(() => {
@@ -2047,17 +2089,11 @@ onUnmounted(() => {
2047
2089
  })
2048
2090
 
2049
2091
  function setupTableEditButtons(doc: Document) {
2050
- console.log('setupTableEditButtons called with doc:', doc)
2051
-
2052
2092
  // Simple function to add edit buttons to all tables
2053
2093
  function addEditButtonsToTables() {
2054
- console.log('Adding edit buttons to tables...')
2055
2094
  const tables = doc.querySelectorAll('table:not([data-edit-button-added])') as NodeListOf<HTMLTableElement>
2056
- console.log('Found tables:', tables.length)
2057
2095
 
2058
2096
  tables.forEach((table, index) => {
2059
- console.log(`Processing table ${index + 1}`)
2060
-
2061
2097
  // Create edit button as a span element instead of button
2062
2098
  const editBtn = doc.createElement('span')
2063
2099
  editBtn.className = 'table-edit-btn'
@@ -2071,7 +2107,6 @@ function setupTableEditButtons(doc: Document) {
2071
2107
  editBtn.style.userSelect = 'none'
2072
2108
 
2073
2109
  editBtn.addEventListener('click', (e) => {
2074
- console.log('Edit button clicked!')
2075
2110
  e.preventDefault()
2076
2111
  e.stopPropagation()
2077
2112
  e.stopImmediatePropagation()
@@ -2137,8 +2172,6 @@ function setupTableEditButtons(doc: Document) {
2137
2172
  table.style.marginBottom = '0'
2138
2173
 
2139
2174
  table.setAttribute('data-edit-button-added', 'true')
2140
-
2141
- console.log(`Added edit button to table ${index + 1}`)
2142
2175
  })
2143
2176
  }
2144
2177
 
@@ -2362,7 +2395,7 @@ function setupAutoWrapping(doc: Document) {
2362
2395
  }
2363
2396
 
2364
2397
  // For RTL text, be more careful about splitting
2365
- const isRTLContent = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/.test(paragraph.textContent || '')
2398
+ const isRTLContent = RTL_REGEX.test(paragraph.textContent || '')
2366
2399
 
2367
2400
  // Prevent default and handle manually to avoid browser inconsistencies
2368
2401
  e.preventDefault()
@@ -2374,14 +2407,6 @@ function setupAutoWrapping(doc: Document) {
2374
2407
 
2375
2408
  // Debug logging to understand the issue
2376
2409
  if (props.debug) {
2377
- console.log('Enter pressed:', {
2378
- startContainer: range.startContainer,
2379
- startOffset: range.startOffset,
2380
- textBeforeCursor,
2381
- textAfterCursor,
2382
- paragraphContent: paragraph.textContent,
2383
- isRTLContent
2384
- })
2385
2410
  }
2386
2411
 
2387
2412
  // Create new paragraph with proper direction
@@ -2459,20 +2484,15 @@ function setupAutoWrapping(doc: Document) {
2459
2484
  else if (addedNode.nodeType === Node.ELEMENT_NODE) {
2460
2485
  const element = addedNode as HTMLElement
2461
2486
  if (element.tagName === 'TABLE') {
2462
- console.log('MutationObserver detected new table:', element)
2463
2487
  setTimeout(() => {
2464
- console.log('Applying default table settings...')
2465
2488
  applyDefaultTableSettings(element as HTMLTableElement)
2466
- console.log('Adding edit buttons to newly detected table...')
2467
2489
  setupTableEditButtons(doc)
2468
2490
  }, 100)
2469
2491
  }
2470
2492
  // Apply direction to blockquotes and lists
2471
2493
  else if (['BLOCKQUOTE', 'UL', 'OL'].includes(element.tagName)) {
2472
- console.log('MutationObserver detected new', element.tagName, ':', element)
2473
2494
  if (!element.dir) {
2474
2495
  element.dir = doc.body.dir || 'ltr'
2475
- console.log('Applied direction to', element.tagName, ':', element.dir)
2476
2496
  }
2477
2497
 
2478
2498
  // Also apply direction to list items if it's a list
@@ -2481,7 +2501,6 @@ function setupAutoWrapping(doc: Document) {
2481
2501
  listItems.forEach((li) => {
2482
2502
  if (!li.dir) {
2483
2503
  li.dir = element.dir
2484
- console.log('Applied direction to new list item:', li.dir)
2485
2504
  }
2486
2505
  })
2487
2506
  }
@@ -2507,37 +2526,57 @@ function setupAutoWrapping(doc: Document) {
2507
2526
  doc.addEventListener('click', (e) => {
2508
2527
  const target = e.target as HTMLElement
2509
2528
  const link = target.closest('a')
2510
- const videoFigure = target.closest('.video-figure')
2511
- const imageFigure = target.closest('.image-figure')
2512
- const embedFigure = target.closest('.embed-figure')
2513
2529
  const table = target.closest('table')
2514
2530
 
2531
+ // Resolve media element to edit. Check figure wrappers first (editor-created),
2532
+ // then fall back to bare primitive elements (existing/loaded content).
2533
+ const figure = target.closest('figure') as HTMLElement | null
2534
+ let videoFigure: HTMLElement | null = null
2535
+ let imageFigure: HTMLElement | null = null
2536
+ let embedFigure: HTMLElement | null = null
2537
+
2538
+ if (figure?.querySelector('.video-container, video')) {
2539
+ videoFigure = figure
2540
+ } else if (target.closest('.video-figure')) {
2541
+ videoFigure = target.closest('.video-figure') as HTMLElement
2542
+ } else if (figure?.querySelector('img')) {
2543
+ imageFigure = figure
2544
+ } else if (target.closest('.image-figure')) {
2545
+ imageFigure = target.closest('.image-figure') as HTMLElement
2546
+ } else if (target.tagName === 'IMG') {
2547
+ // Bare <img> not in a figure โ€” pass it directly
2548
+ imageFigure = target
2549
+ } else if (figure?.querySelector('iframe')) {
2550
+ embedFigure = figure
2551
+ } else if (target.closest('.embed-figure')) {
2552
+ embedFigure = target.closest('.embed-figure') as HTMLElement
2553
+ } else if (target.tagName === 'VIDEO') {
2554
+ videoFigure = target
2555
+ } else if (target.tagName === 'IFRAME') {
2556
+ embedFigure = target
2557
+ }
2558
+
2559
+ // Clear previous media selection
2560
+ doc.querySelectorAll('.media-selected').forEach((el) => { el.classList.remove('media-selected') })
2561
+
2515
2562
  if (table) {
2516
- // Table clicks are handled only by edit button, not by direct clicks
2517
- // No action needed for regular table clicks
2518
- }
2519
- else if (videoFigure) {
2563
+ // Table clicks handled only by edit button
2564
+ } else if (videoFigure) {
2520
2565
  e.preventDefault()
2521
2566
  e.stopPropagation()
2522
-
2523
- // Open video modal for editing
2524
- openVideoModal(videoFigure as HTMLElement)
2525
- }
2526
- else if (imageFigure) {
2567
+ videoFigure.classList.add('media-selected')
2568
+ openVideoModal(videoFigure)
2569
+ } else if (imageFigure) {
2527
2570
  e.preventDefault()
2528
2571
  e.stopPropagation()
2529
-
2530
- // Open image modal for editing
2531
- openImageModal(imageFigure as HTMLElement)
2532
- }
2533
- else if (embedFigure) {
2572
+ imageFigure.classList.add('media-selected')
2573
+ openImageModal(imageFigure)
2574
+ } else if (embedFigure) {
2534
2575
  e.preventDefault()
2535
2576
  e.stopPropagation()
2536
-
2537
- // Open embed modal for editing
2538
- openEmbedModal(embedFigure as HTMLElement)
2539
- }
2540
- else if (link && link.href) {
2577
+ embedFigure.classList.add('media-selected')
2578
+ openEmbedModal(embedFigure)
2579
+ } else if (link && link.href) {
2541
2580
  e.preventDefault()
2542
2581
  e.stopPropagation()
2543
2582
 
@@ -2684,10 +2723,88 @@ async function initEditor() {
2684
2723
  background: #0056b3;
2685
2724
  }
2686
2725
  .richtext-editor-content blockquote {
2687
- padding: 8px !important;
2688
- background: #f4f4f4 !important;
2689
- border-inline-start: 4px solid #ccc !important;
2690
- }
2726
+ padding: 8px !important;
2727
+ background: #f4f4f4 !important;
2728
+ border-inline-start: 4px solid #ccc !important;
2729
+ }
2730
+
2731
+ /* โ”€โ”€ Media editing affordances โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */
2732
+ /* Figures wrapping media: ::after badge + hover ring.
2733
+ No display override โ€” <figure> is already block, <img>/<video> keep their natural display. */
2734
+ figure:has(img),
2735
+ figure:has(video),
2736
+ figure:has(iframe),
2737
+ figure.image-figure,
2738
+ figure.video-figure,
2739
+ figure.embed-figure {
2740
+ position: relative;
2741
+ cursor: pointer;
2742
+ }
2743
+
2744
+ figure:has(img)::after,
2745
+ figure:has(video)::after,
2746
+ figure:has(iframe)::after,
2747
+ figure.image-figure::after,
2748
+ figure.video-figure::after,
2749
+ figure.embed-figure::after {
2750
+ content: 'โœŽ Edit';
2751
+ position: absolute;
2752
+ top: 8px;
2753
+ right: 8px;
2754
+ background: rgba(0, 0, 0, 0.72);
2755
+ color: #fff;
2756
+ padding: 4px 10px;
2757
+ border-radius: 4px;
2758
+ font-size: 12px;
2759
+ font-family: sans-serif;
2760
+ line-height: 1.5;
2761
+ opacity: 0;
2762
+ transition: opacity 0.18s ease;
2763
+ pointer-events: none;
2764
+ }
2765
+
2766
+ figure:has(img):hover::after,
2767
+ figure:has(video):hover::after,
2768
+ figure:has(iframe):hover::after,
2769
+ figure.image-figure:hover::after,
2770
+ figure.video-figure:hover::after,
2771
+ figure.embed-figure:hover::after {
2772
+ opacity: 1;
2773
+ }
2774
+
2775
+ figure:has(img):hover,
2776
+ figure:has(video):hover,
2777
+ figure:has(iframe):hover,
2778
+ figure.image-figure:hover,
2779
+ figure.video-figure:hover,
2780
+ figure.embed-figure:hover {
2781
+ outline: 2px solid rgba(0, 123, 204, 0.45);
2782
+ outline-offset: 2px;
2783
+ }
2784
+
2785
+ /* Bare <img> and <video> not inside a <figure> โ€” show outline + pointer only.
2786
+ ::after is not available on replaced/void elements. */
2787
+ img:not(figure img),
2788
+ video:not(figure video) {
2789
+ cursor: pointer;
2790
+ }
2791
+
2792
+ img:not(figure img):hover,
2793
+ video:not(figure video):hover {
2794
+ outline: 2px solid rgba(0, 123, 204, 0.45);
2795
+ outline-offset: 2px;
2796
+ }
2797
+
2798
+ /* Selected state โ€” applied via JS on click, stripped by getCleanHTML() */
2799
+ .media-selected {
2800
+ outline: 2px solid #007bff !important;
2801
+ outline-offset: 2px !important;
2802
+ }
2803
+
2804
+ .media-selected::after {
2805
+ opacity: 1 !important;
2806
+ background: #007bff !important;
2807
+ }
2691
2808
  ` // Create a complete HTML document with proper doctype and meta tags
2692
2809
  const initialContent = props.modelValue || (props.placeholder ? `<p class="placeholder">${props.placeholder}</p>` : '')
2693
2810
  const htmlContent = `
@@ -2741,13 +2858,10 @@ async function initEditor() {
2741
2858
 
2742
2859
  // Apply direction to existing blockquotes and lists
2743
2860
  const blockElements = doc.body.querySelectorAll('blockquote, ul, ol')
2744
- console.log('Found existing block elements:', blockElements.length)
2745
2861
  blockElements.forEach((element) => {
2746
2862
  const htmlElement = element as HTMLElement
2747
- console.log('Processing element:', htmlElement.tagName, 'current dir:', htmlElement.dir)
2748
2863
  if (!htmlElement.dir) {
2749
2864
  htmlElement.dir = doc.body.dir || 'ltr'
2750
- console.log('Applied direction to existing', htmlElement.tagName, ':', htmlElement.dir)
2751
2865
  }
2752
2866
 
2753
2867
  // Also apply direction to list items
@@ -2757,7 +2871,6 @@ async function initEditor() {
2757
2871
  const listItem = li as HTMLElement
2758
2872
  if (!listItem.dir) {
2759
2873
  listItem.dir = htmlElement.dir
2760
- console.log('Applied direction to list item:', listItem.dir)
2761
2874
  }
2762
2875
  })
2763
2876
  }
@@ -2889,9 +3002,7 @@ async function initEditor() {
2889
3002
  setupAutoWrapping(doc)
2890
3003
 
2891
3004
  // Setup table edit buttons
2892
- console.log('About to setup table edit buttons...')
2893
3005
  setupTableEditButtons(doc)
2894
- console.log('Table edit buttons setup completed')
2895
3006
 
2896
3007
  // Update state.content after cleanup
2897
3008
  updateContentWithHistory(doc)
@@ -2936,16 +3047,7 @@ async function initEditor() {
2936
3047
 
2937
3048
  // Function to update CSS colors in the iframe
2938
3049
  function updateIframeColors() {
2939
- console.log('๐ŸŽฏ updateIframeColors called', {
2940
- hasIframe: !!iframe.value,
2941
- hasContentDocument: !!iframe.value?.contentDocument,
2942
- hasInitialized: hasInitialized.value
2943
- })
2944
-
2945
- if (!iframe.value?.contentDocument || iframe.value.contentDocument === null) {
2946
- console.warn('โŒ No iframe or contentDocument')
2947
- return
2948
- }
3050
+ if (!iframe.value?.contentDocument) { return }
2949
3051
 
2950
3052
  const doc = iframe.value.contentDocument
2951
3053
  let styleElement = doc.getElementById('editor-theme-styles') as HTMLStyleElement
@@ -2954,7 +3056,6 @@ function updateIframeColors() {
2954
3056
  styleElement = doc.createElement('style')
2955
3057
  styleElement.id = 'editor-theme-styles'
2956
3058
  doc.head.appendChild(styleElement)
2957
- console.log('โœ… Created new style element')
2958
3059
  }
2959
3060
 
2960
3061
  const fontSize = props.fontSize ? (typeof props.fontSize === 'number' ? `${props.fontSize}px` : props.fontSize) : '16px'
@@ -2977,45 +3078,32 @@ function updateIframeColors() {
2977
3078
  `
2978
3079
 
2979
3080
  styleElement.textContent = css
2980
- console.log('๐ŸŽจ Updated iframe colors:', {
2981
- inputColor: currentInputColor.value,
2982
- textColor: currentTextColor.value,
2983
- css: css.trim()
2984
- })
2985
3081
  }
2986
3082
 
2987
3083
  // Watch for theme changes and update iframe colors
2988
- watch([currentInputColor, currentTextColor], (newValues, oldValues) => {
2989
- console.log('๐Ÿ”„ Colors changed:', {
2990
- newValues,
2991
- oldValues,
2992
- hasInitialized: hasInitialized.value
2993
- })
2994
-
3084
+ watch([currentInputColor, currentTextColor], () => {
2995
3085
  if (hasInitialized.value) {
2996
3086
  updateIframeColors()
2997
3087
  }
2998
3088
  }, { flush: 'post' })
2999
3089
 
3000
- // Reset initialization state when content changes significantly
3090
+ // Reset initialization state when content changes significantly.
3091
+ // Compare incoming value against the CLEAN output (not raw DOM innerHTML) to avoid
3092
+ // false positives caused by editor chrome (table wrappers, edit buttons, etc.)
3001
3093
  watch(() => props.modelValue, (newValue, oldValue) => {
3002
- if (newValue !== editor.state.content) {
3003
- // Only reset if content change is significant (not just minor edits)
3094
+ const cleanCurrent = editor.state.doc ? getCleanHTML(editor.state.doc ?? null) : ''
3095
+ if (newValue !== cleanCurrent) {
3004
3096
  if (!oldValue || Math.abs(newValue.length - oldValue.length) > 50) {
3005
3097
  hasInitialized.value = false
3006
- // For external changes, update content directly but then push to history
3007
3098
  editor.state.content = newValue
3008
3099
  editor.updateState.content('html')
3009
- // Add this external change to history after a brief delay
3010
3100
  setTimeout(() => {
3011
3101
  if (editor.state.doc) {
3012
3102
  updateContentWithHistory(editor.state.doc)
3013
- // Also setup table edit buttons for any new tables
3014
3103
  setupTableEditButtons(editor.state.doc)
3015
3104
  }
3016
3105
  }, 100)
3017
3106
  } else {
3018
- // For minor changes, still check for new tables
3019
3107
  setTimeout(() => {
3020
3108
  if (editor.state.doc) {
3021
3109
  setupTableEditButtons(editor.state.doc)
@@ -3025,11 +3113,12 @@ watch(() => props.modelValue, (newValue, oldValue) => {
3025
3113
  }
3026
3114
  })
3027
3115
 
3028
- watch(() => editor.state.content, (newValue) => {
3116
+ // Always emit clean, vanilla HTML โ€” never the editor's internal DOM format
3117
+ watch(() => editor.state.content, () => {
3029
3118
  if (editor.state.doc?.body) {
3030
3119
  editor.state.doc.body.dir = hasRTL.value ? 'rtl' : 'ltr'
3031
3120
  }
3032
- emit('update:modelValue', newValue)
3121
+ emit('update:modelValue', getCleanHTML(editor.state.doc ?? null))
3033
3122
  })
3034
3123
 
3035
3124
  // Watch table alignment changes and update the table immediately
@@ -3076,6 +3165,16 @@ defineExpose({
3076
3165
  editor,
3077
3166
  commands
3078
3167
  })
3168
+
3169
+ const imgTransform = computed({
3170
+ get: () => {
3171
+ return imageForm.value.src
3172
+ },
3173
+ set: (newVal: string) => {
3174
+ if (!newVal || imageForm.value.src === newVal) return
3175
+ imageForm.value.src = pathKeyToURL(newVal)
3176
+ }
3177
+ })
3079
3178
  </script>
3080
3179
 
3081
3180
  <template>
@@ -3270,6 +3369,7 @@ defineExpose({
3270
3369
  v-model:visible="showImageModal" :title="pendingImageData?.existingImage ? 'Edit Image' : 'Insert Image'"
3271
3370
  width="500"
3272
3371
  >
3372
+ <UploadInput v-model="imgTransform" label="Image" type="image" @keydown.enter="submitImage" />
3273
3373
  <TextInput
3274
3374
  v-model="imageForm.src" label="Image URL" type="url" placeholder="https://example.com/image.jpg"
3275
3375
  @keydown.enter="submitImage"
@@ -3334,7 +3434,10 @@ defineExpose({
3334
3434
  </template>
3335
3435
  </Modal>
3336
3436
  <!-- Video Modal -->
3337
- <Modal v-model:visible="showVideoModal" title="Insert Video" width="500">
3437
+ <Modal
3438
+ v-model:visible="showVideoModal" :title="pendingVideoData?.existingVideo ? 'Edit Video' : 'Insert Video'"
3439
+ width="500"
3440
+ >
3338
3441
  <div class="grid gap-0">
3339
3442
  <TextInput
3340
3443
  v-model="videoForm.src" label="Video URL or Embed Code" type="url"
@@ -3578,6 +3681,7 @@ defineExpose({
3578
3681
  min-width: calc(var(--input-height) * 3);
3579
3682
  width: 100%;
3580
3683
  }
3684
+
3581
3685
  .rich-text-editor--basic .content-area:hover {
3582
3686
  outline-color: rgba(0, 0, 0, 0.05);
3583
3687
  box-shadow: inset 0 0 8px #00000018;