@bagelink/vue 1.19.1 → 1.20.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.
- package/dist/components/Dropdown.vue.d.ts +73 -48
- package/dist/components/Dropdown.vue.d.ts.map +1 -1
- package/dist/components/Icon/Icon.vue.d.ts.map +1 -1
- package/dist/components/dataTable/DataTable.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts +1 -0
- package/dist/components/form/inputs/RichText/components/EditorToolbar.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/composables/useCommands.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/composables/useEditorKeyboard.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/config.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/index.vue.d.ts +6 -0
- package/dist/components/form/inputs/RichText/index.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/richTextTypes.d.ts +1 -0
- package/dist/components/form/inputs/RichText/richTextTypes.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/commands.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/contentMetrics.d.ts +7 -0
- package/dist/components/form/inputs/RichText/utils/contentMetrics.d.ts.map +1 -0
- package/dist/components/form/inputs/RichText/utils/image.d.ts +17 -0
- package/dist/components/form/inputs/RichText/utils/image.d.ts.map +1 -0
- package/dist/components/form/inputs/RichText/utils/sanitizePaste.d.ts +15 -0
- package/dist/components/form/inputs/RichText/utils/sanitizePaste.d.ts.map +1 -0
- package/dist/components/form/inputs/RichText/utils/selection.d.ts +1 -1
- package/dist/components/form/inputs/RichText/utils/selection.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/table.d.ts.map +1 -1
- package/dist/components/form/inputs/RichText/utils/video.d.ts +23 -0
- package/dist/components/form/inputs/RichText/utils/video.d.ts.map +1 -0
- package/dist/components/form/inputs/SelectBtn.vue.d.ts +28 -1
- package/dist/components/form/inputs/SelectBtn.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts +2 -0
- package/dist/components/form/inputs/Upload/UploadInput.vue.d.ts.map +1 -1
- package/dist/components/form/inputs/Upload/useFileUpload.d.ts +1 -0
- package/dist/components/form/inputs/Upload/useFileUpload.d.ts.map +1 -1
- package/dist/core.css +2 -2
- package/dist/index.cjs +218 -130
- package/dist/index.css +1 -1
- package/dist/index.mjs +18604 -17549
- package/dist/style.css +2 -2
- package/package.json +1 -1
- package/src/components/Dropdown.vue +48 -7
- package/src/components/FilterQuery.vue +1 -1
- package/src/components/Icon/Icon.vue +8 -0
- package/src/components/dataTable/DataTable.vue +6 -3
- package/src/components/form/inputs/RichText/components/EditorToolbar.vue +238 -8
- package/src/components/form/inputs/RichText/composables/useCommands.ts +23 -5
- package/src/components/form/inputs/RichText/composables/useEditorKeyboard.ts +28 -0
- package/src/components/form/inputs/RichText/config.ts +17 -14
- package/src/components/form/inputs/RichText/index.vue +354 -463
- package/src/components/form/inputs/RichText/richTextTypes.ts +1 -0
- package/src/components/form/inputs/RichText/utils/commands.ts +437 -24
- package/src/components/form/inputs/RichText/utils/contentMetrics.ts +24 -0
- package/src/components/form/inputs/RichText/utils/image.ts +136 -0
- package/src/components/form/inputs/RichText/utils/sanitizePaste.ts +110 -0
- package/src/components/form/inputs/RichText/utils/selection.ts +32 -0
- package/src/components/form/inputs/RichText/utils/table.ts +77 -8
- package/src/components/form/inputs/RichText/utils/video.ts +270 -0
- package/src/components/form/inputs/SelectBtn.vue +126 -15
- package/src/components/form/inputs/TelInput.vue +1 -1
- package/src/components/form/inputs/Upload/UploadInput.vue +3 -2
- package/src/components/form/inputs/Upload/useFileUpload.ts +2 -0
- package/src/styles/colors.css +5 -2
- package/src/styles/core.css +1 -0
- 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.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 (
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
+
}
|