@bagelink/vue 1.19.1 → 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 (60) 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 +18654 -17606
  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 +354 -463
  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
@@ -0,0 +1,136 @@
1
+ export type ImageAlignment = 'full' | 'start' | 'end'
2
+
3
+ export interface ImageData {
4
+ src: string
5
+ alt: string
6
+ width: string
7
+ height: string
8
+ credit: string
9
+ figcaption: boolean
10
+ align: ImageAlignment
11
+ link: string
12
+ linkNewTab: boolean
13
+ }
14
+
15
+ export function createDefaultImageData(): ImageData {
16
+ return {
17
+ src: '',
18
+ alt: '',
19
+ width: '100%',
20
+ height: '',
21
+ credit: '',
22
+ figcaption: false,
23
+ align: 'full',
24
+ link: '',
25
+ linkNewTab: false,
26
+ }
27
+ }
28
+
29
+ function toCssDimension(value: string, fallback: string): string {
30
+ const normalized = value.trim()
31
+ if (!normalized) { return fallback }
32
+ return /[%a-z]/i.test(normalized) ? normalized : `${normalized}px`
33
+ }
34
+
35
+ function getImageFigure(element: HTMLElement): HTMLElement {
36
+ return element.tagName === 'FIGURE'
37
+ ? element
38
+ : element.closest('figure') as HTMLElement | null
39
+ || element.closest('a') as HTMLElement | null
40
+ || element
41
+ }
42
+
43
+ export function readImageData(element: HTMLElement): ImageData {
44
+ const figure = getImageFigure(element)
45
+ const image = (figure.matches('img') ? figure : figure.querySelector('img')) as HTMLImageElement | null
46
+ const anchor = image?.closest('a')
47
+ const caption = figure.querySelector('figcaption')
48
+ const alignValue = figure.getAttribute('data-align') || image?.getAttribute('data-align')
49
+ const align: ImageAlignment = alignValue === 'start' || alignValue === 'end' ? alignValue : 'full'
50
+
51
+ return {
52
+ src: image?.getAttribute('src') || '',
53
+ alt: image?.getAttribute('alt') || '',
54
+ width: figure.getAttribute('data-width') || image?.getAttribute('data-width') || figure.style.width || image?.style.width || '100%',
55
+ height: image?.getAttribute('data-height') || image?.style.height || '',
56
+ credit: figure.getAttribute('data-credit') || image?.getAttribute('data-credit') || '',
57
+ figcaption: Boolean(caption?.querySelector('.alt-text') || (caption && !caption.querySelector('.photo-credit'))),
58
+ align,
59
+ link: anchor?.getAttribute('href') || '',
60
+ linkNewTab: anchor?.getAttribute('target') === '_blank',
61
+ }
62
+ }
63
+
64
+ function createCaption(doc: Document, data: ImageData): HTMLElement | null {
65
+ if (!(data.figcaption && data.alt) && !data.credit) { return null }
66
+ const caption = doc.createElement('figcaption')
67
+ if (data.figcaption && data.alt) {
68
+ const alt = doc.createElement('strong')
69
+ alt.className = 'alt-text'
70
+ alt.textContent = data.alt
71
+ caption.appendChild(alt)
72
+ }
73
+ if (data.credit) {
74
+ if (caption.hasChildNodes()) { caption.appendChild(doc.createTextNode(' • ')) }
75
+ const credit = doc.createElement('span')
76
+ credit.className = 'photo-credit'
77
+ credit.textContent = data.credit
78
+ caption.appendChild(credit)
79
+ }
80
+ return caption
81
+ }
82
+
83
+ export function buildImageFigure(doc: Document, data: ImageData, existing?: HTMLElement | null): HTMLElement {
84
+ const previous = existing ? getImageFigure(existing) : null
85
+ const figure = previous?.tagName === 'FIGURE' ? previous : doc.createElement('figure')
86
+ const normalized = { ...createDefaultImageData(), ...data }
87
+ const width = normalized.width.trim() || (normalized.align === 'full' ? '100%' : '45%')
88
+
89
+ const image = doc.createElement('img')
90
+ image.src = normalized.src
91
+ image.alt = normalized.alt
92
+ image.style.width = '100%'
93
+ image.style.height = toCssDimension(normalized.height, 'auto')
94
+ if (normalized.height) { image.setAttribute('data-height', normalized.height) }
95
+
96
+ let content: HTMLElement = image
97
+ if (normalized.link.trim()) {
98
+ const anchor = doc.createElement('a')
99
+ anchor.href = normalized.link.trim()
100
+ if (normalized.linkNewTab) {
101
+ anchor.target = '_blank'
102
+ anchor.rel = 'noopener noreferrer'
103
+ }
104
+ anchor.appendChild(image)
105
+ content = anchor
106
+ }
107
+
108
+ figure.replaceChildren(content)
109
+ const caption = createCaption(doc, normalized)
110
+ if (caption) { figure.appendChild(caption) }
111
+
112
+ figure.classList.remove('align-full', 'align-start', 'align-end')
113
+ figure.classList.add('image-figure', `align-${normalized.align}`)
114
+ figure.setAttribute('data-media-type', 'image')
115
+ figure.setAttribute('data-align', normalized.align)
116
+ figure.setAttribute('data-width', width)
117
+ figure.style.width = toCssDimension(width, '100%')
118
+ if (normalized.credit) { figure.setAttribute('data-credit', normalized.credit) }
119
+ else { figure.removeAttribute('data-credit') }
120
+ figure.tabIndex = 0
121
+ figure.setAttribute('role', 'group')
122
+ figure.setAttribute('aria-label', normalized.alt ? `Image: ${normalized.alt}` : 'Image')
123
+
124
+ if (previous && previous !== figure) { previous.replaceWith(figure) }
125
+ return figure
126
+ }
127
+
128
+ export function hydrateImageFigures(root: ParentNode): void {
129
+ root.querySelectorAll<HTMLElement>('figure[data-media-type="image"], figure:has(img)').forEach((figure) => {
130
+ figure.classList.add('image-figure')
131
+ figure.tabIndex = 0
132
+ figure.setAttribute('role', 'group')
133
+ const alt = figure.querySelector('img')?.getAttribute('alt')
134
+ figure.setAttribute('aria-label', alt ? `Image: ${alt}` : 'Image')
135
+ })
136
+ }
@@ -0,0 +1,110 @@
1
+ const ALLOWED_TAGS = new Set([
2
+ 'A', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'EM', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
3
+ 'HR', 'I', 'LI', 'OL', 'P', 'PRE', 'S', 'STRIKE', 'STRONG', 'SUB', 'SUP', 'U', 'UL',
4
+ ])
5
+
6
+ function isSafeUrl(value: string): boolean {
7
+ const normalized = value
8
+ .replace(/&#(?:x([0-9a-f]+)|(\d+));?/gi, (_match, hex, decimal) =>
9
+ String.fromCodePoint(Number.parseInt(hex || decimal, hex ? 16 : 10)))
10
+ .replace(/&(tab|newline);/gi, '')
11
+ .split('')
12
+ .filter(character => {
13
+ const code = character.charCodeAt(0)
14
+ return code > 32 && !(code >= 127 && code <= 159)
15
+ })
16
+ .join('')
17
+ .trim()
18
+ .toLowerCase()
19
+ return !normalized.startsWith('javascript:')
20
+ && !normalized.startsWith('data:')
21
+ && !normalized.startsWith('vbscript:')
22
+ }
23
+
24
+ function unwrap(element: Element) {
25
+ const parent = element.parentNode
26
+ if (!parent) { return }
27
+ while (element.firstChild) {
28
+ parent.insertBefore(element.firstChild, element)
29
+ }
30
+ element.remove()
31
+ }
32
+
33
+ /**
34
+ * Removes source-specific markup while preserving the semantic document structure
35
+ * users expect when pasting from websites and document editors.
36
+ */
37
+ export function sanitizePastedHtml(html: string, doc: Document = document): string {
38
+ const template = doc.createElement('template')
39
+ template.innerHTML = html
40
+
41
+ template.content.querySelectorAll('script, style, link, meta, object, embed, iframe, form, input, button')
42
+ .forEach(element => element.remove())
43
+
44
+ const elements = Array.from(template.content.querySelectorAll('*'))
45
+ for (const element of elements) {
46
+ if (!element.isConnected && !template.content.contains(element)) { continue }
47
+
48
+ if (!ALLOWED_TAGS.has(element.tagName)) {
49
+ unwrap(element)
50
+ continue
51
+ }
52
+
53
+ const href = element.tagName === 'A' ? element.getAttribute('href')?.trim() : undefined
54
+ const opensNewTab = element.tagName === 'A' && element.getAttribute('target') === '_blank'
55
+ for (const attribute of Array.from(element.attributes)) {
56
+ element.removeAttribute(attribute.name)
57
+ }
58
+
59
+ if (href && isSafeUrl(href)) {
60
+ element.setAttribute('href', href)
61
+ if (opensNewTab) {
62
+ element.setAttribute('target', '_blank')
63
+ element.setAttribute('rel', 'noopener noreferrer')
64
+ }
65
+ }
66
+ }
67
+
68
+ return template.innerHTML
69
+ }
70
+
71
+ export interface PasteContent {
72
+ html?: string
73
+ text?: string
74
+ }
75
+
76
+ function createPlainTextFragment(doc: Document, text: string): DocumentFragment {
77
+ const fragment = doc.createDocumentFragment()
78
+ const lines = text.replace(/\r\n?/g, '\n').split('\n')
79
+ lines.forEach((line, index) => {
80
+ if (index > 0) { fragment.appendChild(doc.createElement('br')) }
81
+ fragment.appendChild(doc.createTextNode(line))
82
+ })
83
+ return fragment
84
+ }
85
+
86
+ /**
87
+ * Replaces the current selection with sanitized clipboard content and leaves the
88
+ * caret directly after the inserted content. History remains the caller's concern.
89
+ */
90
+ export function insertPastedContent(doc: Document, selection: Selection, content: PasteContent): boolean {
91
+ if (!selection.rangeCount || (!content.html && !content.text)) { return false }
92
+
93
+ const range = selection.getRangeAt(0)
94
+ const fragment = content.html
95
+ ? range.createContextualFragment(sanitizePastedHtml(content.html, doc))
96
+ : createPlainTextFragment(doc, content.text || '')
97
+
98
+ if (!fragment.hasChildNodes()) { return false }
99
+ range.deleteContents()
100
+ const lastNode = fragment.lastChild
101
+ range.insertNode(fragment)
102
+
103
+ if (lastNode) {
104
+ range.setStartAfter(lastNode)
105
+ range.collapse(true)
106
+ selection.removeAllRanges()
107
+ selection.addRange(range)
108
+ }
109
+ return true
110
+ }
@@ -1,4 +1,5 @@
1
1
  const ALIGN_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, blockquote, li, td, th'
