@bagelink/vue 1.19.1 → 1.20.1

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 (61) hide show
  1. package/dist/components/Dropdown.vue.d.ts +73 -48
  2. package/dist/components/Dropdown.vue.d.ts.map +1 -1
  3. package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
  4. package/dist/components/dataTable/DataTable.vue.d.ts.map +1 -1
  5. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts +1 -0
  6. package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
  7. package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
  8. package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
  9. package/dist/components/form/inputs/RichText/config.d.ts.map +1 -1
  10. package/dist/components/form/inputs/RichText/index.vue.d.ts +6 -0
  11. package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
  12. package/dist/components/form/inputs/RichText/richTextTypes.d.ts +1 -0
  13. package/dist/components/form/inputs/RichText/richTextTypes.d.ts.map +1 -1
  14. package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
  15. package/dist/components/form/inputs/RichText/utils/contentMetrics.d.ts +7 -0
  16. package/dist/components/form/inputs/RichText/utils/contentMetrics.d.ts.map +1 -0
  17. package/dist/components/form/inputs/RichText/utils/image.d.ts +17 -0
  18. package/dist/components/form/inputs/RichText/utils/image.d.ts.map +1 -0
  19. package/dist/components/form/inputs/RichText/utils/sanitizePaste.d.ts +15 -0
  20. package/dist/components/form/inputs/RichText/utils/sanitizePaste.d.ts.map +1 -0
  21. package/dist/components/form/inputs/RichText/utils/selection.d.ts +1 -1
  22. package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
  23. package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
  24. package/dist/components/form/inputs/RichText/utils/video.d.ts +23 -0
  25. package/dist/components/form/inputs/RichText/utils/video.d.ts.map +1 -0
  26. package/dist/components/form/inputs/SelectBtn.vue.d.ts +28 -1
  27. package/dist/components/form/inputs/SelectBtn.vue.d.ts.map +1 -1
  28. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -0
  29. package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
  30. package/dist/components/form/inputs/Upload/useFileUpload.d.ts +1 -0
  31. package/dist/components/form/inputs/Upload/useFileUpload.d.ts.map +1 -1
  32. package/dist/core.css +2 -2
  33. package/dist/index.cjs +218 -130
  34. package/dist/index.css +1 -1
  35. package/dist/index.mjs +18604 -17549
  36. package/dist/style.css +2 -2
  37. package/package.json +1 -1
  38. package/src/components/Dropdown.vue +48 -7
  39. package/src/components/FilterQuery.vue +1 -1
  40. package/src/components/Icon/Icon.vue +8 -0
  41. package/src/components/dataTable/DataTable.vue +6 -3
  42. package/src/components/form/inputs/RichText/components/EditorToolbar.vue +238 -8
  43. package/src/components/form/inputs/RichText/composables/useCommands.ts +23 -5
  44. package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +28 -0
  45. package/src/components/form/inputs/RichText/config.ts +17 -14
  46. package/src/components/form/inputs/RichText/index.vue +354 -463
  47. package/src/components/form/inputs/RichText/richTextTypes.ts +1 -0
  48. package/src/components/form/inputs/RichText/utils/commands.ts +437 -24
  49. package/src/components/form/inputs/RichText/utils/contentMetrics.ts +24 -0
  50. package/src/components/form/inputs/RichText/utils/image.ts +136 -0
  51. package/src/components/form/inputs/RichText/utils/sanitizePaste.ts +110 -0
  52. package/src/components/form/inputs/RichText/utils/selection.ts +32 -0
  53. package/src/components/form/inputs/RichText/utils/table.ts +77 -8
  54. package/src/components/form/inputs/RichText/utils/video.ts +270 -0
  55. package/src/components/form/inputs/SelectBtn.vue +126 -15
  56. package/src/components/form/inputs/TelInput.vue +1 -1
  57. package/src/components/form/inputs/Upload/UploadInput.vue +3 -2
  58. package/src/components/form/inputs/Upload/useFileUpload.ts +2 -0
  59. package/src/styles/colors.css +5 -2
  60. package/src/styles/core.css +1 -0
  61. package/src/styles/rich-content.css +109 -0
@@ -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,
@@ -67,11 +72,17 @@ const props = defineProps<{
67
72
  textColor?: string
68
73
  // Font size
69
74
  fontSize?: number | string
75
+ // Optional document metrics and limits
76
+ showCounter?: boolean
77
+ maxLength?: number
78
+ maxWords?: number
70
79
  // Field-specific CSS injected into the editor iframe.
71
80
  contentStyles?: string
81
+ // Optional image uploader. Must resolve to a persistent path key or URL.
82
+ uploadImage?: (file: File) => Promise<string>
72
83
  } & BagelInputShellProps>()
73
84
 
74
- const emit = defineEmits(['update:modelValue'])
85
+ const emit = defineEmits(['update:modelValue', 'limit-exceeded'])
75
86
 
76
87
  const { shellClass, shellStyle } = useBagelInputShell(props)
77
88
  const extensionRegistry = inject(RICH_TEXT_EXTENSION_REGISTRY_KEY, new Map())
@@ -107,6 +118,35 @@ const effectiveContentStyles = computed(() => [
107
118
  ...resolvedExtensions.value.map(extension => extension.contentStyles),
108
119
  ].filter(Boolean).join('\n'))
109
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
+
110
150
  function updateCustomToolbarStyles() {
111
151
  const { doc } = editor.state
112
152
  if (!doc) return
@@ -146,6 +186,25 @@ const editor = useEditor()
146
186
  const isInitializing = ref(false)
147
187
  const hasInitialized = ref(false)
148
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
+ })
149
208
 
