@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
@@ -4,6 +4,10 @@ import type { CustomToolbarButton, RichTextExtension, RichTextToolbarApi, Toolba
4
4
  import type { DropdownItem } from '../../../../index'
5
5
  import { CodeEditor, copyText, Btn, ContextMenu, Dialog, Divider, BglVideo, Icon, Card, ColorInput, pathKeyToURL, usePointerSession } from '../../../../index'
6
6
  import { watch, onUnmounted, onBeforeUnmount, onMounted, ref, computed, useAttrs, inject } from 'vue'
7
+ import { getRichTextMetrics } from './utils/contentMetrics'
8
+ import { insertPastedContent } from './utils/sanitizePaste'
9
+ import { buildImageFigure, createDefaultImageData, hydrateImageFigures, readImageData } from './utils/image'
10
+ import { buildVideoFigure, createDefaultVideoData, hydrateVideoFigures, readVideoData, serializeVideoFigures } from './utils/video'
7
11
  import CheckInput from '../CheckInput.vue'
8
12
  import NumberInput from '../NumberInput.vue'
9
13
  import SelectBtn from '../SelectBtn.vue'
@@ -20,6 +24,7 @@ import {
20
24
  deleteColumn as tableDeleteColumn,
21
25
  deleteRow as tableDeleteRow,
22
26
  findCellCoords,
27
+ focusTableCell,
23
28
  insertColumn as tableInsertColumn,
24
29
  mergeCells as tableMergeCells,
25
30
  moveColumn as tableMoveColumn,
@@ -30,7 +35,6 @@ import {
30
35
  import type { BagelInputShellProps } from '../bagelInputShell'
31
36
  import { useBagelInputShell } from '../bagelInputShell'
32
37
  import { RICH_TEXT_EXTENSION_REGISTRY_KEY } from './richTextExtensions'
33
- import { normalizeRichTextLink, sanitizeRichTextLinks } from './utils/linkPolicy'
34
38
 
35
39
  // Disable automatic inheritance of non-prop attributes
36
40
  defineOptions({
@@ -68,11 +72,17 @@ const props = defineProps<{
68
72
  textColor?: string
69
73
  // Font size
70
74
  fontSize?: number | string
75
+ // Optional document metrics and limits
76
+ showCounter?: boolean
77
+ maxLength?: number
78
+ maxWords?: number
71
79
  // Field-specific CSS injected into the editor iframe.
72
80
  contentStyles?: string
81
+ // Optional image uploader. Must resolve to a persistent path key or URL.
82
+ uploadImage?: (file: File) => Promise<string>
73
83
  } & BagelInputShellProps>()
74
84
 
75
- const emit = defineEmits(['update:modelValue'])
85
+ const emit = defineEmits(['update:modelValue', 'limit-exceeded'])
76
86
 
77
87
  const { shellClass, shellStyle } = useBagelInputShell(props)
78
88
  const extensionRegistry = inject(RICH_TEXT_EXTENSION_REGISTRY_KEY, new Map())
@@ -108,6 +118,35 @@ const effectiveContentStyles = computed(() => [
108
118
  ...resolvedExtensions.value.map(extension => extension.contentStyles),
109
119
  ].filter(Boolean).join('\n'))
110
120
 
121
+ function collectDocumentColors(property: 'color' | 'background-color'): string[] {
122
+ if (!normalizedModelValue.value || typeof document === 'undefined') { return [] }
123
+ const parsed = new DOMParser().parseFromString(normalizedModelValue.value, 'text/html')
124
+ const colors = new Set<string>()
125
+
126
+ parsed.body.querySelectorAll<HTMLElement>('[style]').forEach((element) => {
127
+ const value = element.style.getPropertyValue(property).trim()
128
+ if (value && value !== 'transparent' && value !== 'inherit') {
129
+ colors.add(value)
130
+ }
131
+ })
132
+
133
+ if (property === 'background-color') {
134
+ parsed.body.querySelectorAll<HTMLElement>('[bgcolor]').forEach((element) => {
135
+ const value = element.getAttribute('bgcolor')?.trim()
136
+ if (value) {
137
+ colors.add(value)
138
+ }
139
+ })
140
+ }
141
+
142
+ return Array.from(colors).slice(0, 12)
143
+ }
144
+
145
+ const documentColors = computed(() => Array.from(new Set([
146
+ ...collectDocumentColors('color'),
147
+ ...collectDocumentColors('background-color'),
148
+ ])).slice(0, 12))
149
+
111
150
  function updateCustomToolbarStyles() {
112
151
  const { doc } = editor.state
113
152
  if (!doc) return
@@ -147,6 +186,25 @@ const editor = useEditor()
147
186
  const isInitializing = ref(false)
148
187
  const hasInitialized = ref(false)
149
188
  const commands = useCommands(editor.state, editor.state.debug)
189
+ const documentMetrics = computed(() => {
190
+ void editor.state.content
191
+ return getRichTextMetrics(normalizedModelValue.value, editor.state.doc)
192
+ })
193
+ const characterCount = computed(() => documentMetrics.value.characters)
194
+ const wordCount = computed(() => documentMetrics.value.words)
195
+ const characterLimitExceeded = computed(() => Boolean(props.maxLength && characterCount.value > props.maxLength))
196
+ const wordLimitExceeded = computed(() => Boolean(props.maxWords && wordCount.value > props.maxWords))
197
+ const hasExceededLimit = computed(() => characterLimitExceeded.value || wordLimitExceeded.value)
198
+ const showDocumentCounter = computed(() => props.showCounter || Boolean(props.maxLength) || Boolean(props.maxWords))
199
+ const counterLabel = computed(() => {
200
+ const characters = props.maxLength
201
+ ? `${characterCount.value} / ${props.maxLength} characters`
202
+ : `${characterCount.value} characters`
203
+ const words = props.maxWords
204
+ ? `${wordCount.value} / ${props.maxWords} words`
205
+ : `${wordCount.value} words`
206
+ return `${characters} · ${words}`
207
+ })
150
208
 
151
209
  function setBlock(tagName: string, className?: string, removeClasses: string[] = []) {
152
210
  const { doc } = editor.state
@@ -356,17 +414,8 @@ let pendingLinkData: {
356
414
 
357
415
  // Image modal state
358
416
  const showImageModal = ref(false)
359
- const imageForm = ref({
360
- src: '',
361
- alt: '',
362
- width: '',
363
- height: '',
364
- credit: '',
365
- figcaption: false,
366
- align: 'full', // full, start, end
367
- link: '',
368
- linkNewTab: false
369
- })
417
+ const imageForm = ref(createDefaultImageData())
418
+ const imageUploadError = ref('')
370
419
  let pendingImageData: {
371
420
  selection: Selection
372
421
  range: Range
@@ -394,19 +443,7 @@ const inlineToolbarSelection = ref<Selection | null>(null)
394
443
 
395
444
  // Video modal state
396
445
  const showVideoModal = ref(false)
397
- const videoForm = ref({
398
- src: '',
399
- width: '',
400
- autoplay: false,
401
- mute: false,
402
- controls: true,
403
- loop: false,
404
- aspectRatio: '16:9',
405
- customWidth: '',
406
- customHeight: '',
407
- caption: '',
408
- showCaption: false
409
- })
446
+ const videoForm = ref(createDefaultVideoData())
410
447
  let pendingVideoData: {
411
448
  selection: Selection
412
449
  range: Range
@@ -697,7 +734,7 @@ function showTooltipMessage(message: string, x?: number, y?: number) {
697
734
  function openLinkModal(selection: Selection, range: Range, existingLink: HTMLAnchorElement | null) {
698
735
  pendingLinkData = { selection, range, existingLink }
699
736
  linkForm.value = {
700
- url: existingLink?.getAttribute('href') || '',
737
+ url: existingLink?.href || '',
701
738
  title: existingLink?.getAttribute('title') || '',
702
739
  openInNewTab: existingLink?.target !== '_self'
703
740
  }
@@ -706,19 +743,17 @@ function openLinkModal(selection: Selection, range: Range, existingLink: HTMLAnc
706
743
 
707
744
  // Function to submit link
708
745
  function submitLink() {
709
- const normalizedUrl = normalizeRichTextLink(linkForm.value.url)
710
- if (!pendingLinkData || !normalizedUrl) { return }
746
+ if (!pendingLinkData || !linkForm.value.url) { return }
711
747
 
712
748
  const { selection, range, existingLink } = pendingLinkData
713
- const { title, openInNewTab } = linkForm.value
749
+ const { url, title, openInNewTab } = linkForm.value
714
750
  const { doc } = editor.state
715
751
 
716
752
  if (!doc) { return }
717
- linkForm.value.url = normalizedUrl
718
753
 
719
754
  if (existingLink) {
720
755
  // Update existing link
721
- existingLink.setAttribute('href', normalizedUrl)
756
+ existingLink.href = url
722
757
  if (title.trim()) {
723
758
  existingLink.title = title.trim()
724
759
  } else {
@@ -734,7 +769,7 @@ function submitLink() {
734
769
  // Create new link
735
770
  try {
736
771
  const anchor = doc.createElement('a')
737
- anchor.setAttribute('href', normalizedUrl)
772
+ anchor.href = url
738
773
  if (title.trim()) {
739
774
  anchor.title = title.trim()
740
775
  }
@@ -769,8 +804,14 @@ function submitLink() {
769
804
 
770
805
  // Function to visit link
771
806
  function visitLink() {
772
- const url = normalizeRichTextLink(linkForm.value.url)
773
- if (!url) { return }
807
+ if (!linkForm.value.url || !isValidUrl(linkForm.value.url)) { return }
808
+
809
+ let url = linkForm.value.url.trim()
810
+
811
+ // Add https:// if no protocol is specified
812
+ if (!url.match(/^https?:\/\//)) {
813
+ url = `https://${url}`
814
+ }
774
815
 
775
816
  if (linkForm.value.openInNewTab) {
776
817
  window.open(url, '_blank', 'noopener,noreferrer')
@@ -781,13 +822,27 @@ function visitLink() {
781
822
 
782
823
  // Function to validate URL
783
824
  function isValidUrl(url: string): boolean {
784
- return normalizeRichTextLink(url) !== null
825
+ if (!url || url.trim() === '') { return false }
826
+
827
+ try {
828
+ URL.canParse(url)
829
+ return true
830
+ } catch {
831
+ // Try with https:// prefix if it's missing
832
+ try {
833
+ URL.canParse(`https://${url}`)
834
+ return true
835
+ } catch {
836
+ return false
837
+ }
838
+ }
785
839
  }
786
840
 
787
841
  // Function to open image modal
788
842
  function openImageModal(existingImage: HTMLElement | null = null) {
789
843
  const { doc } = editor.state
790
844
  if (!doc) { return }
845
+ imageUploadError.value = ''
791
846
 
792
847
  // Get current selection for new images
793
848
  if (!existingImage) {
@@ -805,51 +860,18 @@ function openImageModal(existingImage: HTMLElement | null = null) {
805
860
  }
806
861
 
807
862
  if (existingImage) {
808
- // Handle both <figure><img></figure> (editor-created) and bare <img> (loaded content)
809
- const img = existingImage.tagName === 'IMG'
810
- ? existingImage as HTMLImageElement
811
- : existingImage.querySelector('img')
812
- const figcaption = existingImage.querySelector('figcaption')
813
- const credit = existingImage.getAttribute('data-credit') || img?.getAttribute('data-credit') || ''
814
-
815
- // Read alignment from the figure (or bare img) data-align attribute
816
- const alignSource = existingImage.tagName === 'IMG' ? img : existingImage
817
- const align = alignSource?.getAttribute('data-align') || 'full'
818
-
819
- // Read link from the anchor wrapping the image (if any)
820
- const anchor = img?.closest('a')
821
- const link = anchor?.getAttribute('href') || ''
822
- const linkNewTab = anchor?.getAttribute('target') === '_blank'
823
-
824
- imageForm.value = {
825
- src: img?.src || '',
826
- alt: img?.alt || '',
827
- width: img?.getAttribute('data-width') || img?.style.width || '',
828
- height: img?.getAttribute('data-height') || img?.style.height || '',
829
- credit,
830
- figcaption: !!figcaption,
831
- align,
832
- link,
833
- linkNewTab
834
- }
863
+ imageForm.value = readImageData(existingImage)
835
864
  } else {
836
- // Reset form for new image
837
- imageForm.value = {
838
- src: '',
839
- alt: '',
840
- width: '',
841
- height: '',
842
- credit: '',
843
- figcaption: false,
844
- align: 'full',
845
- link: '',
846
- linkNewTab: false
847
- }
865
+ imageForm.value = createDefaultImageData()
848
866
  }
849
867
 
850
868
  showImageModal.value = true
851
869
  }
852
870
 
871
+ function handleImageUploadError() {
872
+ imageUploadError.value = 'Image upload failed. Please try again or paste an image URL.'
873
+ }
874
+
853
875
  // Function to submit image
854
876
  function submitImage() {
855
877
  if (!imageForm.value.src || !pendingImageData) { return }
@@ -858,124 +880,9 @@ function submitImage() {
858
880
  if (!doc) { return }
859
881
 
860
882
  const { existingImage } = pendingImageData
883
+ const elementToUpdate = buildImageFigure(doc, imageForm.value, existingImage)
861
884
 
862
- // Create image element
863
- const img = doc.createElement('img')
864
- img.src = imageForm.value.src
865
- img.alt = imageForm.value.alt || ''
866
-
867
- // Store original width/height values in data attributes
868
- if (imageForm.value.width) {
869
- img.setAttribute('data-width', imageForm.value.width)
870
- if (imageForm.value.width.includes('%') || imageForm.value.width.includes('px') || imageForm.value.width.includes('vw') || imageForm.value.width.includes('rem') || imageForm.value.width.includes('em')) {
871
- img.style.width = imageForm.value.width
872
- } else {
873
- img.style.width = `${imageForm.value.width}px`
874
- }
875
- } else {
876
- // Floated images default to a partial width so text can wrap alongside;
877
- // full-width images fill the container.
878
- img.style.width = (imageForm.value.align === 'start' || imageForm.value.align === 'end') ? '45%' : '100%'
879
- }
880
-
881
- if (imageForm.value.height) {
882
- img.setAttribute('data-height', imageForm.value.height)
883
- if (imageForm.value.height.includes('%') || imageForm.value.height.includes('px') || imageForm.value.height.includes('vh') || imageForm.value.height.includes('rem') || imageForm.value.height.includes('em') || imageForm.value.height === 'auto') {
884
- img.style.height = imageForm.value.height
885
- } else {
886
- img.style.height = `${imageForm.value.height}px`
887
- }
888
- } else {
889
- img.style.height = 'auto'
890
- }
891
-
892
- // Helper function to create figcaption with alt text and credit (using safe DOM nodes)
893
- function createFigcaption() {
894
- const figcaption = doc!.createElement('figcaption')
895
-
896
- if (imageForm.value.figcaption && imageForm.value.alt) {
897
- const altStrong = doc!.createElement('strong')
898
- altStrong.className = 'alt-text'
899
- altStrong.textContent = imageForm.value.alt
900
- figcaption.appendChild(altStrong)
901
- }
902
-
903
- if (imageForm.value.credit) {
904
- if (figcaption.childNodes.length > 0) {
905
- figcaption.appendChild(doc!.createTextNode(' • '))
906
- }
907
- const creditSpan = doc!.createElement('span')
908
- creditSpan.className = 'photo-credit'
909
- creditSpan.textContent = imageForm.value.credit
910
- figcaption.appendChild(creditSpan)
911
- }
912
-
913
- return figcaption
914
- }
915
-
916
- // Wrap the image in an anchor when a link is provided, otherwise return the bare image
917
- function createImageContent(): HTMLElement {
918
- const link = normalizeRichTextLink(imageForm.value.link)
919
- if (!link) { return img }
920
-
921
- const anchor = doc!.createElement('a')
922
- anchor.setAttribute('href', link)
923
- if (imageForm.value.linkNewTab) {
924
- anchor.setAttribute('target', '_blank')
925
- anchor.setAttribute('rel', 'noopener noreferrer')
926
- }
927
- anchor.appendChild(img)
928
- return anchor
929
- }
930
-
931
- // Create or update figure/image - always wrap in figure
932
- let elementToUpdate: HTMLElement
933
- if (existingImage) {
934
- if (existingImage.tagName.toLowerCase() === 'img') {
935
- // Converting standalone image to figure (always)
936
- const figure = doc.createElement('figure')
937
- figure.className = 'image-figure'
938
- if (imageForm.value.credit) {
939
- figure.setAttribute('data-credit', imageForm.value.credit)
940
- }
941
- figure.appendChild(createImageContent())
942
-
943
- // Add caption if needed (alt with checkbox checked OR credit exists)
944
- if ((imageForm.value.figcaption && imageForm.value.alt) || imageForm.value.credit) {
945
- figure.appendChild(createFigcaption())
946
- }
947
-
948
- existingImage.parentNode?.replaceChild(figure, existingImage)
949
- elementToUpdate = figure
950
- } else {
951
- // Updating an existing figure
952
- existingImage.innerHTML = '' // Clear existing content
953
- if (imageForm.value.credit) {
954
- existingImage.setAttribute('data-credit', imageForm.value.credit)
955
- }
956
- existingImage.appendChild(createImageContent())
957
-
958
- // Add caption if needed (alt with checkbox checked OR credit exists)
959
- if ((imageForm.value.figcaption && imageForm.value.alt) || imageForm.value.credit) {
960
- existingImage.appendChild(createFigcaption())
961
- }
962
- elementToUpdate = existingImage
963
- }
964
- } else {
965
- // Creating new image - always wrap in figure
966
- const figure = doc.createElement('figure')
967
- figure.className = 'image-figure'
968
- if (imageForm.value.credit) {
969
- figure.setAttribute('data-credit', imageForm.value.credit)
970
- }
971
- figure.appendChild(createImageContent())
972
-
973
- // Add caption if needed (alt with checkbox checked OR credit exists)
974
- if ((imageForm.value.figcaption && imageForm.value.alt) || imageForm.value.credit) {
975
- figure.appendChild(createFigcaption())
976
- }
977
- elementToUpdate = figure
978
-
885
+ if (!existingImage) {
979
886
  // Insert new element
980
887
  const { selection, range } = pendingImageData
981
888
  range.collapse(false)
@@ -988,12 +895,6 @@ function submitImage() {
988
895
  selection.addRange(range)
989
896
  }
990
897
 
991
- // Apply alignment to the figure wrapper (full = full width, start/end = float)
992
- const align = imageForm.value.align || 'full'
993
- elementToUpdate.classList.remove('align-full', 'align-start', 'align-end')
994
- elementToUpdate.classList.add(`align-${align}`)
995
- elementToUpdate.setAttribute('data-align', align)
996
-
997
898
  updateContentWithHistory(doc)
998
899
  showImageModal.value = false
999
900
  pendingImageData = null
@@ -1091,6 +992,7 @@ function submitEmbed() {
1091
992
  iframe.frameBorder = '0'
1092
993
  iframe.setAttribute('allowfullscreen', '')
1093
994
  iframe.setAttribute('data-media-type', 'embed')
995
+ iframe.title = embedForm.value.alt || 'Embedded content'
1094
996
  iframe.className = 'embed-iframe'
1095
997
 
1096
998
  // Set width and height - always set them
@@ -1163,43 +1065,9 @@ function openVideoModal(existingVideo: HTMLElement | null = null) {
1163
1065
  }
1164
1066
 
1165
1067
  if (existingVideo) {
1166
- // Handle both editor-internal figures (with .video-container) and bare <video>/<iframe> elements
1167
- const container = existingVideo.querySelector('.video-container')
1168
- const bareVideo = existingVideo.tagName === 'VIDEO' ? existingVideo as HTMLVideoElement : null
1169
- const bareIframe = existingVideo.tagName === 'IFRAME' ? existingVideo as HTMLIFrameElement : null
1170
- const isCustom = container?.getAttribute('data-custom-aspect-ratio') === 'true'
1171
-
1172
- videoForm.value = {
1173
- src: container?.getAttribute('data-video-src')
1174
- || bareVideo?.src
1175
- || bareIframe?.src
1176
- || '',
1177
- width: container?.getAttribute('data-width') || (bareVideo?.style.width ?? '') || '',
1178
- autoplay: container ? container.getAttribute('data-autoplay') === 'true' : (bareVideo?.autoplay ?? false),
1179
- mute: container ? container.getAttribute('data-mute') === 'true' : (bareVideo?.muted ?? false),
1180
- controls: container ? container.getAttribute('data-controls') === 'true' : (bareVideo?.controls ?? true),
1181
- loop: container ? container.getAttribute('data-loop') === 'true' : (bareVideo?.loop ?? false),
1182
- aspectRatio: isCustom ? 'custom' : (container?.getAttribute('data-aspect-ratio') || '16:9'),
1183
- customWidth: container?.getAttribute('data-custom-width') || '',
1184
- customHeight: container?.getAttribute('data-custom-height') || '',
1185
- caption: existingVideo.querySelector?.('figcaption')?.textContent || '',
1186
- showCaption: !!existingVideo.querySelector?.('figcaption')
1187
- }
1068
+ videoForm.value = readVideoData(existingVideo)
1188
1069
  } else {
1189
- // Reset form for new video
1190
- videoForm.value = {
1191
- src: '',
1192
- width: '',
1193
- autoplay: false,
1194
- mute: false,
1195
- controls: true,
1196
- loop: false,
1197
- aspectRatio: '16:9',
1198
- customWidth: '',
1199
- customHeight: '',
1200
- caption: '',
1201
- showCaption: false
1202
- }
1070
+ videoForm.value = createDefaultVideoData()
1203
1071
  }
1204
1072
 
1205
1073
  showVideoModal.value = true
@@ -1248,133 +1116,7 @@ function submitVideo() {
1248
1116
  if (!doc) { return }
1249
1117
 
1250
1118
  const { existingVideo } = pendingVideoData
1251
-
1252
- // Calculate aspect ratio
1253
- let { aspectRatio } = videoForm.value
1254
- const isCustom = videoForm.value.aspectRatio === 'custom'
1255
- if (isCustom && videoForm.value.customWidth && videoForm.value.customHeight) {
1256
- aspectRatio = `${videoForm.value.customWidth}:${videoForm.value.customHeight}`
1257
- }
1258
-
1259
- // Auto-adjust aspect ratio for YouTube Shorts
1260
- const isYoutubeShort = videoForm.value.src.includes('youtube.com/shorts/')
1261
- if (isYoutubeShort && aspectRatio === '16:9') {
1262
- aspectRatio = '9:16'
1263
- }
1264
-
1265
- // Create video container
1266
- const videoContainer = doc.createElement('div')
1267
- videoContainer.className = 'video-container'
1268
- videoContainer.setAttribute('data-video-src', videoForm.value.src)
1269
- videoContainer.setAttribute('data-component', 'BglVideo')
1270
- videoContainer.setAttribute('data-media-type', 'video')
1271
- videoContainer.setAttribute('data-width', videoForm.value.width)
1272
- videoContainer.setAttribute('data-autoplay', videoForm.value.autoplay.toString())
1273
- videoContainer.setAttribute('data-mute', videoForm.value.mute.toString())
1274
- videoContainer.setAttribute('data-controls', videoForm.value.controls.toString())
1275
- videoContainer.setAttribute('data-loop', videoForm.value.loop.toString())
1276
- videoContainer.setAttribute('data-aspect-ratio', aspectRatio)
1277
- videoContainer.setAttribute('data-custom-aspect-ratio', isCustom.toString())
1278
- videoContainer.setAttribute('data-custom-width', videoForm.value.customWidth)
1279
- videoContainer.setAttribute('data-custom-height', videoForm.value.customHeight)
1280
-
1281
- // Set width
1282
- if (videoForm.value.width) {
1283
- videoContainer.style.width = videoForm.value.width.includes('%') || videoForm.value.width.includes('px')
1284
- ? videoForm.value.width
1285
- : `${videoForm.value.width}px`
1286
- }
1287
-
1288
- // Create the video placeholder/thumbnail instead of actual video
1289
- const videoWrapper = doc.createElement('div')
1290
- videoWrapper.className = 'bgl_vid'
1291
-
1292
- // Create video placeholder/thumbnail
1293
- const placeholder = doc.createElement('div')
1294
- placeholder.className = 'video-placeholder'
1295
- placeholder.style.aspectRatio = aspectRatio.replace(':', '/')
1296
-
1297
- // Detect video type for display
1298
- const isYoutube = videoForm.value.src.includes('youtube.com') || videoForm.value.src.includes('youtu.be')
1299
- const isVimeo = videoForm.value.src.includes('vimeo.com')
1300
-
1301
- // Create play icon
1302
- const playIcon = doc.createElement('div')
1303
- playIcon.className = 'video-placeholder-icon'
1304
-
1305
- // Create text description
1306
- const description = doc.createElement('div')
1307
- description.className = 'video-placeholder-description'
1308
- let platformText = 'video'
1309
- if (isYoutube) { platformText = 'YouTube video' }
1310
- else if (isVimeo) { platformText = 'Vimeo video' }
1311
- description.textContent = platformText
1312
-
1313
- // Try to get YouTube thumbnail if possible
1314
- if (isYoutube) {
1315
- let videoId = ''
1316
- if (videoForm.value.src.includes('youtube.com/watch?v=')) {
1317
- videoId = videoForm.value.src.split('watch?v=')[1]?.split('&')[0]
1318
- } else if (videoForm.value.src.includes('youtu.be/')) {
1319
- videoId = videoForm.value.src.split('youtu.be/')[1]?.split('?')[0]
1320
- } else if (videoForm.value.src.includes('youtube.com/shorts/')) {
1321
- videoId = videoForm.value.src.split('shorts/')[1]?.split('?')[0]
1322
- }
1323
-
1324
- if (videoId) {
1325
- // Add thumbnail class and create thumbnail image
1326
- placeholder.classList.add('has-thumbnail')
1327
-
1328
- const thumbnail = doc.createElement('img')
1329
- thumbnail.className = 'video-thumbnail'
1330
- thumbnail.src = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`
1331
- placeholder.appendChild(thumbnail)
1332
-
1333
- // Add overlay with play button
1334
- const overlay = doc.createElement('div')
1335
- overlay.className = 'video-overlay'
1336
-
1337
- const overlayPlayIcon = doc.createElement('div')
1338
- overlayPlayIcon.className = 'video-overlay-icon'
1339
-
1340
- overlay.appendChild(overlayPlayIcon)
1341
- placeholder.appendChild(overlay)
1342
- } else {
1343
- placeholder.appendChild(playIcon)
1344
- placeholder.appendChild(description)
1345
- }
1346
- } else {
1347
- placeholder.appendChild(playIcon)
1348
- placeholder.appendChild(description)
1349
- }
1350
-
1351
- // Add special class for YouTube Shorts
1352
- if (isYoutube && videoForm.value.src.includes('youtube.com/shorts/')) {
1353
- videoWrapper.classList.add('vid_short')
1354
- }
1355
-
1356
- videoWrapper.appendChild(placeholder)
1357
-
1358
- videoContainer.appendChild(videoWrapper)
1359
-
1360
- // Create or update figure
1361
- let figure: HTMLElement
1362
- if (existingVideo) {
1363
- figure = existingVideo
1364
- figure.innerHTML = '' // Clear existing content
1365
- } else {
1366
- figure = doc.createElement('figure')
1367
- figure.className = 'video-figure'
1368
- }
1369
-
1370
- figure.appendChild(videoContainer)
1371
-
1372
- // Add caption if enabled
1373
- if (videoForm.value.showCaption && videoForm.value.caption) {
1374
- const figcaption = doc.createElement('figcaption')
1375
- figcaption.textContent = videoForm.value.caption
1376
- figure.appendChild(figcaption)
1377
- }
1119
+ const figure = buildVideoFigure(doc, videoForm.value, existingVideo)
1378
1120
 
1379
1121
  // Insert new video or update existing
1380
1122
  if (!existingVideo) {
@@ -1809,6 +1551,7 @@ function deleteTable() {
1809
1551
  if (!pendingTableData?.existingTable) { return }
1810
1552
 
1811
1553
  removeTableElement(pendingTableData.existingTable)
1554
+ updateContentWithHistory(editor.state.doc!)
1812
1555
  showTableEditor.value = false
1813
1556
  pendingTableData = null
1814
1557
  }
@@ -1867,6 +1610,11 @@ function getCleanHTML(doc: Document | null): string {
1867
1610
  clone.querySelectorAll('.media-selected').forEach((el) => { el.classList.remove('media-selected') })
1868
1611
  clone.querySelectorAll('.table-cell-selected').forEach((el) => { el.classList.remove('table-cell-selected') })
1869
1612
  clone.querySelectorAll('[data-dir-explicit]').forEach((el) => { el.removeAttribute('data-dir-explicit') })
1613
+ clone.querySelectorAll('figure[data-media-type]').forEach((figure) => {
1614
+ figure.removeAttribute('tabindex')
1615
+ figure.removeAttribute('role')
1616
+ figure.removeAttribute('aria-label')
1617
+ })
1870
1618
 
1871
1619
  // Unwrap .table-wrapper divs: restore original table margin-based alignment
1872
1620
  clone.querySelectorAll('.table-wrapper').forEach((wrapper) => {
@@ -1899,79 +1647,7 @@ function getCleanHTML(doc: Document | null): string {
1899
1647
  table.removeAttribute('data-edit-button-added')
1900
1648
  })
1901
1649
 
1902
- // Convert internal video placeholder divs → real <video> or <iframe> HTML
1903
- clone.querySelectorAll('.video-container[data-component="BglVideo"]').forEach((container) => {
1904
- const el = container as HTMLElement
1905
- const src = el.getAttribute('data-video-src') || ''
1906
- if (!src) { return }
1907
-
1908
- const autoplay = el.getAttribute('data-autoplay') === 'true'
1909
- const muted = el.getAttribute('data-mute') === 'true'
1910
- const controls = el.getAttribute('data-controls') !== 'false'
1911
- const loop = el.getAttribute('data-loop') === 'true'
1912
- const aspectRatio = el.getAttribute('data-aspect-ratio') || '16:9'
1913
- const width = el.getAttribute('data-width') || ''
1914
-
1915
- const widthStyle = width
1916
- ? `width: ${/[%pxvwemr]/.test(width) ? width : `${width}px`};`
1917
- : 'width: 100%;'
1918
-
1919
- const [ratioW, ratioH] = aspectRatio.split(':').map(Number)
1920
- const paddingBottom = ratioW && ratioH ? `${(ratioH / ratioW) * 100}%` : '56.25%'
1921
-
1922
- const makeResponsiveIframe = (iframeSrc: string): HTMLElement => {
1923
- const wrapper = document.createElement('div')
1924
- wrapper.style.cssText = `${widthStyle} position: relative; padding-bottom: ${paddingBottom}; height: 0; overflow: hidden;`
1925
- const iframe = document.createElement('iframe')
1926
- iframe.src = iframeSrc
1927
- iframe.frameBorder = '0'
1928
- iframe.setAttribute('allowfullscreen', '')
1929
- iframe.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%;'
1930
- wrapper.appendChild(iframe)
1931
- return wrapper
1932
- }
1933
-
1934
- let newEl: HTMLElement | null = null
1935
-
1936
- if (src.includes('youtube.com') || src.includes('youtu.be')) {
1937
- let embedUrl = src
1938
- if (src.includes('youtube.com/watch?v=')) {
1939
- const videoId = src.split('watch?v=')[1]?.split('&')[0]
1940
- const params = []
1941
- if (autoplay) { params.push('autoplay=1') }
1942
- if (muted) { params.push('mute=1') }
1943
- if (loop) { params.push(`loop=1&playlist=${videoId}`) }
1944
- if (!controls) { params.push('controls=0') }
1945
- embedUrl = `https://www.youtube.com/embed/${videoId}${params.length ? `?${params.join('&')}` : ''}`
1946
- } else if (src.includes('youtu.be/')) {
1947
- const videoId = src.split('youtu.be/')[1]?.split('?')[0]
1948
- embedUrl = `https://www.youtube.com/embed/${videoId}`
1949
- } else if (src.includes('youtube.com/shorts/')) {
1950
- const videoId = src.split('shorts/')[1]?.split('?')[0]
1951
- embedUrl = `https://www.youtube.com/embed/${videoId}`
1952
- }
1953
- newEl = makeResponsiveIframe(embedUrl)
1954
- } else if (src.includes('vimeo.com')) {
1955
- const match = src.match(/vimeo\.com\/(\d+)/)
1956
- const embedUrl = match ? `https://player.vimeo.com/video/${match[1]}` : src
1957
- newEl = makeResponsiveIframe(embedUrl)
1958
- } else if (src.includes('<iframe')) {
1959
- const srcMatch = src.match(/src=["']([^"']+)["']/)
1960
- if (srcMatch) { newEl = makeResponsiveIframe(srcMatch[1]) }
1961
- } else {
1962
- // Direct video file
1963
- const video = document.createElement('video')
1964
- video.src = src
1965
- if (controls) { video.setAttribute('controls', '') }
1966
- if (autoplay) { video.setAttribute('autoplay', '') }
1967
- if (muted) { video.setAttribute('muted', '') }
1968
- if (loop) { video.setAttribute('loop', '') }
1969
- video.style.cssText = `${widthStyle} height: auto; display: block;`
1970
- newEl = video
1971
- }
1972
-
1973
- if (newEl) { el.parentNode?.replaceChild(newEl, el) }
1974
- })
1650
+ serializeVideoFigures(clone)
1975
1651
 
1976
1652
  // Strip internal placeholder content from video figures (the bgl_vid wrapper)
1977
1653
  // that remains after video-container conversion
@@ -1985,10 +1661,6 @@ function getCleanHTML(doc: Document | null): string {
1985
1661
  if (fig.classList.length === 0) { fig.removeAttribute('class') }
1986
1662
  })
1987
1663
 
1988
- // Keep imported/pasted legacy links from leaking into saved output. Invalid
1989
- // destinations lose their href, but their visible content is preserved.
1990
- sanitizeRichTextLinks(clone)
1991
-
1992
1664
  const html = clone.innerHTML
1993
1665
  if (!clone.textContent?.trim() && !clone.querySelector('img, video, iframe, table, hr, figure')) {
1994
1666
  return ''
@@ -2188,11 +1860,12 @@ function setupTableColumnResizing(doc: Document) {
2188
1860
  table: HTMLTableElement
2189
1861
  cell: HTMLTableCellElement
2190
1862
  insertPosition: 'before' | 'after'
1863
+ rowInsertPosition: 'before' | 'after'
2191
1864
  } | null = null
2192
1865
 
2193
1866
  const hideAllInlineControls = () => {
2194
1867
  doc.querySelectorAll<HTMLElement>('.table-inline-controls').forEach((ctrl) => {
2195
- ctrl.classList.remove('is-visible', 'show-col-add', 'show-col-thumb', 'is-dragging-col')
1868
+ ctrl.classList.remove('is-visible', 'show-col-add', 'show-row-add', 'show-col-thumb', 'is-dragging-col')
2196
1869
  })
2197
1870
  }
2198
1871
 
@@ -2257,7 +1930,8 @@ function setupTableColumnResizing(doc: Document) {
2257
1930
  const cell = target.closest('td, th') as HTMLTableCellElement | null
2258
1931
  const table = cell?.closest('table') as HTMLTableElement | null
2259
1932
  const wrapper = table?.closest('.table-wrapper') as HTMLElement | null
2260
- if (!cell || !table || !wrapper) {
1933
+ const row = cell?.closest('tr') as HTMLTableRowElement | null
1934
+ if (!cell || !table || !wrapper || !row) {
2261
1935
  hideAllInlineControls()
2262
1936
  return
2263
1937
  }
@@ -2265,12 +1939,14 @@ function setupTableColumnResizing(doc: Document) {
2265
1939
  const controls = wrapper.querySelector<HTMLElement>('.table-inline-controls')
2266
1940
  const addBtn = controls?.querySelector<HTMLElement>('.table-col-add-btn')
2267
1941
  const addLine = controls?.querySelector<HTMLElement>('.table-col-add-line')
1942
+ const rowAddBtn = controls?.querySelector<HTMLElement>('.table-row-add-btn')
1943
+ const rowAddLine = controls?.querySelector<HTMLElement>('.table-row-add-line')
2268
1944
  const thumbBtn = controls?.querySelector<HTMLElement>('.table-col-thumb')
2269
- if (!controls || !addBtn || !addLine || !thumbBtn) { return }
1945
+ if (!controls || !addBtn || !addLine || !rowAddBtn || !rowAddLine || !thumbBtn) { return }
2270
1946
 
2271
1947
  doc.querySelectorAll<HTMLElement>('.table-inline-controls').forEach((other) => {
2272
1948
  if (other !== controls) {
2273
- other.classList.remove('is-visible', 'show-col-add', 'show-col-thumb', 'is-dragging-col')
1949
+ other.classList.remove('is-visible', 'show-col-add', 'show-row-add', 'show-col-thumb', 'is-dragging-col')
2274
1950
  }
2275
1951
  })
2276
1952
 
@@ -2280,6 +1956,8 @@ function setupTableColumnResizing(doc: Document) {
2280
1956
 
2281
1957
  const tableTop = tableRect.top - wrapperRect.top
2282
1958
  const tableHeight = tableRect.height
1959
+ const tableLeft = tableRect.left - wrapperRect.left
1960
+ const tableWidth = tableRect.width
2283
1961
  const cellLeft = cellRect.left - wrapperRect.left
2284
1962
  const cellRight = cellRect.right - wrapperRect.left
2285
1963
  const cellCenter = cellLeft + cellRect.width / 2
@@ -2290,18 +1968,29 @@ function setupTableColumnResizing(doc: Document) {
2290
1968
  const distRight = Math.abs(e.clientX - cellRect.right)
2291
1969
  const nearLeft = distLeft <= distRight
2292
1970
  const minEdgeDist = Math.min(distLeft, distRight)
1971
+ const distTop = Math.abs(e.clientY - cellRect.top)
1972
+ const distBottom = Math.abs(e.clientY - cellRect.bottom)
1973
+ const nearTop = distTop <= distBottom
1974
+ const minRowEdgeDist = Math.min(distTop, distBottom)
2293
1975
  const nearTopBand = (e.clientY - tableRect.top) <= 36
1976
+ const nearInlineStartBand = isRTL
1977
+ ? (tableRect.right - e.clientX) <= 36
1978
+ : (e.clientX - tableRect.left) <= 36
1979
+ const isHeaderRow = row.parentElement?.tagName === 'THEAD'
2294
1980
 
2295
1981
  // In LTR: left edge inserts 'before', right edge inserts 'after'. In RTL: reversed.
2296
1982
  const insertPosition: 'before' | 'after' = nearLeft
2297
1983
  ? (isRTL ? 'after' : 'before')
2298
1984
  : (isRTL ? 'before' : 'after')
1985
+ const rowInsertPosition: 'before' | 'after' = isHeaderRow ? 'after' : (nearTop ? 'before' : 'after')
2299
1986
  const boundaryX = nearLeft ? cellLeft : cellRight
1987
+ const boundaryY = (isHeaderRow ? row.getBoundingClientRect().bottom : (nearTop ? cellRect.top : cellRect.bottom)) - wrapperRect.top
2300
1988
 
2301
1989
  activeHoverState = {
2302
1990
  table,
2303
1991
  cell,
2304
1992
  insertPosition,
1993
+ rowInsertPosition,
2305
1994
  }
2306
1995
 
2307
1996
  controls.classList.add('is-visible', 'show-col-thumb')
@@ -2319,6 +2008,18 @@ function setupTableColumnResizing(doc: Document) {
2319
2008
  } else {
2320
2009
  controls.classList.remove('show-col-add')
2321
2010
  }
2011
+
2012
+ // Mirror the column affordance on horizontal row boundaries.
2013
+ if (minRowEdgeDist <= ADD_TRIGGER_ZONE || nearInlineStartBand) {
2014
+ rowAddBtn.style.left = `${isRTL ? tableLeft + tableWidth : tableLeft}px`
2015
+ rowAddBtn.style.top = `${boundaryY}px`
2016
+ rowAddLine.style.left = `${tableLeft}px`
2017
+ rowAddLine.style.top = `${boundaryY}px`
2018
+ rowAddLine.style.width = `${tableWidth}px`
2019
+ controls.classList.add('show-row-add')
2020
+ } else {
2021
+ controls.classList.remove('show-row-add')
2022
+ }
2322
2023
  }
2323
2024
 
2324
2025
  const onHoverPointerMove = (e: PointerEvent) => {
@@ -2336,7 +2037,32 @@ function setupTableColumnResizing(doc: Document) {
2336
2037
  const onPointerDown = (e: PointerEvent) => {
2337
2038
  const target = e.target as HTMLElement | null
2338
2039
 
2339
- // 1. Handle native inline '+' (Add Column) click
2040
+ // 1. Handle native inline '+' (Add Row) click
2041
+ const addRowBtn = target?.closest('.table-row-add-btn') as HTMLElement | null
2042
+ if (addRowBtn) {
2043
+ e.preventDefault()
2044
+ e.stopPropagation()
2045
+ e.stopImmediatePropagation()
2046
+ const wrapper = addRowBtn.closest('.table-wrapper')
2047
+ const table = wrapper?.querySelector('table') as HTMLTableElement | null
2048
+ const fallbackCell = table?.querySelector('tbody td, td, th') as HTMLTableCellElement | null
2049
+ const targetCell = (activeHoverState?.table === table && activeHoverState?.cell.isConnected)
2050
+ ? activeHoverState.cell
2051
+ : (contextMenuCell.value?.closest('table') === table ? contextMenuCell.value : fallbackCell)
2052
+ const pos = activeHoverState?.table === table
2053
+ ? activeHoverState.rowInsertPosition
2054
+ : ((addRowBtn.getAttribute('data-insert-pos') as 'before' | 'after') || 'after')
2055
+
2056
+ if (targetCell) {
2057
+ const newRow = tableAddRow(pos, editor.state.range, doc, targetCell)
2058
+ const firstNewCell = newRow?.querySelector('td, th') as HTMLTableCellElement | null
2059
+ if (firstNewCell) { focusTableCell(firstNewCell, true) }
2060
+ updateContentWithHistory(doc)
2061
+ }
2062
+ return
2063
+ }
2064
+
2065
+ // 2. Handle native inline '+' (Add Column) click
2340
2066
  const addColBtn = target?.closest('.table-col-add-btn') as HTMLElement | null
2341
2067
  if (addColBtn) {
2342
2068
  e.preventDefault()
@@ -2359,7 +2085,7 @@ function setupTableColumnResizing(doc: Document) {
2359
2085
  return
2360
2086
  }
2361
2087
 
2362
- // 2. Handle native inline '⋯' (Column Thumb: drag via usePointerSession to reorder, or click to open ContextMenu)
2088
+ // 3. Handle native inline '⋯' (Column Thumb: drag via usePointerSession to reorder, or click to open ContextMenu)
2363
2089
  const thumbColBtn = target?.closest('.table-col-thumb') as HTMLElement | null
2364
2090
  if (thumbColBtn) {
2365
2091
  e.stopPropagation()
@@ -2457,7 +2183,7 @@ function setupTableColumnResizing(doc: Document) {
2457
2183
  return
2458
2184
  }
2459
2185
 
2460
- // 3. Handle column border drag-resizing via usePointerSession (with Escape rollback)
2186
+ // 4. Handle column border drag-resizing via usePointerSession (with Escape rollback)
2461
2187
  const hovered = getHoveredResizeCell(e)
2462
2188
  if (!hovered || !hovered.cell.nextElementSibling) { return }
2463
2189
 
@@ -2566,6 +2292,18 @@ function setupTableEditButtons(doc: Document) {
2566
2292
  addBtn.setAttribute('contenteditable', 'false')
2567
2293
  addBtn.setAttribute('tabindex', '-1')
2568
2294
 
2295
+ const rowAddLine = doc.createElement('span')
2296
+ rowAddLine.className = 'table-row-add-line'
2297
+ rowAddLine.setAttribute('contenteditable', 'false')
2298
+
2299
+ const rowAddBtn = doc.createElement('span')
2300
+ rowAddBtn.className = 'table-row-add-btn'
2301
+ rowAddBtn.textContent = '+'
2302
+ rowAddBtn.title = 'Insert row here'
2303
+ rowAddBtn.setAttribute('aria-label', 'Insert row here')
2304
+ rowAddBtn.setAttribute('contenteditable', 'false')
2305
+ rowAddBtn.setAttribute('tabindex', '-1')
2306
+
2569
2307
  const thumbBtn = doc.createElement('span')
2570
2308
  thumbBtn.className = 'table-col-thumb'
2571
2309
  thumbBtn.textContent = '⋯'
@@ -2576,6 +2314,8 @@ function setupTableEditButtons(doc: Document) {
2576
2314
 
2577
2315
  controls.appendChild(addLine)
2578
2316
  controls.appendChild(addBtn)
2317
+ controls.appendChild(rowAddLine)
2318
+ controls.appendChild(rowAddBtn)
2579
2319
  controls.appendChild(thumbBtn)
2580
2320
  return controls
2581
2321
  }
@@ -2787,6 +2527,24 @@ function setupAutoWrapping(doc: Document) {
2787
2527
  }
2788
2528
  }
2789
2529
 
2530
+ doc.addEventListener('paste', (event) => {
2531
+ const clipboard = event.clipboardData
2532
+ if (!clipboard) { return }
2533
+
2534
+ const selection = doc.getSelection()
2535
+ if (!selection || !selection.rangeCount) { return }
2536
+
2537
+ const pastedHtml = clipboard.getData('text/html')
2538
+ const pastedText = clipboard.getData('text/plain')
2539
+ if (!pastedHtml && !pastedText) { return }
2540
+
2541
+ event.preventDefault()
2542
+ if (!insertPastedContent(doc, selection, { html: pastedHtml, text: pastedText })) { return }
2543
+
2544
+ normalizeContent()
2545
+ updateContentWithHistory(doc)
2546
+ })
2547
+
2790
2548
  // Handle input events
2791
2549
  doc.addEventListener('input', (e) => {
2792
2550
  const inputEvent = e as InputEvent
@@ -2963,9 +2721,7 @@ function setupAutoWrapping(doc: Document) {
2963
2721
  subtree: true
2964
2722
  })
2965
2723
 
2966
- // Handle clicks on links to open link modal
2967
- doc.addEventListener('click', (e) => {
2968
- const target = e.target as HTMLElement
2724
+ function openMediaFromTarget(target: HTMLElement, event?: Event): boolean {
2969
2725
  const link = target.closest('a')
2970
2726
  const table = target.closest('table')
2971
2727
 
@@ -3003,27 +2759,30 @@ function setupAutoWrapping(doc: Document) {
3003
2759
  if (table) {
3004
2760
  // Table clicks handled only by edit button
3005
2761
  } else if (videoFigure) {
3006
- e.preventDefault()
3007
- e.stopPropagation()
2762
+ event?.preventDefault()
2763
+ event?.stopPropagation()
3008
2764
  videoFigure.classList.add('media-selected')
3009
2765
  openVideoModal(videoFigure)
2766
+ return true
3010
2767
  } else if (imageFigure) {
3011
- e.preventDefault()
3012
- e.stopPropagation()
2768
+ event?.preventDefault()
2769
+ event?.stopPropagation()
3013
2770
  imageFigure.classList.add('media-selected')
3014
2771
  openImageModal(imageFigure)
2772
+ return true
3015
2773
  } else if (embedFigure) {
3016
- e.preventDefault()
3017
- e.stopPropagation()
2774
+ event?.preventDefault()
2775
+ event?.stopPropagation()
3018
2776
  embedFigure.classList.add('media-selected')
3019
2777
  openEmbedModal(embedFigure)
2778
+ return true
3020
2779
  } else if (link && link.href) {
3021
- e.preventDefault()
3022
- e.stopPropagation()
2780
+ event?.preventDefault()
2781
+ event?.stopPropagation()
3023
2782
 
3024
2783
  // Get current selection
3025
2784
  const selection = doc.getSelection()
3026
- if (!selection) { return }
2785
+ if (!selection) { return false }
3027
2786
 
3028
2787
  // Create a range that selects the entire link
3029
2788
  const range = doc.createRange()
@@ -3044,7 +2803,21 @@ function setupAutoWrapping(doc: Document) {
3044
2803
  }
3045
2804
 
3046
2805
  showLinkModal.value = true
2806
+ return true
3047
2807
  }
2808
+ return false
2809
+ }
2810
+
2811
+ // Handle clicks on links and media to open their edit modals
2812
+ doc.addEventListener('click', (e) => {
2813
+ openMediaFromTarget(e.target as HTMLElement, e)
2814
+ })
2815
+
2816
+ doc.addEventListener('keydown', (e) => {
2817
+ if (e.key !== 'Enter' && e.key !== ' ') { return }
2818
+ const target = e.target as HTMLElement
2819
+ if (!target.matches('figure[data-media-type="image"], figure[data-media-type="video"]')) { return }
2820
+ openMediaFromTarget(target, e)
3048
2821
  })
3049
2822
 
3050
2823
  // Handle focus to remove placeholder when user starts interacting
@@ -3154,7 +2927,7 @@ async function initEditor() {
3154
2927
 
3155
2928
  /* Ensure all text elements inherit the theme color */
3156
2929
  p, h1, h2, h3, h4, h5, h6, div, span, li {
3157
- color: inherit !important;
2930
+ color: inherit;
3158
2931
  }
3159
2932
  table {
3160
2933
  border-collapse: collapse;
@@ -3176,12 +2949,12 @@ async function initEditor() {
3176
2949
  /* Table edit button styles */
3177
2950
  .table-edit-btn {
3178
2951
  position: absolute;
3179
- top: -8px;
2952
+ bottom: -8px;
3180
2953
  right: -8px;
3181
- background: #000;
2954
+ background: rgba(0, 0, 0, 0.72);
3182
2955
  color: #fff !important;
3183
2956
  border: 1px solid #fff;
3184
- border-radius: 4px;
2957
+ border-radius: 6px;
3185
2958
  padding: 4px 8px;
3186
2959
  font-size: 12px;
3187
2960
  cursor: pointer;
@@ -3236,6 +3009,7 @@ async function initEditor() {
3236
3009
  }
3237
3010
 
3238
3011
  .table-col-add-btn,
3012
+ .table-row-add-btn,
3239
3013
  .table-col-thumb {
3240
3014
  position: absolute;
3241
3015
  display: flex;
@@ -3252,7 +3026,8 @@ async function initEditor() {
3252
3026
  transition: opacity 0.14s ease, transform 0.14s ease, background-color 0.14s ease, border-color 0.14s ease, visibility 0.14s ease;
3253
3027
  }
3254
3028
 
3255
- .table-col-add-btn {
3029
+ .table-col-add-btn,
3030
+ .table-row-add-btn {
3256
3031
  width: 20px;
3257
3032
  height: 20px;
3258
3033
  border-radius: 999px;
@@ -3263,16 +3038,18 @@ async function initEditor() {
3263
3038
  cursor: pointer;
3264
3039
  box-shadow: 0 2px 6px rgba(0, 123, 255, 0.35);
3265
3040
  z-index: 98;
3041
+ padding-bottom: 2px;
3266
3042
  }
3267
3043
 
3268
- .table-col-add-btn:hover {
3044
+ .table-col-add-btn:hover,
3045
+ .table-row-add-btn:hover {
3269
3046
  background: #0056b3;
3270
3047
  transform: translate(-50%, -50%) scale(1.08) !important;
3271
3048
  }
3272
3049
 
3273
- .table-col-add-line {
3050
+ .table-col-add-line,
3051
+ .table-row-add-line {
3274
3052
  position: absolute;
3275
- width: 2px;
3276
3053
  background: #007bff;
3277
3054
  pointer-events: none;
3278
3055
  opacity: 0;
@@ -3282,6 +3059,15 @@ async function initEditor() {
3282
3059
  z-index: 94;
3283
3060
  }
3284
3061
 
3062
+ .table-col-add-line {
3063
+ width: 2px;
3064
+ }
3065
+
3066
+ .table-row-add-line {
3067
+ height: 2px;
3068
+ transform: translateY(-50%);
3069
+ }
3070
+
3285
3071
  .table-col-thumb {
3286
3072
  width: 28px;
3287
3073
  height: 14px;
@@ -3311,19 +3097,22 @@ async function initEditor() {
3311
3097
  border-color: #0056b3;
3312
3098
  }
3313
3099
 
3314
- .table-wrapper:hover .table-inline-controls.is-visible.show-col-add .table-col-add-btn {
3100
+ .table-wrapper:hover .table-inline-controls.is-visible.show-col-add .table-col-add-btn,
3101
+ .table-wrapper:hover .table-inline-controls.is-visible.show-row-add .table-row-add-btn {
3315
3102
  opacity: 1;
3316
3103
  visibility: visible;
3317
3104
  transform: translate(-50%, -50%) scale(1);
3318
3105
  }
3319
3106
 
3320
3107
  .table-wrapper:hover .table-inline-controls.is-visible.show-col-add .table-col-add-line,
3108
+ .table-wrapper:hover .table-inline-controls.is-visible.show-row-add .table-row-add-line,
3321
3109
  .table-inline-controls.is-dragging-col .table-col-add-line {
3322
3110
  opacity: 0.45;
3323
3111
  visibility: visible;
3324
3112
  }
3325
3113
 
3326
3114
  .table-wrapper:hover .table-inline-controls.is-visible.show-col-add:has(.table-col-add-btn:hover) .table-col-add-line,
3115
+ .table-wrapper:hover .table-inline-controls.is-visible.show-row-add:has(.table-row-add-btn:hover) .table-row-add-line,
3327
3116
  .table-inline-controls.is-dragging-col .table-col-add-line {
3328
3117
  opacity: 0.9;
3329
3118
  visibility: visible;
@@ -3342,6 +3131,55 @@ async function initEditor() {
3342
3131
  }
3343
3132
 
3344
3133
  /* ── Media editing affordances ─────────────────────────────── */
3134
+ figure.video-figure {
3135
+ box-sizing: border-box;
3136
+ width: 100%;
3137
+ max-width: 100%;
3138
+ margin: 2rem 0;
3139
+ text-align: center;
3140
+ }
3141
+
3142
+ figure.video-figure .video-container,
3143
+ figure.video-figure .bgl_vid,
3144
+ figure.video-figure .video-placeholder {
3145
+ box-sizing: border-box;
3146
+ width: 100%;
3147
+ max-width: 100%;
3148
+ }
3149
+
3150
+ figure.video-figure .video-placeholder {
3151
+ position: relative;
3152
+ display: flex;
3153
+ align-items: center;
3154
+ justify-content: center;
3155
+ flex-direction: column;
3156
+ overflow: hidden;
3157
+ min-height: 50px;
3158
+ background: #f4f4f4;
3159
+ border-radius: 8px;
3160
+ }
3161
+
3162
+ figure.video-figure .video-thumbnail {
3163
+ position: absolute;
3164
+ inset: 0;
3165
+ display: block;
3166
+ width: 100%;
3167
+ height: 100%;
3168
+ max-width: 100%;
3169
+ object-fit: cover;
3170
+ }
3171
+
3172
+ figure.video-figure .video-overlay {
3173
+ position: absolute;
3174
+ inset: 0;
3175
+ display: flex;
3176
+ align-items: center;
3177
+ justify-content: center;
3178
+ flex-direction: column;
3179
+ background: rgba(0, 0, 0, 0.25);
3180
+ color: #fff;
3181
+ }
3182
+
3345
3183
  /* Figures wrapping media: ::after badge + hover ring.
3346
3184
  No display override — <figure> is already block, <img>/<video> keep their natural display. */
3347
3185
  figure:has(img),
@@ -3435,6 +3273,25 @@ async function initEditor() {
3435
3273
  width: 100%;
3436
3274
  }
3437
3275
 
3276
+ figure.video-figure.align-start,
3277
+ figure.video-figure.align-end {
3278
+ max-width: 50%;
3279
+ }
3280
+
3281
+ figure.video-figure .video-container {
3282
+ width: 100%;
3283
+ }
3284
+
3285
+ @media (max-width: 600px) {
3286
+ figure.video-figure.align-start,
3287
+ figure.video-figure.align-end {
3288
+ float: none;
3289
+ width: 100% !important;
3290
+ max-width: 100%;
3291
+ margin-inline: 0;
3292
+ }
3293
+ }
3294
+
3438
3295
  /* Linked images: the wrapping <a> should behave like the image block.
3439
3296
  In the editor, disable navigation so clicks open the edit modal. */
3440
3297
  figure.image-figure > a {
@@ -3491,6 +3348,8 @@ async function initEditor() {
3491
3348
 
3492
3349
  if (normalizedModelValue.value) {
3493
3350
  doc.body.innerHTML = normalizedModelValue.value
3351
+ hydrateVideoFigures(doc.body)
3352
+ hydrateImageFigures(doc.body)
3494
3353
  } else if (props.placeholder) {
3495
3354
  const placeholderEl = doc.createElement('p')
3496
3355
  placeholderEl.className = 'placeholder'
@@ -3752,10 +3611,12 @@ function updateIframeColors() {
3752
3611
  font-family: ${currentFontFamily.value} !important;
3753
3612
  }
3754
3613
 
3755
- /* Ensure all text elements inherit the color and font size */
3614
+ /* Body copy inherits the configured base size; headings keep semantic sizes. */
3615
+ p, div, li {
3616
+ font-size: inherit;
3617
+ }
3756
3618
  p, h1, h2, h3, h4, h5, h6, div, span, li {
3757
- color: inherit !important;
3758
- font-size: inherit !important;
3619
+ color: inherit;
3759
3620
  }
3760
3621
  ${effectiveContentStyles.value}
3761
3622
  `
@@ -3796,6 +3657,8 @@ watch(normalizedModelValue, (newValue) => {
3796
3657
  }
3797
3658
  } else {
3798
3659
  doc.body.innerHTML = newValue
3660
+ hydrateVideoFigures(doc.body)
3661
+ hydrateImageFigures(doc.body)
3799
3662
  }
3800
3663
 
3801
3664
  detectAndSetDirection()
@@ -3827,6 +3690,16 @@ watch(() => editor.state.content, () => {
3827
3690
  emit('update:modelValue', getCleanHTML(editor.state.doc ?? null))
3828
3691
  })
3829
3692
 
3693
+ watch(hasExceededLimit, (exceeded) => {
3694
+ if (!exceeded) { return }
3695
+ emit('limit-exceeded', {
3696
+ characters: characterCount.value,
3697
+ words: wordCount.value,
3698
+ maxLength: props.maxLength,
3699
+ maxWords: props.maxWords,
3700
+ })
3701
+ })
3702
+
3830
3703
  // Watch table alignment changes and update the table immediately
3831
3704
  watch(() => tableForm.value.alignment, (newAlignment) => {
3832
3705
  if (pendingTableData?.existingTable) {
@@ -3892,6 +3765,7 @@ const imgTransform = computed({
3892
3765
  :selectedStyles="editor.state.selectedStyles" :hide-images="hideImages" :hide-videos="hideVideos"
3893
3766
  :hide-embeds="hideEmbed" :hide-tables="hideTables" :hide-alignment="hideAlignment"
3894
3767
  :hide-directions="hideDirections" :hide-h5-h6="hideH5H6" :hide="effectiveHideArray"
3768
+ :document-colors="documentColors"
3895
3769
  @action="commands.execute"
3896
3770
  >
3897
3771
  <template #end>
@@ -3914,6 +3788,17 @@ const imgTransform = computed({
3914
3788
  </slot>
3915
3789
  </template>
3916
3790
  </EditorToolbar>
3791
+ <div
3792
+ v-if="showDocumentCounter"
3793
+ class="flex space-between txt10 pb-025"
3794
+ :class="hasExceededLimit ? 'color-red' : 'color-gray'"
3795
+ role="status"
3796
+ aria-live="polite"
3797
+ >
3798
+ <span>{{ characterCount }}<template v-if="maxLength"> / {{ maxLength }}</template> characters</span>
3799
+ <span>{{ wordCount }}<template v-if="maxWords"> / {{ maxWords }}</template> words</span>
3800
+ <span class="hide">{{ counterLabel }}</span>
3801
+ </div>
3917
3802
  <div class="editor-container" :class="{ 'split-view': editor.state.isSplitView }">
3918
3803
  <div
3919
3804
  class="content-area radius-05"
@@ -4025,7 +3910,15 @@ const imgTransform = computed({
4025
3910
  width="l" @update:open="showImageModal = $event"
4026
3911
  >
4027
3912
  <div class="image-dialog-layout">
4028
- <UploadInput v-model="imgTransform" label="Image" type="image" @keydown.enter="submitImage" />
3913
+ <div>
3914
+ <UploadInput
3915
+ v-model="imgTransform" label="Image" type="image" accept="image/*" :upload="uploadImage"
3916
+ @add-file-start="imageUploadError = ''" @upload-error="handleImageUploadError" @keydown.enter="submitImage"
3917
+ />
3918
+ <p v-if="imageUploadError" class="color-red txt12 m-0" role="alert">
3919
+ {{ imageUploadError }}
3920
+ </p>
3921
+ </div>
4029
3922
  <div class="flex flex-column gap-05 min-w-0">
4030
3923
  <TextInput
4031
3924
  v-model="imageForm.src" label="Image URL" type="url" placeholder="https://example.com/image.jpg"
@@ -4131,6 +4024,16 @@ const imgTransform = computed({
4131
4024
  help="Examples: 100%, 500px, 50vw, 300"
4132
4025
  />
4133
4026
 
4027
+ <SelectBtn
4028
+ v-model="videoForm.align"
4029
+ label="Video alignment"
4030
+ :options="[
4031
+ { value: 'full', label: 'Full width', icon: 'fit_screen' },
4032
+ { value: 'start', label: 'Align start', icon: 'align_horizontal_left' },
4033
+ { value: 'end', label: 'Align end', icon: 'align_horizontal_right' },
4034
+ ]"
4035
+ />
4036
+
4134
4037
  <SelectInput
4135
4038
  v-model="videoForm.aspectRatio" label="Aspect Ratio" :options="[
4136
4039
  { value: '16:9', label: '16:9 (Standard)' },
@@ -4142,12 +4045,12 @@ const imgTransform = computed({
4142
4045
  ]"
4143
4046
  />
4144
4047
 
4145
- <div v-if="videoForm.aspectRatio === 'custom'" class="grid grid-wrap-2 gap-05">
4146
- <TextInput v-model="videoForm.customWidth" label="Width ratio" placeholder="16" type="number" />
4147
- <TextInput v-model="videoForm.customHeight" label="Height ratio" placeholder="9" type="number" />
4048
+ <div v-if="videoForm.aspectRatio === 'custom'" class="grid grid-wrap-2 gap-05 m_gap-05">
4049
+ <TextInput v-model="videoForm.customWidth" class="m_grid-span-1" label="Width ratio" placeholder="16" type="number" />
4050
+ <TextInput v-model="videoForm.customHeight" class="m_grid-span-1" label="Height ratio" placeholder="9" type="number" />
4148
4051
  </div>
4149
4052
 
4150
- <div class="grid grid-wrap-2 gap-05 py-1">
4053
+ <div class="grid grid-wrap-2 gap-05 py-1 m_gap-05 m_py-05">
4151
4054
  <CheckInput v-model="videoForm.controls" label="Show controls" />
4152
4055
  <CheckInput v-model="videoForm.autoplay" label="Autoplay" />
4153
4056
  <CheckInput v-model="videoForm.mute" label="Mute" />
@@ -4175,14 +4078,14 @@ const imgTransform = computed({
4175
4078
  </Card>
4176
4079
  </div>
4177
4080
  <template #footer>
4178
- <div class="flex gap-05 w-100 ">
4179
- <Btn value="Cancel" flat thin @click="showVideoModal = false" />
4081
+ <div class="flex gap-05 w-100 m_column">
4082
+ <Btn value="Cancel" flat thin full-width-mobile @click="showVideoModal = false" />
4180
4083
  <Btn
4181
4084
  v-if="pendingVideoData?.existingVideo" value="Delete Video" color="red" flat thin icon="delete"
4182
- @click="deleteVideo"
4085
+ full-width-mobile @click="deleteVideo"
4183
4086
  />
4184
4087
  <Btn
4185
- value="Insert Video" class="ms-auto" :disabled="!videoForm.src || !isValidVideoUrl(videoForm.src)"
4088
+ :value="pendingVideoData?.existingVideo ? 'Save Video' : 'Insert Video'" class="ms-auto m_ms-0" full-width-mobile :disabled="!videoForm.src || !isValidVideoUrl(videoForm.src)"
4186
4089
  @click="submitVideo"
4187
4090
  />
4188
4091
  </div>