2
+ const STYLE_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, blockquote, li, td, th'
2
3
 
3
4
  export const ACTIVE_STYLE_KEYS = [
4
5
  'bold',
@@ -19,6 +20,7 @@ export const ACTIVE_STYLE_KEYS = [
19
20
  'alignCenter',
20
21
  'alignRight',
21
22
  'alignJustify',
23
+ 'tableContext',
22
24
  'textDirection',
23
25
  'ltrDirection',
24
26
  'rtlDirection'
@@ -32,6 +34,20 @@ export function getSelectionRange(state: any) {
32
34
  return state.range
33
35
  }
34
36
 
37
+ function rangeOverlapsNodeContent(range: Range, node: Node): boolean {
38
+ const nodeRange = node.ownerDocument?.createRange()
39
+ if (!nodeRange) { return false }
40
+ nodeRange.selectNodeContents(node)
41
+ return range.compareBoundaryPoints(Range.END_TO_START, nodeRange) < 0
42
+ && range.compareBoundaryPoints(Range.START_TO_END, nodeRange) > 0
43
+ }
44
+
45
+ function getSelectedLeafBlocks(doc: Document, range: Range): HTMLElement[] {
46
+ return Array.from(doc.body.querySelectorAll<HTMLElement>(STYLE_BLOCK_SELECTOR))
47
+ .filter(block => !block.querySelector(STYLE_BLOCK_SELECTOR))
48
+ .filter(block => rangeOverlapsNodeContent(range, block))
49
+ }
50
+
35
51
  export function isStyleActive(style: string, doc: Document): boolean {
36
52
  const selection = doc.getSelection()
37
53
  if (!selection || !selection.rangeCount) { return false }
@@ -44,6 +60,21 @@ export function isStyleActive(style: string, doc: Document): boolean {
44
60
 
45
61
  if (!parent) { return false }
46
62
 
63
+ const selectedBlocks = range.collapsed ? [] : getSelectedLeafBlocks(doc, range)
64
+ const blockStyleTags = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote'])
65
+ if (blockStyleTags.has(style) && selectedBlocks.length > 0) {
66
+ return selectedBlocks.every(block => block.tagName.toLowerCase() === style)
67
+ }
68
+
69
+ if (style === 'tableContext' && selectedBlocks.length > 0) {
70
+ return selectedBlocks.every(block => Boolean(block.closest('table')))
71
+ }
72
+
73
+ if ((style === 'orderedList' || style === 'unorderedList') && selectedBlocks.length > 0) {
74
+ const listTag = style === 'orderedList' ? 'ol' : 'ul'
75
+ return selectedBlocks.every(block => block.tagName === 'LI' && Boolean(block.closest(listTag)))
76
+ }
77
+
47
78
  const checkParent = (element: Element | null, checker: (el: Element) => boolean): boolean => {
48
79
  if (!element || element === doc.body || element === doc.documentElement) { return false }
49
80
  if (checker(element)) { return true }
@@ -100,6 +131,7 @@ export function isStyleActive(style: string, doc: Document): boolean {
100
131
  const block = el.closest(ALIGN_BLOCK_SELECTOR) as HTMLElement | null
101
132
  return block?.style.textAlign === 'justify'
102
133
  },
134
+ tableContext: el => Boolean(el.closest('table')),
103
135
 
104
136
  // Text direction
105
137
  textDirection: (el) => {
@@ -135,18 +135,87 @@ export function syncTableColgroup(table: HTMLTableElement, targetCols?: number)
135
135
  }
136
136
  }
137
137
 
138
+ const CELL_VISUAL_PROPERTIES = [
139
+ 'background-color',
140
+ 'color',
141
+ 'padding-top',
142
+ 'padding-right',
143
+ 'padding-bottom',
144
+ 'padding-left',
145
+ 'border-top',
146
+ 'border-right',
147
+ 'border-bottom',
148
+ 'border-left',
149
+ 'text-align',
150
+ 'vertical-align',
151
+ 'direction',
152
+ 'font-family',
153
+ 'font-size',
154
+ 'font-style',
155
+ 'font-weight',
156
+ 'line-height',
157
+ 'letter-spacing',
158
+ 'white-space',
159
+ ] as const
160
+
138
161
  function copyCellBaseStyle(source: HTMLElement | null | undefined, target: HTMLElement) {
139
162
  if (source) {
140
- target.style.padding = source.style.padding || '8px'
141
- target.style.border = source.style.border || '1px solid var(--bgl-border-color, #ddd)'
142
- if (source.style.backgroundColor) {
143
- target.style.backgroundColor = source.style.backgroundColor
163
+ target.style.cssText = source.style.cssText
164
+ // Column widths are recalculated for the whole table after insertion.
165
+ target.style.removeProperty('width')
166
+
167
+ // Preserve styles supplied by the editor stylesheet or a consumer class, not
168
+ // only inline declarations. Temporarily ignore editor-only selection styling.
169
+ const wasSelected = source.classList.contains('table-cell-selected')
170
+ if (wasSelected) {
171
+ source.classList.remove('table-cell-selected')
172
+ }
173
+ const computedStyle = source.ownerDocument.defaultView?.getComputedStyle(source)
174
+ if (computedStyle) {
175
+ for (const property of CELL_VISUAL_PROPERTIES) {
176
+ const value = computedStyle.getPropertyValue(property)
177
+ || source.style.getPropertyValue(property)
178
+ if (value) {
179
+ target.style.setProperty(property, value)
180
+ }
181
+ }
144
182
  }
145
- if (source.style.color) {
146
- target.style.color = source.style.color
183
+ if (wasSelected) {
184
+ source.classList.add('table-cell-selected')
185
+ }
186
+
187
+ const persistentClasses = Array.from(source.classList)
188
+ .filter(className => className !== 'table-cell-selected')
189
+ if (persistentClasses.length) {
190
+ target.classList.add(...persistentClasses)
147
191
  }
148
- if (source.style.textAlign) {
149
- target.style.textAlign = source.style.textAlign
192
+
193
+ for (const attribute of ['dir', 'align', 'valign', 'bgcolor']) {
194
+ const value = source.getAttribute(attribute)
195
+ if (value !== null) {
196
+ target.setAttribute(attribute, value)
197
+ }
198
+ }
199
+
200
+ const hasPadding = Boolean(
201
+ target.style.padding
202
+ || target.style.paddingTop
203
+ || target.style.paddingRight
204
+ || target.style.paddingBottom
205
+ || target.style.paddingLeft
206
+ )
207
+ if (!hasPadding) {
208
+ target.style.padding = '8px'
209
+ }
210
+ const hasBorder = Boolean(
211
+ target.style.border
212
+ || target.style.borderTop
213
+ || target.style.borderRight
214
+ || target.style.borderBottom
215
+ || target.style.borderLeft
216
+ )
217
+ if (!hasBorder) {
218
+ target.style.border = '1px solid var(--bgl-border-color, #ddd)'
150
219
  }
151
220
  } else {
152
221
  target.style.padding = '8px'
@@ -0,0 +1,270 @@
1
+ export function normalizeVideoWidth(value?: string | null): string {
2
+ return value?.trim() || '100%'
3
+ }
4
+
5
+ export function toVideoCssWidth(value?: string | null): string {
6
+ const width = normalizeVideoWidth(value)
7
+ return /[%a-z]/i.test(width) ? width : `${width}px`
8
+ }
9
+
10
+ export type VideoAlignment = 'full' | 'start' | 'end'
11
+
12
+ export interface VideoData {
13
+ src: string
14
+ width: string
15
+ align: VideoAlignment
16
+ autoplay: boolean
17
+ mute: boolean
18
+ controls: boolean
19
+ loop: boolean
20
+ aspectRatio: string
21
+ customWidth: string
22
+ customHeight: string
23
+ caption: string
24
+ showCaption: boolean
25
+ }
26
+
27
+ export function createDefaultVideoData(): VideoData {
28
+ return {
29
+ src: '',
30
+ width: '100%',
31
+ align: 'full',
32
+ autoplay: false,
33
+ mute: false,
34
+ controls: true,
35
+ loop: false,
36
+ aspectRatio: '16:9',
37
+ customWidth: '',
38
+ customHeight: '',
39
+ caption: '',
40
+ showCaption: false,
41
+ }
42
+ }
43
+
44
+ function getYoutubeId(src: string): string {
45
+ try {
46
+ const url = new URL(src)
47
+ if (url.hostname === 'youtu.be') { return url.pathname.split('/').filter(Boolean)[0] || '' }
48
+ if (url.pathname.startsWith('/shorts/') || url.pathname.startsWith('/embed/')) {
49
+ return url.pathname.split('/').filter(Boolean)[1] || ''
50
+ }
51
+ return url.searchParams.get('v') || ''
52
+ } catch {
53
+ return ''
54
+ }
55
+ }
56
+
57
+ function getVideoFigure(element: HTMLElement): HTMLElement {
58
+ return element.tagName === 'FIGURE'
59
+ ? element
60
+ : element.closest('figure') as HTMLElement | null || element
61
+ }
62
+
63
+ function getBooleanAttribute(element: Element | null, dataName: string, nativeName: string, fallback: boolean): boolean {
64
+ const dataValue = element?.getAttribute(dataName)
65
+ if (dataValue !== null && dataValue !== undefined) { return dataValue === 'true' }
66
+ if (element?.hasAttribute(nativeName)) { return true }
67
+ return fallback
68
+ }
69
+
70
+ export function readVideoData(element: HTMLElement): VideoData {
71
+ const figure = getVideoFigure(element)
72
+ const container = figure.querySelector<HTMLElement>('.video-container[data-component="BglVideo"]')
73
+ const video = (figure.matches('video') ? figure : figure.querySelector('video')) as HTMLVideoElement | null
74
+ const iframe = (figure.matches('iframe') ? figure : figure.querySelector('iframe')) as HTMLIFrameElement | null
75
+ const source = figure.hasAttribute('data-video-src') ? figure : container || figure
76
+ const isCustom = source.getAttribute('data-custom-aspect-ratio') === 'true'
77
+ const caption = figure.querySelector('figcaption')?.textContent || ''
78
+ const alignValue = figure.getAttribute('data-align')
79
+ const align: VideoAlignment = alignValue === 'start' || alignValue === 'end' ? alignValue : 'full'
80
+
81
+ return {
82
+ src: source.getAttribute('data-video-src') || video?.getAttribute('src') || iframe?.getAttribute('src') || '',
83
+ width: normalizeVideoWidth(figure.getAttribute('data-width') || figure.style.width || container?.getAttribute('data-width') || video?.style.width),
84
+ align,
85
+ autoplay: getBooleanAttribute(source, 'data-autoplay', 'autoplay', video?.autoplay ?? false),
86
+ mute: getBooleanAttribute(source, 'data-mute', 'muted', video?.muted ?? false),
87
+ controls: getBooleanAttribute(source, 'data-controls', 'controls', video?.controls ?? true),
88
+ loop: getBooleanAttribute(source, 'data-loop', 'loop', video?.loop ?? false),
89
+ aspectRatio: isCustom ? 'custom' : source.getAttribute('data-aspect-ratio') || '16:9',
90
+ customWidth: source.getAttribute('data-custom-width') || '',
91
+ customHeight: source.getAttribute('data-custom-height') || '',
92
+ caption,
93
+ showCaption: Boolean(caption),
94
+ }
95
+ }
96
+
97
+ function resolvedAspectRatio(data: VideoData): { aspectRatio: string, isCustom: boolean } {
98
+ const isCustom = data.aspectRatio === 'custom'
99
+ let aspectRatio = isCustom && data.customWidth && data.customHeight
100
+ ? `${data.customWidth}:${data.customHeight}`
101
+ : data.aspectRatio
102
+
103
+ if (data.src.includes('youtube.com/shorts/') && aspectRatio === '16:9') {
104
+ aspectRatio = '9:16'
105
+ }
106
+ return { aspectRatio, isCustom }
107
+ }
108
+
109
+ function applyVideoMetadata(figure: HTMLElement, data: VideoData, aspectRatio: string, isCustom: boolean): void {
110
+ const width = normalizeVideoWidth(data.width)
111
+ figure.classList.remove('align-full', 'align-start', 'align-end')
112
+ figure.classList.add('video-figure', `align-${data.align}`)
113
+ figure.setAttribute('data-media-type', 'video')
114
+ figure.setAttribute('data-video-src', data.src)
115
+ figure.setAttribute('data-width', width)
116
+ figure.setAttribute('data-align', data.align)
117
+ figure.setAttribute('data-autoplay', String(data.autoplay))
118
+ figure.setAttribute('data-mute', String(data.mute))
119
+ figure.setAttribute('data-controls', String(data.controls))
120
+ figure.setAttribute('data-loop', String(data.loop))
121
+ figure.setAttribute('data-aspect-ratio', aspectRatio)
122
+ figure.setAttribute('data-custom-aspect-ratio', String(isCustom))
123
+ figure.setAttribute('data-custom-width', data.customWidth)
124
+ figure.setAttribute('data-custom-height', data.customHeight)
125
+ figure.style.width = toVideoCssWidth(width)
126
+ figure.tabIndex = 0
127
+ figure.setAttribute('role', 'group')
128
+ figure.setAttribute('aria-label', data.caption ? `Video: ${data.caption}` : 'Video')
129
+ }
130
+
131
+ function createVideoPlaceholder(doc: Document, data: VideoData, aspectRatio: string): HTMLElement {
132
+ const videoContainer = doc.createElement('div')
133
+ videoContainer.className = 'video-container'
134
+ videoContainer.setAttribute('data-component', 'BglVideo')
135
+ videoContainer.setAttribute('data-media-type', 'video')
136
+
137
+ const videoWrapper = doc.createElement('div')
138
+ videoWrapper.className = 'bgl_vid'
139
+ const placeholder = doc.createElement('div')
140
+ placeholder.className = 'video-placeholder'
141
+ placeholder.style.aspectRatio = aspectRatio.replace(':', '/')
142
+
143
+ const youtubeId = getYoutubeId(data.src)
144
+ const isYoutube = Boolean(youtubeId)
145
+ if (data.src.includes('youtube.com/shorts/')) { videoWrapper.classList.add('vid_short') }
146
+
147
+ if (isYoutube) {
148
+ placeholder.classList.add('has-thumbnail')
149
+ const thumbnail = doc.createElement('img')
150
+ thumbnail.className = 'video-thumbnail'
151
+ thumbnail.src = `https://img.youtube.com/vi/${youtubeId}/maxresdefault.jpg`
152
+ placeholder.appendChild(thumbnail)
153
+
154
+ const overlay = doc.createElement('div')
155
+ overlay.className = 'video-overlay'
156
+ const icon = doc.createElement('div')
157
+ icon.className = 'video-overlay-icon'
158
+ overlay.appendChild(icon)
159
+ placeholder.appendChild(overlay)
160
+ } else {
161
+ const icon = doc.createElement('div')
162
+ icon.className = 'video-placeholder-icon'
163
+ placeholder.appendChild(icon)
164
+ const description = doc.createElement('div')
165
+ description.className = 'video-placeholder-description'
166
+ description.textContent = data.src.includes('vimeo.com') ? 'Vimeo video' : 'video'
167
+ placeholder.appendChild(description)
168
+ }
169
+
170
+ videoWrapper.appendChild(placeholder)
171
+ videoContainer.appendChild(videoWrapper)
172
+ return videoContainer
173
+ }
174
+
175
+ export function buildVideoFigure(doc: Document, data: VideoData, existing?: HTMLElement | null): HTMLElement {
176
+ const previous = existing ? getVideoFigure(existing) : null
177
+ const figure = previous?.tagName === 'FIGURE' ? previous : doc.createElement('figure')
178
+ const normalized = { ...createDefaultVideoData(), ...data, width: normalizeVideoWidth(data.width) }
179
+ const { aspectRatio, isCustom } = resolvedAspectRatio(normalized)
180
+
181
+ figure.replaceChildren(createVideoPlaceholder(doc, normalized, aspectRatio))
182
+ applyVideoMetadata(figure, normalized, aspectRatio, isCustom)
183
+
184
+ if (normalized.showCaption && normalized.caption) {
185
+ const caption = doc.createElement('figcaption')
186
+ caption.textContent = normalized.caption
187
+ figure.appendChild(caption)
188
+ }
189
+
190
+ if (previous && previous !== figure) { previous.replaceWith(figure) }
191
+ return figure
192
+ }
193
+
194
+ function createResponsiveIframe(doc: Document, src: string, aspectRatio: string): HTMLElement {
195
+ const [ratioW, ratioH] = aspectRatio.split(':').map(Number)
196
+ const paddingBottom = ratioW && ratioH ? `${(ratioH / ratioW) * 100}%` : '56.25%'
197
+ const wrapper = doc.createElement('div')
198
+ wrapper.className = 'video-responsive'
199
+ wrapper.style.cssText = `position: relative; padding-bottom: ${paddingBottom}; height: 0; overflow: hidden; width: 100%;`
200
+ const iframe = doc.createElement('iframe')
201
+ iframe.src = src
202
+ iframe.frameBorder = '0'
203
+ iframe.setAttribute('allowfullscreen', '')
204
+ iframe.setAttribute('title', 'Video')
205
+ iframe.style.cssText = 'position: absolute; inset: 0; width: 100%; height: 100%;'
206
+ wrapper.appendChild(iframe)
207
+ return wrapper
208
+ }
209
+
210
+ function toYoutubeEmbedUrl(data: VideoData): string {
211
+ const id = getYoutubeId(data.src)
212
+ if (!id) { return data.src }
213
+ const params = new URLSearchParams()
214
+ if (data.autoplay) { params.set('autoplay', '1') }
215
+ if (data.mute) { params.set('mute', '1') }
216
+ if (data.loop) {
217
+ params.set('loop', '1')
218
+ params.set('playlist', id)
219
+ }
220
+ if (!data.controls) { params.set('controls', '0') }
221
+ return `https://www.youtube.com/embed/${id}${params.size ? `?${params}` : ''}`
222
+ }
223
+
224
+ function createOutputVideo(doc: Document, data: VideoData): HTMLElement {
225
+ if (data.src.includes('youtube.com') || data.src.includes('youtu.be')) {
226
+ return createResponsiveIframe(doc, toYoutubeEmbedUrl(data), data.aspectRatio)
227
+ }
228
+ if (data.src.includes('vimeo.com')) {
229
+ const match = data.src.match(/vimeo\.com\/(?:video\/)?(\d+)/)
230
+ return createResponsiveIframe(doc, match ? `https://player.vimeo.com/video/${match[1]}` : data.src, data.aspectRatio)
231
+ }
232
+ if (data.src.includes('<iframe')) {
233
+ const template = doc.createElement('template')
234
+ template.innerHTML = data.src
235
+ const src = template.content.querySelector('iframe')?.getAttribute('src') || ''
236
+ return createResponsiveIframe(doc, src, data.aspectRatio)
237
+ }
238
+
239
+ const video = doc.createElement('video')
240
+ video.src = data.src
241
+ if (data.controls) { video.setAttribute('controls', '') }
242
+ if (data.autoplay) { video.setAttribute('autoplay', '') }
243
+ if (data.mute) { video.setAttribute('muted', '') }
244
+ if (data.loop) { video.setAttribute('loop', '') }
245
+ video.style.cssText = 'width: 100%; height: auto; display: block;'
246
+ return video
247
+ }
248
+
249
+ export function serializeVideoFigures(root: ParentNode): void {
250
+ root.querySelectorAll<HTMLElement>('.video-container[data-component="BglVideo"]').forEach((container) => {
251
+ const figure = container.closest('figure') as HTMLElement | null
252
+ if (!figure) { return }
253
+ const data = readVideoData(figure)
254
+ const { aspectRatio, isCustom } = resolvedAspectRatio(data)
255
+ applyVideoMetadata(figure, data, aspectRatio, isCustom)
256
+ figure.classList.remove('video-figure')
257
+ container.replaceWith(createOutputVideo(figure.ownerDocument, { ...data, aspectRatio }))
258
+ })
259
+ }
260
+
261
+ export function hydrateVideoFigures(root: ParentNode): void {
262
+ root.querySelectorAll<HTMLElement>('figure[data-media-type="video"]').forEach((figure) => {
263
+ if (figure.querySelector('.video-container[data-component="BglVideo"]')) {
264
+ figure.tabIndex = 0
265
+ figure.setAttribute('role', 'group')
266
+ return
267
+ }
268
+ buildVideoFigure(figure.ownerDocument, readVideoData(figure), figure)
269
+ })
270
+ }