150
209
  function setBlock(tagName: string, className?: string, removeClasses: string[] = []) {
151
210
  const { doc } = editor.state
@@ -355,17 +414,8 @@ let pendingLinkData: {
355
414
 
356
415
  // Image modal state
357
416
  const showImageModal = ref(false)
358
- const imageForm = ref({
359
- src: '',
360
- alt: '',
361
- width: '',
362
- height: '',
363
- credit: '',
364
- figcaption: false,
365
- align: 'full', // full, start, end
366
- link: '',
367
- linkNewTab: false
368
- })
417
+ const imageForm = ref(createDefaultImageData())
418
+ const imageUploadError = ref('')
369
419
  let pendingImageData: {
370
420
  selection: Selection
371
421
  range: Range
@@ -393,19 +443,7 @@ const inlineToolbarSelection = ref<Selection | null>(null)
393
443
 
394
444
  // Video modal state
395
445
  const showVideoModal = ref(false)
396
- const videoForm = ref({
397
- src: '',
398
- width: '',
399
- autoplay: false,
400
- mute: false,
401
- controls: true,
402
- loop: false,
403
- aspectRatio: '16:9',
404
- customWidth: '',
405
- customHeight: '',
406
- caption: '',
407
- showCaption: false
408
- })
446
+ const videoForm = ref(createDefaultVideoData())
409
447
  let pendingVideoData: {
410
448
  selection: Selection
411
449
  range: Range
@@ -804,6 +842,7 @@ function isValidUrl(url: string): boolean {
804
842
  function openImageModal(existingImage: HTMLElement | null = null) {
805
843
  const { doc } = editor.state
806
844
  if (!doc) { return }
845
+ imageUploadError.value = ''
807
846
 
808
847
  // Get current selection for new images
809
848
  if (!existingImage) {
@@ -821,51 +860,18 @@ function openImageModal(existingImage: HTMLElement | null = null) {
821
860
  }
822
861
 
823
862
  if (existingImage) {
824
- // Handle both <figure><img></figure> (editor-created) and bare <img> (loaded content)
825
- const img = existingImage.tagName === 'IMG'
826
- ? existingImage as HTMLImageElement
827
- : existingImage.querySelector('img')
828
- const figcaption = existingImage.querySelector('figcaption')
829
- const credit = existingImage.getAttribute('data-credit') || img?.getAttribute('data-credit') || ''
830
-
831
- // Read alignment from the figure (or bare img) data-align attribute
832
- const alignSource = existingImage.tagName === 'IMG' ? img : existingImage
833
- const align = alignSource?.getAttribute('data-align') || 'full'
834
-
835
- // Read link from the anchor wrapping the image (if any)
836
- const anchor = img?.closest('a')
837
- const link = anchor?.getAttribute('href') || ''
838
- const linkNewTab = anchor?.getAttribute('target') === '_blank'
839
-
840
- imageForm.value = {
841
- src: img?.src || '',
842
- alt: img?.alt || '',
843
- width: img?.getAttribute('data-width') || img?.style.width || '',
844
- height: img?.getAttribute('data-height') || img?.style.height || '',
845
- credit,
846
- figcaption: !!figcaption,
847
- align,
848
- link,
849
- linkNewTab
850
- }
863
+ imageForm.value = readImageData(existingImage)
851
864
  } else {
852
- // Reset form for new image
853
- imageForm.value = {
854
- src: '',
855
- alt: '',
856
- width: '',
857
- height: '',
858
- credit: '',
859
- figcaption: false,
860
- align: 'full',
861
- link: '',
862
- linkNewTab: false
863
- }
865
+ imageForm.value = createDefaultImageData()
864
866
  }
865
867
 
866
868
  showImageModal.value = true
867
869
  }
868
870
 
871
+ function handleImageUploadError() {
872
+ imageUploadError.value = 'Image upload failed. Please try again or paste an image URL.'
873
+ }
874
+
869
875
  // Function to submit image
870
876
  function submitImage() {
871
877
  if (!imageForm.value.src || !pendingImageData) { return }
@@ -874,124 +880,9 @@ function submitImage() {
874
880
  if (!doc) { return }
875
881
 
876
882
  const { existingImage } = pendingImageData
883
+ const elementToUpdate = buildImageFigure(doc, imageForm.value, existingImage)
877
884
 
878
- // Create image element
879
- const img = doc.createElement('img')
880
- img.src = imageForm.value.src
881
- img.alt = imageForm.value.alt || ''
882
-
883
- // Store original width/height values in data attributes
884
- if (imageForm.value.width) {
885
- img.setAttribute('data-width', imageForm.value.width)
886
- 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')) {
887
- img.style.width = imageForm.value.width
888
- } else {
889
- img.style.width = `${imageForm.value.width}px`
890
- }
891
- } else {
892
- // Floated images default to a partial width so text can wrap alongside;
893
- // full-width images fill the container.
894
- img.style.width = (imageForm.value.align === 'start' || imageForm.value.align === 'end') ? '45%' : '100%'
895
- }
896
-
897
- if (imageForm.value.height) {
898
- img.setAttribute('data-height', imageForm.value.height)
899
- 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') {
900
- img.style.height = imageForm.value.height
901
- } else {
902
- img.style.height = `${imageForm.value.height}px`
903
- }
904
- } else {
905
- img.style.height = 'auto'
906
- }
907
-
908
- // Helper function to create figcaption with alt text and credit (using safe DOM nodes)
909
- function createFigcaption() {
910
- const figcaption = doc!.createElement('figcaption')
911
-
912
- if (imageForm.value.figcaption && imageForm.value.alt) {
913
- const altStrong = doc!.createElement('strong')
914
- altStrong.className = 'alt-text'
915
- altStrong.textContent = imageForm.value.alt
916
- figcaption.appendChild(altStrong)
917
- }
918
-
919
- if (imageForm.value.credit) {
920
- if (figcaption.childNodes.length > 0) {
921
- figcaption.appendChild(doc!.createTextNode(' • '))
922
- }
923
- const creditSpan = doc!.createElement('span')
924
- creditSpan.className = 'photo-credit'
925
- creditSpan.textContent = imageForm.value.credit
926
- figcaption.appendChild(creditSpan)
927
- }
928
-
929
- return figcaption
930
- }
931
-
932
- // Wrap the image in an anchor when a link is provided, otherwise return the bare image
933
- function createImageContent(): HTMLElement {
934
- const link = imageForm.value.link?.trim()
935
- if (!link) { return img }
936
-
937
- const anchor = doc!.createElement('a')
938
- anchor.setAttribute('href', link)
939
- if (imageForm.value.linkNewTab) {
940
- anchor.setAttribute('target', '_blank')
941
- anchor.setAttribute('rel', 'noopener noreferrer')
942
- }
943
- anchor.appendChild(img)
944
- return anchor
945
- }
946
-
947
- // Create or update figure/image - always wrap in figure
948
- let elementToUpdate: HTMLElement
949
- if (existingImage) {
950
- if (existingImage.tagName.toLowerCase() === 'img') {
951
- // Converting standalone image to figure (always)
952
- const figure = doc.createElement('figure')
953
- figure.className = 'image-figure'
954
- if (imageForm.value.credit) {
955
- figure.setAttribute('data-credit', imageForm.value.credit)
956
- }
957
- figure.appendChild(createImageContent())
958
-
959
- // Add caption if needed (alt with checkbox checked OR credit exists)
960
- if ((imageForm.value.figcaption && imageForm.value.alt) || imageForm.value.credit) {
961
- figure.appendChild(createFigcaption())
962
- }
963
-
964
- existingImage.parentNode?.replaceChild(figure, existingImage)
965
- elementToUpdate = figure
966
- } else {
967
- // Updating an existing figure
968
- existingImage.innerHTML = '' // Clear existing content
969
- if (imageForm.value.credit) {
970
- existingImage.setAttribute('data-credit', imageForm.value.credit)
971
- }
972
- existingImage.appendChild(createImageContent())
973
-
974
- // Add caption if needed (alt with checkbox checked OR credit exists)
975
- if ((imageForm.value.figcaption && imageForm.value.alt) || imageForm.value.credit) {
976
- existingImage.appendChild(createFigcaption())
977
- }
978
- elementToUpdate = existingImage
979
- }
980
- } else {
981
- // Creating new image - always wrap in figure
982
- const figure = doc.createElement('figure')
983
- figure.className = 'image-figure'
984
- if (imageForm.value.credit) {
985
- figure.setAttribute('data-credit', imageForm.value.credit)
986
- }
987
- figure.appendChild(createImageContent())
988
-
989
- // Add caption if needed (alt with checkbox checked OR credit exists)
990
- if ((imageForm.value.figcaption && imageForm.value.alt) || imageForm.value.credit) {
991
- figure.appendChild(createFigcaption())
992
- }
993
- elementToUpdate = figure
994
-
885
+ if (!existingImage) {
995
886
  // Insert new element
996
887
  const { selection, range } = pendingImageData
997
888
  range.collapse(false)
@@ -1004,12 +895,6 @@ function submitImage() {
1004
895
  selection.addRange(range)
1005
896
  }
1006
897
 
1007
- // Apply alignment to the figure wrapper (full = full width, start/end = float)
1008
- const align = imageForm.value.align || 'full'
1009
- elementToUpdate.classList.remove('align-full', 'align-start', 'align-end')
1010
- elementToUpdate.classList.add(`align-${align}`)
1011
- elementToUpdate.setAttribute('data-align', align)
1012
-
1013
898
  updateContentWithHistory(doc)
1014
899
  showImageModal.value = false
1015
900
  pendingImageData = null
@@ -1107,6 +992,7 @@ function submitEmbed() {
1107
992
  iframe.frameBorder = '0'
1108
993
  iframe.setAttribute('allowfullscreen', '')
1109
994
  iframe.setAttribute('data-media-type', 'embed')
995
+ iframe.title = embedForm.value.alt || 'Embedded content'
1110
996
  iframe.className = 'embed-iframe'
1111
997
 
1112
998
  // Set width and height - always set them
@@ -1179,43 +1065,9 @@ function openVideoModal(existingVideo: HTMLElement | null = null) {
1179
1065
  }
1180
1066
 
1181
1067
  if (existingVideo) {
1182
- // Handle both editor-internal figures (with .video-container) and bare <video>/<iframe> elements
1183
- const container = existingVideo.querySelector('.video-container')
1184
- const bareVideo = existingVideo.tagName === 'VIDEO' ? existingVideo as HTMLVideoElement : null
1185
- const bareIframe = existingVideo.tagName === 'IFRAME' ? existingVideo as HTMLIFrameElement : null
1186
- const isCustom = container?.getAttribute('data-custom-aspect-ratio') === 'true'
1187
-
1188
- videoForm.value = {
1189
- src: container?.getAttribute('data-video-src')
1190
- || bareVideo?.src
1191
- || bareIframe?.src
1192
- || '',
1193
- width: container?.getAttribute('data-width') || (bareVideo?.style.width ?? '') || '',
1194
- autoplay: container ? container.getAttribute('data-autoplay') === 'true' : (bareVideo?.autoplay ?? false),
1195
- mute: container ? container.getAttribute('data-mute') === 'true' : (bareVideo?.muted ?? false),
1196
- controls: container ? container.getAttribute('data-controls') === 'true' : (bareVideo?.controls ?? true),
1197
- loop: container ? container.getAttribute('data-loop') === 'true' : (bareVideo?.loop ?? false),
1198
- aspectRatio: isCustom ? 'custom' : (container?.getAttribute('data-aspect-ratio') || '16:9'),
1199
- customWidth: container?.getAttribute('data-custom-width') || '',
1200
- customHeight: container?.getAttribute('data-custom-height') || '',
1201
- caption: existingVideo.querySelector?.('figcaption')?.textContent || '',
1202
- showCaption: !!existingVideo.querySelector?.('figcaption')
1203
- }
1068
+ videoForm.value = readVideoData(existingVideo)
1204
1069
  } else {
1205
- // Reset form for new video
1206
- videoForm.value = {
1207
- src: '',
1208
- width: '',
1209
- autoplay: false,
1210
- mute: false,
1211
- controls: true,
1212
- loop: false,
1213
- aspectRatio: '16:9',
1214
- customWidth: '',
1215
- customHeight: '',
1216
- caption: '',
1217
- showCaption: false
1218
- }
1070
+ videoForm.value = createDefaultVideoData()
1219
1071
  }
1220
1072
 
1221
1073
  showVideoModal.value = true
@@ -1264,133 +1116,7 @@ function submitVideo() {
1264
1116
  if (!doc) { return }
1265
1117
 
1266
1118
  const { existingVideo } = pendingVideoData
1267
-
1268
- // Calculate aspect ratio
1269
- let { aspectRatio } = videoForm.value
1270
- const isCustom = videoForm.value.aspectRatio === 'custom'
1271
- if (isCustom && videoForm.value.customWidth && videoForm.value.customHeight) {
1272
- aspectRatio = `${videoForm.value.customWidth}:${videoForm.value.customHeight}`
1273
- }
1274
-
1275
- // Auto-adjust aspect ratio for YouTube Shorts
1276
- const isYoutubeShort = videoForm.value.src.includes('youtube.com/shorts/')
1277
- if (isYoutubeShort && aspectRatio === '16:9') {
1278
- aspectRatio = '9:16'
1279
- }
1280
-
1281
- // Create video container
1282
- const videoContainer = doc.createElement('div')
1283
- videoContainer.className = 'video-container'
1284
- videoContainer.setAttribute('data-video-src', videoForm.value.src)
1285
- videoContainer.setAttribute('data-component', 'BglVideo')
1286
- videoContainer.setAttribute('data-media-type', 'video')
1287
- videoContainer.setAttribute('data-width', videoForm.value.width)
1288
- videoContainer.setAttribute('data-autoplay', videoForm.value.autoplay.toString())
1289
- videoContainer.setAttribute('data-mute', videoForm.value.mute.toString())
1290
- videoContainer.setAttribute('data-controls', videoForm.value.controls.toString())
1291
- videoContainer.setAttribute('data-loop', videoForm.value.loop.toString())
1292
- videoContainer.setAttribute('data-aspect-ratio', aspectRatio)
1293
- videoContainer.setAttribute('data-custom-aspect-ratio', isCustom.toString())
1294
- videoContainer.setAttribute('data-custom-width', videoForm.value.customWidth)
1295
- videoContainer.setAttribute('data-custom-height', videoForm.value.customHeight)
1296
-
1297
- // Set width
1298
- if (videoForm.value.width) {
1299
- videoContainer.style.width = videoForm.value.width.includes('%') || videoForm.value.width.includes('px')
1300
- ? videoForm.value.width
1301
- : `${videoForm.value.width}px`
1302
- }
1303
-
1304
- // Create the video placeholder/thumbnail instead of actual video
1305
- const videoWrapper = doc.createElement('div')
1306
- videoWrapper.className = 'bgl_vid'
1307
-
1308
- // Create video placeholder/thumbnail
1309
- const placeholder = doc.createElement('div')
1310
- placeholder.className = 'video-placeholder'
1311
- placeholder.style.aspectRatio = aspectRatio.replace(':', '/')
1312
-
1313
- // Detect video type for display
1314
- const isYoutube = videoForm.value.src.includes('youtube.com') || videoForm.value.src.includes('youtu.be')
1315
- const isVimeo = videoForm.value.src.includes('vimeo.com')
1316
-
1317
- // Create play icon
1318
- const playIcon = doc.createElement('div')
1319
- playIcon.className = 'video-placeholder-icon'
1320
-
1321
- // Create text description
1322
- const description = doc.createElement('div')
1323
- description.className = 'video-placeholder-description'
1324
- let platformText = 'video'
1325
- if (isYoutube) { platformText = 'YouTube video' }
1326
- else if (isVimeo) { platformText = 'Vimeo video' }
1327
- description.textContent = platformText
1328
-
1329
- // Try to get YouTube thumbnail if possible
1330
- if (isYoutube) {
1331
- let videoId = ''
1332
- if (videoForm.value.src.includes('youtube.com/watch?v=')) {
1333
- videoId = videoForm.value.src.split('watch?v=')[1]?.split('&')[0]
1334
- } else if (videoForm.value.src.includes('youtu.be/')) {
1335
- videoId = videoForm.value.src.split('youtu.be/')[1]?.split('?')[0]
1336
- } else if (videoForm.value.src.includes('youtube.com/shorts/')) {
1337
- videoId = videoForm.value.src.split('shorts/')[1]?.split('?')[0]
1338
- }
1339
-
1340
- if (videoId) {
1341
- // Add thumbnail class and create thumbnail image
1342
- placeholder.classList.add('has-thumbnail')
1343
-
1344
- const thumbnail = doc.createElement('img')
1345
- thumbnail.className = 'video-thumbnail'
1346
- thumbnail.src = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`
1347
- placeholder.appendChild(thumbnail)
1348
-
1349
- // Add overlay with play button
1350
- const overlay = doc.createElement('div')
1351
- overlay.className = 'video-overlay'
1352
-
1353
- const overlayPlayIcon = doc.createElement('div')
1354
- overlayPlayIcon.className = 'video-overlay-icon'
1355
-
1356
- overlay.appendChild(overlayPlayIcon)
1357
- placeholder.appendChild(overlay)
1358
- } else {
1359
- placeholder.appendChild(playIcon)
1360
- placeholder.appendChild(description)
1361
- }
1362
- } else {
1363
- placeholder.appendChild(playIcon)
1364
- placeholder.appendChild(description)
1365
- }
1366
-
1367
- // Add special class for YouTube Shorts
1368
- if (isYoutube && videoForm.value.src.includes('youtube.com/shorts/')) {
1369
- videoWrapper.classList.add('vid_short')
1370
- }
1371
-
1372
- videoWrapper.appendChild(placeholder)
1373
-
1374
- videoContainer.appendChild(videoWrapper)
1375
-
1376
- // Create or update figure
1377
- let figure: HTMLElement
1378
- if (existingVideo) {
1379
- figure = existingVideo
1380
- figure.innerHTML = '' // Clear existing content
1381
- } else {
1382
- figure = doc.createElement('figure')
1383
- figure.className = 'video-figure'
1384
- }
1385
-
1386
- figure.appendChild(videoContainer)
1387
-
1388
- // Add caption if enabled
1389
- if (videoForm.value.showCaption && videoForm.value.caption) {
1390
- const figcaption = doc.createElement('figcaption')
1391
- figcaption.textContent = videoForm.value.caption
1392
- figure.appendChild(figcaption)
1393
- }
1119
+ const figure = buildVideoFigure(doc, videoForm.value, existingVideo)
1394
1120
 
1395
1121
  // Insert new video or update existing
1396
1122
  if (!existingVideo) {
@@ -1825,6 +1551,7 @@ function deleteTable() {
1825
1551
  if (!pendingTableData?.existingTable) { return }
1826
1552
 
1827
1553
  removeTableElement(pendingTableData.existingTable)
1554
+ updateContentWithHistory(editor.state.doc!)
1828
1555
  showTableEditor.value = false
1829
1556
  pendingTableData = null
1830
1557
  }
@@ -1883,6 +1610,11 @@ function getCleanHTML(doc: Document | null): string {
1883
1610
  clone.querySelectorAll('.media-selected').forEach((el) => { el.classList.remove('media-selected') })
1884
1611
  clone.querySelectorAll('.table-cell-selected').forEach((el) => { el.classList.remove('table-cell-selected') })
1885
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
+ })
1886
1618
 
1887
1619
  // Unwrap .table-wrapper divs: restore original table margin-based alignment
1888
1620
  clone.querySelectorAll('.table-wrapper').forEach((wrapper) => {
@@ -1915,79 +1647,7 @@ function getCleanHTML(doc: Document | null): string {
1915
1647
  table.removeAttribute('data-edit-button-added')
1916
1648
  })
1917
1649
 
1918
- // Convert internal video placeholder divs → real <video> or <iframe> HTML
1919
- clone.querySelectorAll('.video-container[data-component="BglVideo"]').forEach((container) => {
1920
- const el = container as HTMLElement
1921
- const src = el.getAttribute('data-video-src') || ''
1922
- if (!src) { return }
1923
-
1924
- const autoplay = el.getAttribute('data-autoplay') === 'true'
1925
- const muted = el.getAttribute('data-mute') === 'true'
1926
- const controls = el.getAttribute('data-controls') !== 'false'
1927
- const loop = el.getAttribute('data-loop') === 'true'
1928
- const aspectRatio = el.getAttribute('data-aspect-ratio') || '16:9'
1929
- const width = el.getAttribute('data-width') || ''
1930
-
1931
- const widthStyle = width
1932
- ? `width: ${/[%pxvwemr]/.test(width) ? width : `${width}px`};`
1933
- : 'width: 100%;'
1934
-
1935
- const [ratioW, ratioH] = aspectRatio.split(':').map(Number)
1936
- const paddingBottom = ratioW && ratioH ? `${(ratioH / ratioW) * 100}%` : '56.25%'
1937
-
1938
- const makeResponsiveIframe = (iframeSrc: string): HTMLElement => {
1939
- const wrapper = document.createElement('div')
1940
- wrapper.style.cssText = `${widthStyle} position: relative; padding-bottom: ${paddingBottom}; height: 0; overflow: hidden;`
1941
- const iframe = document.createElement('iframe')
1942
- iframe.src = iframeSrc
1943
- iframe.frameBorder = '0'
1944
- iframe.setAttribute('allowfullscreen', '')
1945
- iframe.style.cssText = 'position: absolute; top: 0; left: 0; width: 100%; height: 100%;'
1946
- wrapper.appendChild(iframe)
1947
- return wrapper
1948
- }
1949
-
1950
- let newEl: HTMLElement | null = null
1951
-
1952
- if (src.includes('youtube.com') || src.includes('youtu.be')) {
1953
- let embedUrl = src
1954
- if (src.includes('youtube.com/watch?v=')) {
1955
- const videoId = src.split('watch?v=')[1]?.split('&')[0]
1956
- const params = []
1957
- if (autoplay) { params.push('autoplay=1') }
1958
- if (muted) { params.push('mute=1') }
1959
- if (loop) { params.push(`loop=1&playlist=${videoId}`) }
1960
- if (!controls) { params.push('controls=0') }
1961
- embedUrl = `https://www.youtube.com/embed/${videoId}${params.length ? `?${params.join('&')}` : ''}`
1962
- } else if (src.includes('youtu.be/')) {
1963
- const videoId = src.split('youtu.be/')[1]?.split('?')[0]
1964
- embedUrl = `https://www.youtube.com/embed/${videoId}`
1965
- } else if (src.includes('youtube.com/shorts/')) {
1966
- const videoId = src.split('shorts/')[1]?.split('?')[0]
1967
- embedUrl = `https://www.youtube.com/embed/${videoId}`
1968
- }
1969
- newEl = makeResponsiveIframe(embedUrl)
1970
- } else if (src.includes('vimeo.com')) {
1971
- const match = src.match(/vimeo\.com\/(\d+)/)
1972
- const embedUrl = match ? `https://player.vimeo.com/video/${match[1]}` : src
1973
- newEl = makeResponsiveIframe(embedUrl)
1974
- } else if (src.includes('<iframe')) {
1975
- const srcMatch = src.match(/src=["']([^"']+)["']/)
1976
- if (srcMatch) { newEl = makeResponsiveIframe(srcMatch[1]) }
1977
- } else {
1978
- // Direct video file
1979
- const video = document.createElement('video')
1980
- video.src = src
1981
- if (controls) { video.setAttribute('controls', '') }
1982
- if (autoplay) { video.setAttribute('autoplay', '') }
1983
- if (muted) { video.setAttribute('muted', '') }
1984
- if (loop) { video.setAttribute('loop', '') }
1985
- video.style.cssText = `${widthStyle} height: auto; display: block;`
1986
- newEl = video
1987
- }
1988
-
1989
- if (newEl) { el.parentNode?.replaceChild(newEl, el) }
1990
- })
1650
+ serializeVideoFigures(clone)
1991
1651
 
1992
1652
  // Strip internal placeholder content from video figures (the bgl_vid wrapper)
1993
1653
  // that remains after video-container conversion
@@ -2200,11 +1860,12 @@ function setupTableColumnResizing(doc: Document) {
2200
1860
  table: HTMLTableElement
2201
1861
  cell: HTMLTableCellElement
2202
1862
  insertPosition: 'before' | 'after'
1863
+ rowInsertPosition: 'before' | 'after'
2203
1864
  } | null = null
2204
1865
 
2205
1866
  const hideAllInlineControls = () => {
2206
1867
  doc.querySelectorAll<HTMLElement>('.table-inline-controls').forEach((ctrl) => {
2207
- 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')
2208
1869
  })
2209
1870
  }
2210
1871
 
@@ -2269,7 +1930,8 @@ function setupTableColumnResizing(doc: Document) {
2269
1930
  const cell = target.closest('td, th') as HTMLTableCellElement | null
2270
1931
  const table = cell?.closest('table') as HTMLTableElement | null
2271
1932
  const wrapper = table?.closest('.table-wrapper') as HTMLElement | null
2272
- if (!cell || !table || !wrapper) {
1933
+ const row = cell?.closest('tr') as HTMLTableRowElement | null
1934
+ if (!cell || !table || !wrapper || !row) {
2273
1935
  hideAllInlineControls()
2274
1936
  return
2275
1937
  }
@@ -2277,12 +1939,14 @@ function setupTableColumnResizing(doc: Document) {
2277
1939
  const controls = wrapper.querySelector<HTMLElement>('.table-inline-controls')
2278
1940
  const addBtn = controls?.querySelector<HTMLElement>('.table-col-add-btn')
2279
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')
2280
1944
  const thumbBtn = controls?.querySelector<HTMLElement>('.table-col-thumb')
2281
- if (!controls || !addBtn || !addLine || !thumbBtn) { return }
1945
+ if (!controls || !addBtn || !addLine || !rowAddBtn || !rowAddLine || !thumbBtn) { return }
2282
1946
 
2283
1947
  doc.querySelectorAll<HTMLElement>('.table-inline-controls').forEach((other) => {
2284
1948
  if (other !== controls) {
2285
- 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')
2286
1950
  }
2287
1951
  })
2288
1952
 
@@ -2292,6 +1956,8 @@ function setupTableColumnResizing(doc: Document) {
2292
1956
 
2293
1957
  const tableTop = tableRect.top - wrapperRect.top
2294
1958
  const tableHeight = tableRect.height
1959
+ const tableLeft = tableRect.left - wrapperRect.left
1960
+ const tableWidth = tableRect.width
2295
1961
  const cellLeft = cellRect.left - wrapperRect.left
2296
1962
  const cellRight = cellRect.right - wrapperRect.left
2297
1963
  const cellCenter = cellLeft + cellRect.width / 2
@@ -2302,18 +1968,29 @@ function setupTableColumnResizing(doc: Document) {
2302
1968
  const distRight = Math.abs(e.clientX - cellRect.right)
2303
1969
  const nearLeft = distLeft <= distRight
2304
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)
2305
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'
2306
1980
 
2307
1981
  // In LTR: left edge inserts 'before', right edge inserts 'after'. In RTL: reversed.
2308
1982
  const insertPosition: 'before' | 'after' = nearLeft
2309
1983
  ? (isRTL ? 'after' : 'before')
2310
1984
  : (isRTL ? 'before' : 'after')
1985
+ const rowInsertPosition: 'before' | 'after' = isHeaderRow ? 'after' : (nearTop ? 'before' : 'after')
2311
1986
  const boundaryX = nearLeft ? cellLeft : cellRight
1987
+ const boundaryY = (isHeaderRow ? row.getBoundingClientRect().bottom : (nearTop ? cellRect.top : cellRect.bottom)) - wrapperRect.top
2312
1988
 
2313
1989
  activeHoverState = {
2314
1990
  table,
2315
1991
  cell,
2316
1992
  insertPosition,
1993
+ rowInsertPosition,
2317
1994
  }
2318
1995
 
2319
1996
  controls.classList.add('is-visible', 'show-col-thumb')
@@ -2331,6 +2008,18 @@ function setupTableColumnResizing(doc: Document) {
2331
2008
  } else {
2332
2009
  controls.classList.remove('show-col-add')
2333
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
+ }
2334
2023
  }
2335
2024
 
2336
2025
  const onHoverPointerMove = (e: PointerEvent) => {
@@ -2348,7 +2037,32 @@ function setupTableColumnResizing(doc: Document) {
2348
2037
  const onPointerDown = (e: PointerEvent) => {
2349
2038
  const target = e.target as HTMLElement | null
2350
2039
 
2351
- // 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
2352
2066
  const addColBtn = target?.closest('.table-col-add-btn') as HTMLElement | null
2353
2067
  if (addColBtn) {
2354
2068
  e.preventDefault()
@@ -2371,7 +2085,7 @@ function setupTableColumnResizing(doc: Document) {
2371
2085
  return
2372
2086
  }
2373
2087
 
2374
- // 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)
2375
2089
  const thumbColBtn = target?.closest('.table-col-thumb') as HTMLElement | null
2376
2090
  if (thumbColBtn) {
2377
2091
  e.stopPropagation()
@@ -2469,7 +2183,7 @@ function setupTableColumnResizing(doc: Document) {
2469
2183
  return
2470
2184
  }
2471
2185
 
2472
- // 3. Handle column border drag-resizing via usePointerSession (with Escape rollback)
2186
+ // 4. Handle column border drag-resizing via usePointerSession (with Escape rollback)
2473
2187
  const hovered = getHoveredResizeCell(e)
2474
2188
  if (!hovered || !hovered.cell.nextElementSibling) { return }
2475
2189
 
@@ -2578,6 +2292,18 @@ function setupTableEditButtons(doc: Document) {
2578
2292
  addBtn.setAttribute('contenteditable', 'false')
2579
2293
  addBtn.setAttribute('tabindex', '-1')
2580
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
+
2581
2307
  const thumbBtn = doc.createElement('span')
2582
2308
  thumbBtn.className = 'table-col-thumb'
2583
2309
  thumbBtn.textContent = '⋯'
@@ -2588,6 +2314,8 @@ function setupTableEditButtons(doc: Document) {
2588
2314
 
2589
2315
  controls.appendChild(addLine)
2590
2316
  controls.appendChild(addBtn)
2317
+ controls.appendChild(rowAddLine)
2318
+ controls.appendChild(rowAddBtn)
2591
2319
  controls.appendChild(thumbBtn)
2592
2320
  return controls
2593
2321
  }
@@ -2799,6 +2527,24 @@ function setupAutoWrapping(doc: Document) {
2799
2527
  }
2800
2528
  }
2801
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
+
2802
2548
  // Handle input events
2803
2549
  doc.addEventListener('input', (e) => {
2804
2550
  const inputEvent = e as InputEvent
@@ -2975,9 +2721,7 @@ function setupAutoWrapping(doc: Document) {
2975
2721
  subtree: true
2976
2722
  })
2977
2723
 
2978
- // Handle clicks on links to open link modal
2979
- doc.addEventListener('click', (e) => {
2980
- const target = e.target as HTMLElement
2724
+ function openMediaFromTarget(target: HTMLElement, event?: Event): boolean {
2981
2725
  const link = target.closest('a')
2982
2726
  const table = target.closest('table')
2983
2727
 
@@ -3015,27 +2759,30 @@ function setupAutoWrapping(doc: Document) {
3015
2759
  if (table) {
3016
2760
  // Table clicks handled only by edit button
3017
2761
  } else if (videoFigure) {
3018
- e.preventDefault()
3019
- e.stopPropagation()
2762
+ event?.preventDefault()
2763
+ event?.stopPropagation()
3020
2764
  videoFigure.classList.add('media-selected')
3021
2765
  openVideoModal(videoFigure)
2766
+ return true
3022
2767
  } else if (imageFigure) {
3023
- e.preventDefault()
3024
- e.stopPropagation()
2768
+ event?.preventDefault()
2769
+ event?.stopPropagation()
3025
2770
  imageFigure.classList.add('media-selected')
3026
2771
  openImageModal(imageFigure)
2772
+ return true
3027
2773
  } else if (embedFigure) {
3028
- e.preventDefault()
3029
- e.stopPropagation()
2774
+ event?.preventDefault()
2775
+ event?.stopPropagation()
3030
2776
  embedFigure.classList.add('media-selected')
3031
2777
  openEmbedModal(embedFigure)
2778
+ return true
3032
2779
  } else if (link && link.href) {
3033
- e.preventDefault()
3034
- e.stopPropagation()
2780
+ event?.preventDefault()
2781
+ event?.stopPropagation()
3035
2782
 
3036
2783
  // Get current selection
3037
2784
  const selection = doc.getSelection()
3038
- if (!selection) { return }
2785
+ if (!selection) { return false }
3039
2786
 
3040
2787
  // Create a range that selects the entire link
3041
2788
  const range = doc.createRange()
@@ -3056,7 +2803,21 @@ function setupAutoWrapping(doc: Document) {
3056
2803
  }
3057
2804
 
3058
2805
  showLinkModal.value = true
2806
+ return true
3059
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)
3060
2821
  })
3061
2822
 
3062
2823
  // Handle focus to remove placeholder when user starts interacting
@@ -3166,7 +2927,7 @@ async function initEditor() {
3166
2927
 
3167
2928
  /* Ensure all text elements inherit the theme color */
3168
2929
  p, h1, h2, h3, h4, h5, h6, div, span, li {
3169
- color: inherit !important;
2930
+ color: inherit;
3170
2931
  }
3171
2932
  table {
3172
2933
  border-collapse: collapse;
@@ -3188,12 +2949,12 @@ async function initEditor() {
3188
2949
  /* Table edit button styles */
3189
2950
  .table-edit-btn {
3190
2951
  position: absolute;
3191
- top: -8px;
2952
+ bottom: -8px;
3192
2953
  right: -8px;
3193
- background: #000;
2954
+ background: rgba(0, 0, 0, 0.72);
3194
2955
  color: #fff !important;
3195
2956
  border: 1px solid #fff;
3196
- border-radius: 4px;
2957
+ border-radius: 6px;
3197
2958
  padding: 4px 8px;
3198
2959
  font-size: 12px;
3199
2960
  cursor: pointer;
@@ -3248,6 +3009,7 @@ async function initEditor() {
3248
3009
  }
3249
3010
 
3250
3011
  .table-col-add-btn,
3012
+ .table-row-add-btn,
3251
3013
  .table-col-thumb {
3252
3014
  position: absolute;
3253
3015
  display: flex;
@@ -3264,7 +3026,8 @@ async function initEditor() {
3264
3026
  transition: opacity 0.14s ease, transform 0.14s ease, background-color 0.14s ease, border-color 0.14s ease, visibility 0.14s ease;
3265
3027
  }
3266
3028
 
3267
- .table-col-add-btn {
3029
+ .table-col-add-btn,
3030
+ .table-row-add-btn {
3268
3031
  width: 20px;
3269
3032
  height: 20px;
3270
3033
  border-radius: 999px;
@@ -3275,16 +3038,18 @@ async function initEditor() {
3275
3038
  cursor: pointer;
3276
3039
  box-shadow: 0 2px 6px rgba(0, 123, 255, 0.35);
3277
3040
  z-index: 98;
3041
+ padding-bottom: 2px;
3278
3042
  }
3279
3043
 
3280
- .table-col-add-btn:hover {
3044
+ .table-col-add-btn:hover,
3045
+ .table-row-add-btn:hover {
3281
3046
  background: #0056b3;
3282
3047
  transform: translate(-50%, -50%) scale(1.08) !important;
3283
3048
  }
3284
3049
 
3285
- .table-col-add-line {
3050
+ .table-col-add-line,
3051
+ .table-row-add-line {
3286
3052
  position: absolute;
3287
- width: 2px;
3288
3053
  background: #007bff;
3289
3054
  pointer-events: none;
3290
3055
  opacity: 0;
@@ -3294,6 +3059,15 @@ async function initEditor() {
3294
3059
  z-index: 94;
3295
3060
  }
3296
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
+
3297
3071
  .table-col-thumb {
3298
3072
  width: 28px;
3299
3073
  height: 14px;
@@ -3323,19 +3097,22 @@ async function initEditor() {
3323
3097
  border-color: #0056b3;
3324
3098
  }
3325
3099
 
3326
- .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 {
3327
3102
  opacity: 1;
3328
3103
  visibility: visible;
3329
3104
  transform: translate(-50%, -50%) scale(1);
3330
3105
  }
3331
3106
 
3332
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,
3333
3109
  .table-inline-controls.is-dragging-col .table-col-add-line {
3334
3110
  opacity: 0.45;
3335
3111
  visibility: visible;
3336
3112
  }
3337
3113
 
3338
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,
3339
3116
  .table-inline-controls.is-dragging-col .table-col-add-line {
3340
3117
  opacity: 0.9;
3341
3118
  visibility: visible;
@@ -3354,6 +3131,55 @@ async function initEditor() {
3354
3131
  }
3355
3132
 
3356
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
+
3357
3183
  /* Figures wrapping media: ::after badge + hover ring.
3358
3184
  No display override — <figure> is already block, <img>/<video> keep their natural display. */
3359
3185
  figure:has(img),
@@ -3447,6 +3273,25 @@ async function initEditor() {
3447
3273
  width: 100%;
3448
3274
  }
3449
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
+
3450
3295
  /* Linked images: the wrapping <a> should behave like the image block.
3451
3296
  In the editor, disable navigation so clicks open the edit modal. */
3452
3297
  figure.image-figure > a {
@@ -3503,6 +3348,8 @@ async function initEditor() {
3503
3348
 
3504
3349
  if (normalizedModelValue.value) {
3505
3350
  doc.body.innerHTML = normalizedModelValue.value
3351
+ hydrateVideoFigures(doc.body)
3352
+ hydrateImageFigures(doc.body)
3506
3353
  } else if (props.placeholder) {
3507
3354
  const placeholderEl = doc.createElement('p')
3508
3355
  placeholderEl.className = 'placeholder'
@@ -3764,10 +3611,12 @@ function updateIframeColors() {
3764
3611
  font-family: ${currentFontFamily.value} !important;
3765
3612
  }
3766
3613
 
3767
- /* 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
+ }
3768
3618
  p, h1, h2, h3, h4, h5, h6, div, span, li {
3769
- color: inherit !important;
3770
- font-size: inherit !important;
3619
+ color: inherit;
3771
3620
  }
3772
3621
  ${effectiveContentStyles.value}
3773
3622
  `
@@ -3808,6 +3657,8 @@ watch(normalizedModelValue, (newValue) => {
3808
3657
  }
3809
3658
  } else {
3810
3659
  doc.body.innerHTML = newValue
3660
+ hydrateVideoFigures(doc.body)
3661
+ hydrateImageFigures(doc.body)
3811
3662
  }
3812
3663
 
3813
3664
  detectAndSetDirection()
@@ -3839,6 +3690,16 @@ watch(() => editor.state.content, () => {
3839
3690
  emit('update:modelValue', getCleanHTML(editor.state.doc ?? null))
3840
3691
  })
3841
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
+
3842
3703
  // Watch table alignment changes and update the table immediately
3843
3704
  watch(() => tableForm.value.alignment, (newAlignment) => {
3844
3705
  if (pendingTableData?.existingTable) {
@@ -3904,6 +3765,7 @@ const imgTransform = computed({
3904
3765
  :selectedStyles="editor.state.selectedStyles" :hide-images="hideImages" :hide-videos="hideVideos"
3905
3766
  :hide-embeds="hideEmbed" :hide-tables="hideTables" :hide-alignment="hideAlignment"
3906
3767
  :hide-directions="hideDirections" :hide-h5-h6="hideH5H6" :hide="effectiveHideArray"
3768
+ :document-colors="documentColors"
3907
3769
  @action="commands.execute"
3908
3770
  >
3909
3771
  <template #end>
@@ -3926,6 +3788,17 @@ const imgTransform = computed({
3926
3788
  </slot>
3927
3789
  </template>
3928
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>
3929
3802
  <div class="editor-container" :class="{ 'split-view': editor.state.isSplitView }">
3930
3803
  <div
3931
3804
  class="content-area radius-05"
@@ -4037,7 +3910,15 @@ const imgTransform = computed({
4037
3910
  width="l" @update:open="showImageModal = $event"
4038
3911
  >
4039
3912
  <div class="image-dialog-layout">
4040
- <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>
4041
3922
  <div class="flex flex-column gap-05 min-w-0">
4042
3923
  <TextInput
4043
3924
  v-model="imageForm.src" label="Image URL" type="url" placeholder="https://example.com/image.jpg"
@@ -4143,6 +4024,16 @@ const imgTransform = computed({
4143
4024
  help="Examples: 100%, 500px, 50vw, 300"
4144
4025
  />
4145
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
+
4146
4037
  <SelectInput
4147
4038
  v-model="videoForm.aspectRatio" label="Aspect Ratio" :options="[
4148
4039
  { value: '16:9', label: '16:9 (Standard)' },
@@ -4154,12 +4045,12 @@ const imgTransform = computed({
4154
4045
  ]"
4155
4046
  />
4156
4047
 
4157
- <div v-if="videoForm.aspectRatio === 'custom'" class="grid grid-wrap-2 gap-05">
4158
- <TextInput v-model="videoForm.customWidth" label="Width ratio" placeholder="16" type="number" />
4159
- <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" />
4160
4051
  </div>
4161
4052
 
4162
- <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">
4163
4054
  <CheckInput v-model="videoForm.controls" label="Show controls" />
4164
4055
  <CheckInput v-model="videoForm.autoplay" label="Autoplay" />
4165
4056
  <CheckInput v-model="videoForm.mute" label="Mute" />
@@ -4187,14 +4078,14 @@ const imgTransform = computed({
4187
4078
  </Card>
4188
4079
  </div>
4189
4080
  <template #footer>
4190
- <div class="flex gap-05 w-100 ">
4191
- <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" />
4192
4083
  <Btn
4193
4084
  v-if="pendingVideoData?.existingVideo" value="Delete Video" color="red" flat thin icon="delete"
4194
- @click="deleteVideo"
4085
+ full-width-mobile @click="deleteVideo"
4195
4086
  />
4196
4087
  <Btn
4197
- 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)"
4198
4089
  @click="submitVideo"
4199
4090
  />
4200
4091
  </div>