@shelamkoff/rector 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +25 -24
  2. package/dist/core/clipboard/Clipboard.d.ts +2 -0
  3. package/dist/core/clipboard/Clipboard.js +52 -24
  4. package/dist/core/index.js +195 -194
  5. package/dist/core/locale/en.d.ts +1 -0
  6. package/dist/core/locale/en.js +1 -0
  7. package/dist/core/locale/ru.d.ts +1 -0
  8. package/dist/core/locale/ru.js +1 -0
  9. package/dist/core/themes/light.css +1 -1
  10. package/dist/core/themes/variables.css +72 -10
  11. package/dist/locale/en.d.ts +41 -0
  12. package/dist/locale/ru.d.ts +41 -0
  13. package/dist/plugins/attaches/attaches.css +16 -14
  14. package/dist/plugins/attaches/index.js +997 -920
  15. package/dist/plugins/attaches/locale/en.d.ts +9 -0
  16. package/dist/plugins/attaches/locale/en.js +11 -2
  17. package/dist/plugins/attaches/locale/ru.d.ts +9 -0
  18. package/dist/plugins/attaches/locale/ru.js +11 -2
  19. package/dist/plugins/carousel/carousel.css +213 -49
  20. package/dist/plugins/carousel/index.js +918 -858
  21. package/dist/plugins/carousel/locale/en.d.ts +15 -0
  22. package/dist/plugins/carousel/locale/en.js +18 -3
  23. package/dist/plugins/carousel/locale/ru.d.ts +15 -0
  24. package/dist/plugins/carousel/locale/ru.js +18 -3
  25. package/dist/plugins/gallery/README.md +92 -92
  26. package/dist/plugins/gallery/README.ru.md +17 -17
  27. package/dist/plugins/gallery/gallery.css +44 -19
  28. package/dist/plugins/gallery/index.js +375 -354
  29. package/dist/plugins/gallery/locale/en.d.ts +8 -0
  30. package/dist/plugins/gallery/locale/en.js +11 -3
  31. package/dist/plugins/gallery/locale/ru.d.ts +8 -0
  32. package/dist/plugins/gallery/locale/ru.js +11 -3
  33. package/dist/plugins/gallery/settings.js +15 -10
  34. package/dist/plugins/gallery/view-empty.d.ts +2 -0
  35. package/dist/plugins/gallery/view-empty.js +21 -15
  36. package/dist/plugins/gallery/view-filled.d.ts +2 -2
  37. package/dist/plugins/gallery/view-filled.js +84 -72
  38. package/dist/plugins/image/image.css +7 -2
  39. package/dist/plugins/image/index.js +365 -345
  40. package/dist/plugins/image/locale/en.d.ts +8 -0
  41. package/dist/plugins/image/locale/en.js +11 -3
  42. package/dist/plugins/image/locale/ru.d.ts +8 -0
  43. package/dist/plugins/image/locale/ru.js +11 -3
  44. package/dist/plugins/image/view-empty.d.ts +2 -0
  45. package/dist/plugins/image/view-empty.js +21 -15
  46. package/dist/plugins/image/view-filled.d.ts +2 -2
  47. package/dist/plugins/image/view-filled.js +77 -72
  48. package/dist/plugins/shared/dropzone.d.ts +6 -0
  49. package/dist/plugins/shared/dropzone.js +47 -36
  50. package/dist/plugins/shared/layer.d.ts +13 -0
  51. package/dist/plugins/shared/layer.js +45 -0
  52. package/dist/plugins/shared/sourceEditor.css +165 -0
  53. package/dist/plugins/shared/sourceEditor.d.ts +97 -0
  54. package/dist/plugins/shared/sourceEditor.js +260 -0
  55. package/dist/renderer/renderers/carousel/index.js +24 -23
  56. package/dist/renderer/renderers/carousel/styles.css +8 -2
  57. package/dist/renderer/renderers/gallery/README.md +41 -41
  58. package/dist/renderer/renderers/gallery/README.ru.md +41 -41
  59. package/dist/renderer/renderers/gallery/index.js +100 -85
  60. package/dist/renderer/renderers/gallery/styles.css +417 -401
  61. package/dist/shared/galleryMasonry.d.ts +34 -0
  62. package/dist/shared/galleryMasonry.js +152 -0
  63. package/package.json +29 -28
@@ -1,858 +1,918 @@
1
- import { BlockPluginAbstract } from '../BlockPluginAbstract.js'
2
- import { uid } from '../../core/uid.js'
3
- import {
4
- normalizeCarouselAspectRatio,
5
- normalizeCarouselData,
6
- validateCarouselData,
7
- } from '../../shared/carouselData.js'
8
- import {
9
- sanitizeRawHtml,
10
- sanitizeUrl,
11
- setSafeUrlAttribute,
12
- setSanitizedRawHtml,
13
- } from '../../shared/sanitize/index.js'
14
- import { makeActionBtn, makeSep } from '../shared/actionBar.js'
15
- import { renderDropzone } from '../shared/dropzone.js'
16
- import { triggerFileInput } from '../shared/fileInput.js'
17
- import { CarouselState } from './state.js'
18
- import { READ_ONLY_INTERACTIVE_ATTRIBUTE } from '../../core/constants.js'
19
- import {
20
- ICON, ICON_BACK, ICON_CHEVRON, ICON_CODE, ICON_NEXT, ICON_PREVIOUS,
21
- ICON_REPLACE, ICON_SELECT, ICON_SETTINGS, ICON_TRASH, ICON_UPLOAD, ICON_URL,
22
- } from './icons.js'
23
-
24
- const editorStyles = new URL('./carousel.css', import.meta.url).href
25
-
26
- /**
27
- * Read a local image while respecting the owning block's lifecycle.
28
- * @param {File} file File to encode.
29
- * @param {AbortSignal} signal Lifecycle signal owned by the rendered block.
30
- * @returns {Promise<string>} A data URL for the file.
31
- */
32
- function readFileDataUrl(file, signal) {
33
- if (signal.aborted) {
34
- return Promise.reject(signal.reason || new DOMException('File read aborted', 'AbortError'))
35
- }
36
- return new Promise((resolve, reject) => {
37
- const reader = new FileReader()
38
- let settled = false
39
- /**
40
- * Run a completion callback exactly once and detach the abort listener.
41
- * @param {() => void} callback Completion callback to invoke.
42
- * @returns {void}
43
- */
44
- const finish = (callback) => {
45
- if (settled) return
46
- settled = true
47
- signal.removeEventListener('abort', onSignalAbort)
48
- callback()
49
- }
50
- const rejectAbort = () => reject(signal.reason || new DOMException('File read aborted', 'AbortError'))
51
- const onSignalAbort = () => {
52
- if (reader.readyState === FileReader.LOADING) reader.abort()
53
- finish(rejectAbort)
54
- }
55
- signal.addEventListener('abort', onSignalAbort, { once: true })
56
- reader.onload = () => finish(() => resolve(typeof reader.result === 'string' ? reader.result : ''))
57
- reader.onerror = () => finish(() => reject(reader.error || new Error('Failed to read file')))
58
- reader.onabort = () => finish(rejectAbort)
59
- try {
60
- reader.readAsDataURL(file)
61
- } catch (error) {
62
- finish(() => reject(error))
63
- }
64
- })
65
- }
66
-
67
- /**
68
- * Determine the slide type represented by a local file. Some file pickers omit
69
- * the MIME type, so common image and video extensions are used as a narrow
70
- * fallback only when no MIME type is available.
71
- * @param {File} file File selected by the user or supplied by a drop operation.
72
- * @returns {'image' | 'video' | null} Supported slide type, or `null`.
73
- */
74
- function getMediaFileType(file) {
75
- const type = file.type.toLowerCase()
76
- if (type.startsWith('image/')) return 'image'
77
- if (type.startsWith('video/')) return 'video'
78
- if (type) return null
79
- if (/\.(?:avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name)) return 'image'
80
- if (/\.(?:mp4|m4v|mov|ogv|webm)$/i.test(file.name)) return 'video'
81
- return null
82
- }
83
-
84
- /**
85
- * Check whether a local file can be represented by a carousel slide.
86
- * @param {File} file File selected by the user or supplied by a drop operation.
87
- * @returns {boolean} Whether the file is a supported image or video.
88
- */
89
- function isSupportedMediaFile(file) {
90
- return getMediaFileType(file) !== null
91
- }
92
-
93
- /**
94
- * Infer the media type represented by a sanitized URL. URLs without a known
95
- * video extension remain images because browsers can still decode extensionless
96
- * image endpoints, while treating them as videos would remove image semantics.
97
- * @param {string} url Sanitized media URL.
98
- * @returns {'image' | 'video'} Slide type inferred from the URL path.
99
- */
100
- function getMediaUrlType(url) {
101
- return /\.(?:m4v|mov|mp4|ogg|ogv|webm)(?:[?#]|$)/i.test(url) ? 'video' : 'image'
102
- }
103
-
104
- /**
105
- * @typedef {import('../../shared/carouselData').CarouselData} CarouselData
106
- * @typedef {import('../../shared/carouselData').CarouselSlide} CarouselSlide
107
- * @typedef {Object} CarouselAction
108
- * @property {string} label User-visible label for the application-owned source.
109
- * @property {string} [icon] Trusted application-provided SVG or HTML markup.
110
- * @property {(context: { signal: AbortSignal }) => Promise<CarouselSlide[] | null>} handler Loads slides and observes the supplied lifecycle signal.
111
- * @typedef {Object} CarouselConfig
112
- * @property {(file: File, context: { signal: AbortSignal }) => Promise<{ url: string, poster?: string }>} [uploadFile] Uploads an image or video selected in the browser. Without this callback images become data URLs, while videos use temporary object URLs retained until editor disposal.
113
- * @property {CarouselAction[]} [actions] Additional application-owned slide sources such as a media library. Returned slides are normalized and unusable entries are ignored.
114
- * @property {boolean} [injectStyles=true] Whether the editor should load the built-in carousel stylesheet.
115
- * @property {string} [css] Additional stylesheet URL, or the replacement URL when `injectStyles` is `false`.
116
- */
117
-
118
- /**
119
- * Editable mixed-media carousel with extensible file sources and display controls.
120
- * @extends {BlockPluginAbstract<CarouselConfig>}
121
- */
122
- export class CarouselBlock extends BlockPluginAbstract {
123
- static isTextBlock = false
124
- static styles = [editorStyles]
125
- type = 'carousel'
126
- icon = ICON
127
- inlineTools = false
128
- pasteConfig = { files: ['image/*', 'video/*'] }
129
-
130
- /** @type {WeakMap<HTMLElement, CarouselState>} */
131
- #states = new WeakMap()
132
- /** Temporary local URLs retained for undo/redo until editor disposal. @type {Set<string>} */
133
- #objectUrls = new Set()
134
- /**
135
- * Return the localized toolbox label for this block.
136
- * @returns {string}
137
- */
138
- get title() { return this._t('title', 'Carousel') }
139
-
140
- /** Create a stable identifier for a new slide. @returns {string} */
141
- #createId() { return `slide-${uid()}` }
142
- /**
143
- * Create the editable DOM owned by this block instance.
144
- * @param {Record<string, unknown>} data Serialized carousel data.
145
- * @param {import('../../core/types').BlockMutationContext} context Editor mutation and lifecycle context.
146
- * @returns {HTMLElement} Root element owned by this block render.
147
- */
148
- render(data, context) {
149
- const wrapper = document.createElement('div')
150
- wrapper.className = 'oe-carousel-block'
151
- wrapper.contentEditable = 'false'
152
- wrapper.tabIndex = -1
153
- const state = new CarouselState(data, () => this.#createId(), context)
154
- this.#states.set(wrapper, state)
155
- this.#build(wrapper, state)
156
-
157
- const pending = /** @type {File | undefined} */ (/** @type {any} */ (data)?._pendingFile)
158
- if (pending && !context.readOnly) {
159
- state.pendingUpload = this.#addFiles(wrapper, [pending]).finally(() => {
160
- if (this.#states.get(wrapper) === state) state.pendingUpload = null
161
- })
162
- }
163
- return wrapper
164
- }
165
-
166
- /**
167
- * Serialize the current block DOM into document data.
168
- * @param {HTMLElement} element Root element returned by `render()`.
169
- * @returns {CarouselData} Normalized serializable carousel data.
170
- */
171
- save(element) {
172
- const state = this.#states.get(element)
173
- return state ? structuredClone(state.data) : normalizeCarouselData({}, () => this.#createId())
174
- }
175
-
176
- /**
177
- * Check whether serialized data satisfies this block's schema.
178
- * @param {Record<string, unknown>} data Candidate serialized block data.
179
- * @returns {boolean} Whether the candidate satisfies the carousel schema.
180
- */
181
- validate(data) { return validateCarouselData(data) }
182
-
183
- /**
184
- * Check whether the block has no meaningful user content.
185
- * @param {HTMLElement} element Root element returned by `render()`.
186
- * @returns {boolean} Whether the carousel contains no slides.
187
- */
188
- isEmpty(element) { return (this.#states.get(element)?.data.slides.length || 0) === 0 }
189
-
190
- /**
191
- * Extract neutral text that can initialize another block type.
192
- * @param {HTMLElement} element Root element returned by `render()`.
193
- * @returns {{ text: string }} Text assembled from slide captions and alternatives.
194
- */
195
- exportData(element) {
196
- const state = this.#states.get(element)
197
- return { text: state?.data.slides.map(slide => slide.caption || slide.alt || '').filter(Boolean).join(', ') || '' }
198
- }
199
-
200
- /**
201
- * Report whether paste handling completes asynchronously.
202
- * @param {HTMLElement} element Root element returned by `render()`.
203
- * @returns {Promise<void>} Promise settled after a pending pasted file is handled.
204
- */
205
- waitForPaste(element) { return this.#states.get(element)?.pendingUpload ?? Promise.resolve() }
206
-
207
- /**
208
- * Release listeners and resources owned by this block element.
209
- * @param {HTMLElement} element Root element returned by `render()`.
210
- * @returns {void}
211
- */
212
- destroy(element) {
213
- const state = this.#states.get(element)
214
- if (!state) return
215
- state.dispose()
216
- this.#states.delete(element)
217
- }
218
-
219
- /**
220
- * Release temporary local video URLs after the owning editor has discarded
221
- * its blocks and history snapshots.
222
- * @returns {void}
223
- */
224
- dispose() {
225
- for (const url of this.#objectUrls) URL.revokeObjectURL(url)
226
- this.#objectUrls.clear()
227
- }
228
-
229
- /**
230
- * Handle supported pasted content for this block.
231
- * @param {import('../../types').PasteEvent} event Normalized editor paste event.
232
- * @returns {{ _pendingFile: File } | null} Deferred file marker, or `null` for unsupported content.
233
- */
234
- onPaste(event) {
235
- if (event.type === 'file' && event.file) return { _pendingFile: event.file }
236
- return null
237
- }
238
-
239
- /**
240
- * Rebuild the current carousel view from mutable block state.
241
- * @param {HTMLElement} wrapper Root element owned by this block render.
242
- * @param {CarouselState} state Mutable state associated with `wrapper`.
243
- * @returns {void}
244
- */
245
- #build(wrapper, state) {
246
- const signal = state.resetView()
247
- wrapper.replaceChildren()
248
- wrapper.classList.toggle('oe-carousel-block--filled', state.data.slides.length > 0)
249
- if (state.data.slides.length === 0) {
250
- this.#renderEmpty(wrapper, state, signal)
251
- return
252
- }
253
- wrapper.appendChild(this.#stage(wrapper, state, signal))
254
- if (!state.context.readOnly) wrapper.appendChild(this.#actions(wrapper, state, signal))
255
- }
256
-
257
- /**
258
- * Render the empty-state file and application-source chooser.
259
- * @param {HTMLElement} wrapper Root element owned by this block render.
260
- * @param {CarouselState} state Mutable state associated with `wrapper`.
261
- * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
262
- * @returns {void}
263
- */
264
- #renderEmpty(wrapper, state, signal) {
265
- if (state.context.readOnly) {
266
- const empty = document.createElement('div')
267
- empty.className = 'oe-carousel-block__empty'
268
- empty.textContent = this._t('emptyReadonly', 'No slides')
269
- wrapper.appendChild(empty)
270
- return
271
- }
272
- renderDropzone(wrapper, signal, {
273
- select: 'oe-carousel-block__select',
274
- selectIcon: 'oe-carousel-block__select-icon',
275
- selectText: 'oe-carousel-block__select-text',
276
- selectLink: 'oe-carousel-block__select-link',
277
- selectActions: 'oe-carousel-block__select-actions',
278
- selectAction: 'oe-carousel-block__select-action',
279
- dropzoneActive: 'oe-carousel-block__select--dragover',
280
- filled: 'oe-carousel-block--filled',
281
- }, {
282
- iconHtml: ICON_SELECT,
283
- uploadText: this._t('upload', 'Upload'),
284
- afterText: this._t('dropzoneText', 'images or videos from your device or drag and drop them here'),
285
- onUploadClick: () => this.#triggerFileInput(wrapper),
286
- onDrop: dataTransfer => { if (dataTransfer.files.length) void this.#addFiles(wrapper, [...dataTransfer.files]) },
287
- actions: [
288
- ...this.#sourceActions(wrapper, state),
289
- { icon: ICON_URL, label: this._t('addUrl', 'Add URL'), onSelect: () => this.#addUrl(wrapper, state) },
290
- { icon: ICON_CODE, label: this._t('addHtml', 'Add HTML'), onSelect: () => this.#addHtml(wrapper, state) },
291
- ],
292
- })
293
- }
294
-
295
- /**
296
- * Adapt application-provided slide sources to dropzone actions.
297
- * @param {HTMLElement} wrapper Root element owned by this block render.
298
- * @param {CarouselState} state Mutable state associated with `wrapper`.
299
- * @returns {Array<{icon?: string,label: string,onSelect: () => void}>} Selectable source actions.
300
- */
301
- #sourceActions(wrapper, state) {
302
- return (this._config.actions || []).map(action => ({
303
- icon: action.icon,
304
- label: action.label,
305
- onSelect: () => { void this.#runAction(wrapper, state, action) },
306
- }))
307
- }
308
-
309
- /**
310
- * Build the active media stage and optional navigation controls.
311
- * @param {HTMLElement} wrapper Root element owned by this block render.
312
- * @param {CarouselState} state Mutable state associated with `wrapper`.
313
- * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
314
- * @returns {HTMLElement} Carousel stage for the active slide.
315
- */
316
- #stage(wrapper, state, signal) {
317
- const slide = state.data.slides[state.activeIndex]
318
- const stage = document.createElement('div')
319
- stage.className = 'oe-carousel-block__stage'
320
- if (state.data.options.aspectRatio) stage.style.aspectRatio = state.data.options.aspectRatio
321
-
322
- const media = document.createElement('div')
323
- media.className = 'oe-carousel-block__media'
324
- if (slide.type === 'image') {
325
- const image = document.createElement('img')
326
- setSafeUrlAttribute(image, 'src', slide.src || '', 'media')
327
- image.alt = slide.alt || ''
328
- media.appendChild(image)
329
- } else if (slide.type === 'video') {
330
- const video = document.createElement('video')
331
- video.controls = true
332
- video.preload = 'metadata'
333
- setSafeUrlAttribute(video, 'src', slide.src || '', 'media')
334
- setSafeUrlAttribute(video, 'poster', slide.poster || '', 'media')
335
- video.setAttribute('aria-label', slide.alt || this._t('video', 'Video slide'))
336
- media.appendChild(video)
337
- } else {
338
- const html = document.createElement('div')
339
- html.className = 'oe-carousel-block__html'
340
- setSanitizedRawHtml(html, slide.html || '')
341
- media.appendChild(html)
342
- }
343
- stage.appendChild(media)
344
-
345
- if (state.data.slides.length > 1 && state.data.options.navigation) {
346
- const previous = this.#navButton(this._t('previous', 'Previous slide'), ICON_PREVIOUS, 'previous', () => this.#activate(wrapper, state, state.activeIndex - 1), signal)
347
- const next = this.#navButton(this._t('next', 'Next slide'), ICON_NEXT, 'next', () => this.#activate(wrapper, state, state.activeIndex + 1), signal)
348
- if (!state.data.options.loop) {
349
- previous.disabled = state.activeIndex === 0
350
- next.disabled = state.activeIndex === state.data.slides.length - 1
351
- }
352
- stage.append(previous, next)
353
- }
354
-
355
- const counter = document.createElement('span')
356
- counter.className = 'oe-carousel-block__counter'
357
- counter.setAttribute('aria-live', 'polite')
358
- counter.textContent = `${state.activeIndex + 1} / ${state.data.slides.length}`
359
- stage.appendChild(counter)
360
-
361
- const caption = document.createElement('div')
362
- caption.className = 'oe-carousel-block__caption'
363
- caption.contentEditable = state.context.readOnly ? 'false' : 'true'
364
- caption.dataset.placeholder = this._t('caption', 'Caption')
365
- caption.textContent = slide.caption || ''
366
- if (!state.context.readOnly) {
367
- caption.addEventListener('input', () => { slide.caption = caption.textContent?.trim() || '' }, { signal })
368
- caption.addEventListener('keydown', event => {
369
- if (event.key === 'Backspace' && !caption.textContent?.trim()) {
370
- event.preventDefault()
371
- event.stopPropagation()
372
- }
373
- }, { signal })
374
- }
375
- stage.appendChild(caption)
376
-
377
- if (state.data.slides.length > 1 && state.data.options.pagination) {
378
- const dots = document.createElement('div')
379
- dots.className = 'oe-carousel-block__dots'
380
- state.data.slides.forEach((_, index) => {
381
- const dot = document.createElement('button')
382
- dot.type = 'button'
383
- dot.setAttribute(READ_ONLY_INTERACTIVE_ATTRIBUTE, '')
384
- dot.className = 'oe-carousel-block__dot'
385
- dot.classList.toggle('oe-carousel-block__dot--active', index === state.activeIndex)
386
- dot.setAttribute('aria-label', this._t('goToSlide', 'Go to slide {index}').replace('{index}', String(index + 1)))
387
- dot.setAttribute('aria-current', index === state.activeIndex ? 'true' : 'false')
388
- dot.addEventListener('click', () => this.#activate(wrapper, state, index), { signal })
389
- dots.appendChild(dot)
390
- })
391
- stage.appendChild(dots)
392
- }
393
-
394
- if (state.data.options.thumbnails && state.data.slides.length > 1) {
395
- const thumbnails = document.createElement('div')
396
- thumbnails.className = 'oe-carousel-block__thumbnails'
397
- state.data.slides.forEach((item, index) => thumbnails.appendChild(this.#thumbnail(wrapper, state, item, index, signal)))
398
- stage.appendChild(thumbnails)
399
- }
400
- return stage
401
- }
402
-
403
- /**
404
- * Create one previous or next navigation button.
405
- * @param {string} label Accessible button label.
406
- * @param {string} icon Trusted internal SVG markup.
407
- * @param {string} direction Direction modifier used by the stylesheet.
408
- * @param {() => void} activate Callback that activates the adjacent slide.
409
- * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
410
- * @returns {HTMLButtonElement} Configured navigation button.
411
- */
412
- #navButton(label, icon, direction, activate, signal) {
413
- const button = document.createElement('button')
414
- button.type = 'button'
415
- button.setAttribute(READ_ONLY_INTERACTIVE_ATTRIBUTE, '')
416
- button.className = `oe-carousel-block__nav oe-carousel-block__nav--${direction}`
417
- button.innerHTML = icon
418
- button.setAttribute('aria-label', label)
419
- button.addEventListener('click', activate, { signal })
420
- return button
421
- }
422
-
423
- /**
424
- * Create one thumbnail navigation control.
425
- * @param {HTMLElement} wrapper Root element owned by this block render.
426
- * @param {CarouselState} state Mutable state associated with `wrapper`.
427
- * @param {CarouselSlide} slide Slide represented by the thumbnail.
428
- * @param {number} index Zero-based slide index.
429
- * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
430
- * @returns {HTMLButtonElement} Configured thumbnail button.
431
- */
432
- #thumbnail(wrapper, state, slide, index, signal) {
433
- const button = document.createElement('button')
434
- button.type = 'button'
435
- button.setAttribute(READ_ONLY_INTERACTIVE_ATTRIBUTE, '')
436
- button.className = 'oe-carousel-block__thumbnail'
437
- button.classList.toggle('oe-carousel-block__thumbnail--active', index === state.activeIndex)
438
- button.setAttribute('aria-label', this._t('goToSlide', 'Go to slide {index}').replace('{index}', String(index + 1)))
439
- button.setAttribute('aria-current', index === state.activeIndex ? 'true' : 'false')
440
- if (slide.type === 'image' && slide.src) {
441
- const image = document.createElement('img')
442
- setSafeUrlAttribute(image, 'src', slide.src, 'media')
443
- image.alt = ''
444
- button.appendChild(image)
445
- } else {
446
- button.textContent = String(index + 1)
447
- }
448
- button.addEventListener('click', () => this.#activate(wrapper, state, index), { signal })
449
- return button
450
- }
451
-
452
- /**
453
- * Activate a slide, applying loop or boundary behavior from current options.
454
- * @param {HTMLElement} wrapper Root element owned by this block render.
455
- * @param {CarouselState} state Mutable state associated with `wrapper`.
456
- * @param {number} index Requested zero-based slide index.
457
- * @returns {void}
458
- */
459
- #activate(wrapper, state, index) {
460
- const count = state.data.slides.length
461
- if (!count) return
462
- state.activeIndex = state.data.options.loop ? (index + count) % count : Math.max(0, Math.min(index, count - 1))
463
- this.#build(wrapper, state)
464
- }
465
-
466
- /**
467
- * Build the editing action bar shown below a populated carousel.
468
- * @param {HTMLElement} wrapper Root element owned by this block render.
469
- * @param {CarouselState} state Mutable state associated with `wrapper`.
470
- * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
471
- * @returns {HTMLElement} Carousel action bar.
472
- */
473
- #actions(wrapper, state, signal) {
474
- const actions = document.createElement('div')
475
- actions.className = 'oe-carousel-block__actions'
476
- const main = document.createElement('div')
477
- main.className = 'oe-carousel-block__actions-view'
478
-
479
- const settings = document.createElement('div')
480
- settings.className = 'oe-carousel-block__dropdown'
481
- const settingsButton = makeActionBtn('oe-carousel-block__action-btn', `${ICON_SETTINGS} ${this._t('settings', 'Settings')}`, () => {
482
- const open = !settings.classList.contains('oe-carousel-block__dropdown--open')
483
- settings.classList.toggle('oe-carousel-block__dropdown--open', open)
484
- settingsButton.setAttribute('aria-expanded', String(open))
485
- }, signal)
486
- settingsButton.setAttribute('aria-haspopup', 'true')
487
- settingsButton.setAttribute('aria-expanded', 'false')
488
- const panel = this.#settingsPanel(wrapper, state, signal)
489
- settings.append(settingsButton, panel)
490
- document.addEventListener('click', event => {
491
- const target = event.target
492
- if (!(target instanceof Node) || !settings.contains(target)) {
493
- settings.classList.remove('oe-carousel-block__dropdown--open')
494
- settingsButton.setAttribute('aria-expanded', 'false')
495
- }
496
- }, { signal })
497
- settings.addEventListener('keydown', event => {
498
- if (event.key !== 'Escape') return
499
- event.stopPropagation()
500
- settings.classList.remove('oe-carousel-block__dropdown--open')
501
- settingsButton.setAttribute('aria-expanded', 'false')
502
- settingsButton.focus()
503
- }, { signal })
504
- main.append(settings, makeSep('oe-carousel-block__actions-sep'))
505
-
506
- main.appendChild(makeActionBtn(
507
- 'oe-carousel-block__action-btn',
508
- `${ICON_REPLACE} ${this._t('add', 'Add')} ${ICON_CHEVRON}`,
509
- () => this.#showAddView(actions, main, wrapper, state, signal),
510
- signal,
511
- ))
512
- main.appendChild(makeSep('oe-carousel-block__actions-sep'))
513
- const deleteAll = makeActionBtn('oe-carousel-block__action-btn oe-carousel-block__action-btn--danger', ICON_TRASH, () => {
514
- state.context.mutate(() => {
515
- state.data.slides = []
516
- state.activeIndex = 0
517
- this.#build(wrapper, state)
518
- })
519
- }, signal)
520
- deleteAll.setAttribute('aria-label', this._t('deleteAll', 'Remove all slides'))
521
- main.appendChild(deleteAll)
522
- actions.appendChild(main)
523
- return actions
524
- }
525
-
526
- /**
527
- * Replace the primary action bar with controls for adding more slides.
528
- * @param {HTMLElement} actions Action bar container.
529
- * @param {HTMLElement} main Primary action bar view to hide temporarily.
530
- * @param {HTMLElement} wrapper Root element owned by this block render.
531
- * @param {CarouselState} state Mutable state associated with `wrapper`.
532
- * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
533
- * @returns {void}
534
- */
535
- #showAddView(actions, main, wrapper, state, signal) {
536
- main.hidden = true
537
- const view = document.createElement('div')
538
- view.className = 'oe-carousel-block__actions-view'
539
- const restore = () => { view.remove(); main.hidden = false }
540
- view.appendChild(makeActionBtn('oe-carousel-block__action-btn', `${ICON_BACK} ${this._t('back', 'Back')}`, restore, signal))
541
- view.appendChild(makeSep('oe-carousel-block__actions-sep'))
542
- view.appendChild(makeActionBtn('oe-carousel-block__action-btn', `${ICON_UPLOAD} ${this._t('upload', 'Upload')}`, () => {
543
- this.#triggerFileInput(wrapper); restore()
544
- }, signal))
545
- for (const action of this._config.actions || []) {
546
- view.appendChild(makeActionBtn('oe-carousel-block__action-btn', `${action.icon || ''} ${action.label}`, () => {
547
- void this.#runAction(wrapper, state, action); restore()
548
- }, signal))
549
- }
550
- view.appendChild(makeActionBtn('oe-carousel-block__action-btn', `${ICON_URL} URL`, () => { this.#addUrl(wrapper, state); restore() }, signal))
551
- view.appendChild(makeActionBtn('oe-carousel-block__action-btn', `${ICON_CODE} HTML`, () => { this.#addHtml(wrapper, state); restore() }, signal))
552
- actions.appendChild(view)
553
- }
554
-
555
- /**
556
- * Build controls for the active slide and global carousel behavior.
557
- * @param {HTMLElement} wrapper Root element owned by this block render.
558
- * @param {CarouselState} state Mutable state associated with `wrapper`.
559
- * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
560
- * @returns {HTMLElement} Settings panel for the current carousel state.
561
- */
562
- #settingsPanel(wrapper, state, signal) {
563
- const slide = state.data.slides[state.activeIndex]
564
- const panel = document.createElement('div')
565
- panel.className = 'oe-carousel-block__dropdown-panel oe-carousel-block__settings'
566
- panel.setAttribute('role', 'group')
567
- panel.addEventListener('click', event => event.stopPropagation(), { signal })
568
-
569
- panel.appendChild(this.#sectionTitle(this._t('currentSlide', 'Current slide')))
570
- if (slide.type === 'html') {
571
- panel.appendChild(this.#field(this._t('html', 'HTML'), slide.html || '', true, value => {
572
- const html = sanitizeRawHtml(value)
573
- if (html.trim()) slide.html = html
574
- }, wrapper, state, signal, 'text', true))
575
- } else {
576
- panel.appendChild(this.#field(this._t('source', 'Source URL'), slide.src || '', false, value => {
577
- const src = sanitizeUrl(value, { policy: 'media', fallback: '' })
578
- if (src) slide.src = src
579
- }, wrapper, state, signal, 'text', true))
580
- panel.appendChild(this.#field(this._t('alt', 'Alternative text'), slide.alt || '', false, value => { slide.alt = value }, wrapper, state, signal))
581
- if (slide.type === 'video') {
582
- panel.appendChild(this.#field(this._t('poster', 'Poster URL'), slide.poster || '', false, value => {
583
- slide.poster = sanitizeUrl(value, { policy: 'media', fallback: '' })
584
- }, wrapper, state, signal))
585
- }
586
- }
587
- panel.appendChild(this.#field(this._t('caption', 'Caption'), slide.caption || '', false, value => { slide.caption = value }, wrapper, state, signal))
588
-
589
- const order = document.createElement('div')
590
- order.className = 'oe-carousel-block__slide-actions'
591
- const backward = makeActionBtn('oe-carousel-block__settings-button', `${ICON_PREVIOUS} ${this._t('movePreviousShort', 'Earlier')}`, () => this.#move(wrapper, state, state.activeIndex, state.activeIndex - 1), signal)
592
- backward.setAttribute('aria-label', this._t('movePrevious', 'Move slide backward'))
593
- backward.disabled = state.activeIndex === 0
594
- const forward = makeActionBtn('oe-carousel-block__settings-button', `${this._t('moveNextShort', 'Later')} ${ICON_NEXT}`, () => this.#move(wrapper, state, state.activeIndex, state.activeIndex + 1), signal)
595
- forward.setAttribute('aria-label', this._t('moveNext', 'Move slide forward'))
596
- forward.disabled = state.activeIndex === state.data.slides.length - 1
597
- const remove = makeActionBtn('oe-carousel-block__settings-button oe-carousel-block__settings-button--danger', `${ICON_TRASH} ${this._t('removeSlide', 'Remove slide')}`, () => this.#removeSlide(wrapper, state, state.activeIndex), signal)
598
- order.append(backward, forward, remove)
599
- panel.appendChild(order)
600
-
601
- panel.appendChild(this.#sectionTitle(this._t('behavior', 'Behavior')))
602
- const switches = document.createElement('div')
603
- switches.className = 'oe-carousel-block__switches'
604
- for (const key of ['loop', 'autoplay', 'navigation', 'pagination', 'thumbnails']) {
605
- switches.appendChild(this.#checkbox(this._t(key, key), !!state.data.options[key], checked => {
606
- state.data.options[key] = checked
607
- }, wrapper, state, signal))
608
- }
609
- panel.appendChild(switches)
610
- panel.appendChild(this.#field(this._t('autoplayDelay', 'Autoplay delay, ms'), String(state.data.options.autoplayDelay), false, value => {
611
- const delay = Number(value)
612
- if (Number.isFinite(delay) && delay > 0) state.data.options.autoplayDelay = Math.floor(delay)
613
- }, wrapper, state, signal, 'number'))
614
- panel.appendChild(this.#field(this._t('aspectRatio', 'Aspect ratio'), state.data.options.aspectRatio || '', false, value => {
615
- const normalized = normalizeCarouselAspectRatio(value)
616
- if (normalized) state.data.options.aspectRatio = normalized
617
- else delete state.data.options.aspectRatio
618
- }, wrapper, state, signal))
619
- return panel
620
- }
621
-
622
- /**
623
- * Create a heading inside the settings panel.
624
- * @param {string} text Localized heading text.
625
- * @returns {HTMLElement} Settings section heading.
626
- */
627
- #sectionTitle(text) {
628
- const title = document.createElement('div')
629
- title.className = 'oe-carousel-block__settings-title'
630
- title.textContent = text
631
- return title
632
- }
633
-
634
- /**
635
- * Create a settings field that commits one mutation when its value changes.
636
- * @param {string} label Localized field label.
637
- * @param {string} value Initial field value.
638
- * @param {boolean} multiline Whether to create a textarea instead of an input.
639
- * @param {(value: string) => void} update State update performed inside the mutation.
640
- * @param {HTMLElement} wrapper Root element owned by this block render.
641
- * @param {CarouselState} state Mutable state associated with `wrapper`.
642
- * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
643
- * @param {string} [type] HTML input type used for a single-line field.
644
- * @param {boolean} [fullWidth] Whether the field spans the settings panel width.
645
- * @returns {HTMLElement} Label containing the configured input control.
646
- */
647
- #field(label, value, multiline, update, wrapper, state, signal, type = 'text', fullWidth = false) {
648
- const row = document.createElement('label')
649
- row.className = 'oe-carousel-block__field'
650
- row.classList.toggle('oe-carousel-block__field--full', fullWidth)
651
- const title = document.createElement('span')
652
- title.textContent = label
653
- const input = multiline
654
- ? document.createElement('textarea')
655
- : document.createElement('input')
656
- if (input instanceof HTMLInputElement) input.type = type
657
- input.value = value
658
- input.addEventListener('change', () => state.context.mutate(() => {
659
- update(input.value)
660
- this.#build(wrapper, state)
661
- }), { signal })
662
- row.append(title, input)
663
- return row
664
- }
665
-
666
- /**
667
- * Create a boolean settings control that commits one mutation per change.
668
- * @param {string} label Localized control label.
669
- * @param {boolean} checked Initial checked state.
670
- * @param {(checked: boolean) => void} update State update performed inside the mutation.
671
- * @param {HTMLElement} wrapper Root element owned by this block render.
672
- * @param {CarouselState} state Mutable state associated with `wrapper`.
673
- * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
674
- * @returns {HTMLLabelElement} Label containing the checkbox.
675
- */
676
- #checkbox(label, checked, update, wrapper, state, signal) {
677
- const row = document.createElement('label')
678
- row.className = 'oe-carousel-block__switch'
679
- const input = document.createElement('input')
680
- input.type = 'checkbox'
681
- input.checked = checked
682
- input.addEventListener('change', () => state.context.mutate(() => {
683
- update(input.checked)
684
- this.#build(wrapper, state)
685
- }), { signal })
686
- row.append(input, document.createTextNode(label))
687
- return row
688
- }
689
-
690
- /**
691
- * Move one slide and keep it active as a single history operation.
692
- * @param {HTMLElement} wrapper Root element owned by this block render.
693
- * @param {CarouselState} state Mutable state associated with `wrapper`.
694
- * @param {number} from Current zero-based slide index.
695
- * @param {number} to Requested zero-based destination index.
696
- * @returns {void}
697
- */
698
- #move(wrapper, state, from, to) {
699
- if (from < 0 || from >= state.data.slides.length || to < 0 || to >= state.data.slides.length || from === to) return
700
- state.context.mutate(() => {
701
- const [slide] = state.data.slides.splice(from, 1)
702
- state.data.slides.splice(to, 0, slide)
703
- state.activeIndex = to
704
- this.#build(wrapper, state)
705
- })
706
- }
707
-
708
- /**
709
- * Remove one slide as a single history operation.
710
- * @param {HTMLElement} wrapper Root element owned by this block render.
711
- * @param {CarouselState} state Mutable state associated with `wrapper`.
712
- * @param {number} index Zero-based index of the slide to remove.
713
- * @returns {void}
714
- */
715
- #removeSlide(wrapper, state, index) {
716
- if (index < 0 || index >= state.data.slides.length) return
717
- state.context.mutate(() => {
718
- state.data.slides.splice(index, 1)
719
- state.activeIndex = Math.max(0, Math.min(index, state.data.slides.length - 1))
720
- this.#build(wrapper, state)
721
- })
722
- }
723
-
724
- /**
725
- * Open the temporary local-media picker for this rendered block.
726
- * @param {HTMLElement} wrapper Root element owned by this block render.
727
- * @returns {void}
728
- */
729
- #triggerFileInput(wrapper) {
730
- const state = this.#states.get(wrapper)
731
- if (!state || state.context.readOnly) return
732
- triggerFileInput({
733
- accept: 'image/*,video/*',
734
- multiple: true,
735
- signal: state.lifecycleController.signal,
736
- onFiles: files => { void this.#addFiles(wrapper, files) },
737
- })
738
- }
739
-
740
- /**
741
- * Prompt for an image or video URL and append a slide when it is safe.
742
- * @param {HTMLElement} wrapper Root element owned by this block render.
743
- * @param {CarouselState} state Mutable state associated with `wrapper`.
744
- * @returns {void}
745
- */
746
- #addUrl(wrapper, state) {
747
- if (state.context.readOnly) return
748
- const source = prompt(this._t('sourcePrompt', 'Media URL'))
749
- if (!source) return
750
- const src = sanitizeUrl(source, { policy: 'media', fallback: '' })
751
- if (!src) return
752
- const type = getMediaUrlType(src)
753
- state.context.mutate(() => {
754
- state.data.slides.push({ id: this.#createId(), type, src, alt: '', caption: '' })
755
- state.activeIndex = state.data.slides.length - 1
756
- this.#build(wrapper, state)
757
- })
758
- }
759
-
760
- /**
761
- * Prompt for sanitized HTML and append it as a slide.
762
- * @param {HTMLElement} wrapper Root element owned by this block render.
763
- * @param {CarouselState} state Mutable state associated with `wrapper`.
764
- * @returns {void}
765
- */
766
- #addHtml(wrapper, state) {
767
- if (state.context.readOnly) return
768
- const html = prompt(this._t('htmlPrompt', 'Slide HTML'))
769
- if (!html) return
770
- const safe = sanitizeRawHtml(html)
771
- if (!safe.trim()) return
772
- state.context.mutate(() => {
773
- state.data.slides.push({ id: this.#createId(), type: 'html', html: safe, caption: '' })
774
- state.activeIndex = state.data.slides.length - 1
775
- this.#build(wrapper, state)
776
- })
777
- }
778
-
779
- /**
780
- * Convert supported local files to slides and commit them atomically.
781
- * @param {HTMLElement} wrapper Root element owned by this block render.
782
- * @param {File[]} files Files selected by the user, pasted, or dropped.
783
- * @returns {Promise<void>} Promise settled after all supported files are processed.
784
- */
785
- async #addFiles(wrapper, files) {
786
- const state = this.#states.get(wrapper)
787
- if (!state || state.context.readOnly || state.lifecycleController.signal.aborted) return
788
- const signal = state.lifecycleController.signal
789
- const supportedFiles = files.filter(isSupportedMediaFile)
790
- if (!supportedFiles.length) return
791
- const slides = await Promise.all(supportedFiles.map(async file => {
792
- try {
793
- const type = getMediaFileType(file)
794
- if (!type) return null
795
- let src = ''
796
- let poster = ''
797
- if (this._config.uploadFile) {
798
- const result = await this._config.uploadFile(file, { signal })
799
- src = sanitizeUrl(result?.url || '', { policy: 'media', fallback: '' })
800
- poster = sanitizeUrl(result?.poster || '', { policy: 'media', fallback: '' })
801
- } else if (type === 'image') {
802
- src = sanitizeUrl(await readFileDataUrl(file, signal), { policy: 'media', fallback: '' })
803
- } else {
804
- src = URL.createObjectURL(file)
805
- this.#objectUrls.add(src)
806
- }
807
- if (!src) return null
808
- return {
809
- id: this.#createId(), type, src, alt: file.name, caption: '',
810
- ...(type === 'video' && poster ? { poster } : {}),
811
- }
812
- } catch (error) {
813
- if (!signal.aborted) console.warn(`[CarouselBlock] Failed to add "${file.name}":`, error)
814
- return null
815
- }
816
- }))
817
- if (signal.aborted || this.#states.get(wrapper) !== state) return
818
- const valid = /** @type {CarouselSlide[]} */ (slides.filter(slide => slide !== null))
819
- if (!valid.length) return
820
- state.context.mutate(() => {
821
- state.data.slides.push(...valid)
822
- state.activeIndex = state.data.slides.length - valid.length
823
- this.#build(wrapper, state)
824
- })
825
- }
826
-
827
- /**
828
- * Run an application-provided slide source and commit valid results atomically.
829
- * @param {HTMLElement} wrapper Root element owned by this block render.
830
- * @param {CarouselState} state Mutable state associated with `wrapper`.
831
- * @param {CarouselAction} action Application source to execute.
832
- * @returns {Promise<void>} Promise settled after the source completes or is cancelled.
833
- */
834
- async #runAction(wrapper, state, action) {
835
- try {
836
- const signal = state.lifecycleController.signal
837
- const slides = await action.handler({ signal })
838
- if (!slides || signal.aborted || this.#states.get(wrapper) !== state) return
839
- const normalized = normalizeCarouselData({ slides, options: state.data.options }, () => this.#createId()).slides
840
- .filter(slide => slide.type === 'html' ? !!slide.html?.trim() : !!slide.src)
841
- if (!normalized.length) return
842
- const usedIds = new Set(state.data.slides.map(slide => slide.id))
843
- for (const slide of normalized) {
844
- while (usedIds.has(slide.id)) slide.id = this.#createId()
845
- usedIds.add(slide.id)
846
- }
847
- state.context.mutate(() => {
848
- state.data.slides.push(...normalized)
849
- state.activeIndex = state.data.slides.length - normalized.length
850
- this.#build(wrapper, state)
851
- })
852
- } catch (error) {
853
- if (!state.lifecycleController.signal.aborted) console.warn('[CarouselBlock] Action failed:', error)
854
- }
855
- }
856
- }
857
-
858
- export { CarouselBlock as Carousel }
1
+ import { BlockPluginAbstract } from '../BlockPluginAbstract.js'
2
+ import { uid } from '../../core/uid.js'
3
+ import {
4
+ normalizeCarouselAspectRatio,
5
+ normalizeCarouselData,
6
+ validateCarouselData,
7
+ } from '../../shared/carouselData.js'
8
+ import {
9
+ sanitizeRawHtml,
10
+ sanitizeUrl,
11
+ setSafeUrlAttribute,
12
+ setSanitizedRawHtml,
13
+ } from '../../shared/sanitize/index.js'
14
+ import { makeActionBtn, makeSep } from '../shared/actionBar.js'
15
+ import { renderDropzone } from '../shared/dropzone.js'
16
+ import { triggerFileInput } from '../shared/fileInput.js'
17
+ import { createPluginLayer } from '../shared/layer.js'
18
+ import { openSourceEditor, preloadSourceEditor } from '../shared/sourceEditor.js'
19
+ import { CarouselState } from './state.js'
20
+ import { READ_ONLY_INTERACTIVE_ATTRIBUTE } from '../../core/constants.js'
21
+ import {
22
+ ICON, ICON_BACK, ICON_CHEVRON, ICON_CODE, ICON_NEXT, ICON_PREVIOUS,
23
+ ICON_REPLACE, ICON_SELECT, ICON_SETTINGS, ICON_TRASH, ICON_UPLOAD, ICON_URL,
24
+ } from './icons.js'
25
+
26
+ const editorStyles = new URL('./carousel.css', import.meta.url).href
27
+ const sourceEditorStyles = new URL('../shared/sourceEditor.css', import.meta.url).href
28
+
29
+ /**
30
+ * Read a local image while respecting the owning block's lifecycle.
31
+ * @param {File} file File to encode.
32
+ * @param {AbortSignal} signal Lifecycle signal owned by the rendered block.
33
+ * @returns {Promise<string>} A data URL for the file.
34
+ */
35
+ function readFileDataUrl(file, signal) {
36
+ if (signal.aborted) {
37
+ return Promise.reject(signal.reason || new DOMException('File read aborted', 'AbortError'))
38
+ }
39
+ return new Promise((resolve, reject) => {
40
+ const reader = new FileReader()
41
+ let settled = false
42
+ /**
43
+ * Run a completion callback exactly once and detach the abort listener.
44
+ * @param {() => void} callback Completion callback to invoke.
45
+ * @returns {void}
46
+ */
47
+ const finish = (callback) => {
48
+ if (settled) return
49
+ settled = true
50
+ signal.removeEventListener('abort', onSignalAbort)
51
+ callback()
52
+ }
53
+ const rejectAbort = () => reject(signal.reason || new DOMException('File read aborted', 'AbortError'))
54
+ const onSignalAbort = () => {
55
+ if (reader.readyState === FileReader.LOADING) reader.abort()
56
+ finish(rejectAbort)
57
+ }
58
+ signal.addEventListener('abort', onSignalAbort, { once: true })
59
+ reader.onload = () => finish(() => resolve(typeof reader.result === 'string' ? reader.result : ''))
60
+ reader.onerror = () => finish(() => reject(reader.error || new Error('Failed to read file')))
61
+ reader.onabort = () => finish(rejectAbort)
62
+ try {
63
+ reader.readAsDataURL(file)
64
+ } catch (error) {
65
+ finish(() => reject(error))
66
+ }
67
+ })
68
+ }
69
+
70
+ /**
71
+ * Determine the slide type represented by a local file. Some file pickers omit
72
+ * the MIME type, so common image and video extensions are used as a narrow
73
+ * fallback only when no MIME type is available.
74
+ * @param {File} file File selected by the user or supplied by a drop operation.
75
+ * @returns {'image' | 'video' | null} Supported slide type, or `null`.
76
+ */
77
+ function getMediaFileType(file) {
78
+ const type = file.type.toLowerCase()
79
+ if (type.startsWith('image/')) return 'image'
80
+ if (type.startsWith('video/')) return 'video'
81
+ if (type) return null
82
+ if (/\.(?:avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name)) return 'image'
83
+ if (/\.(?:mp4|m4v|mov|ogv|webm)$/i.test(file.name)) return 'video'
84
+ return null
85
+ }
86
+
87
+ /**
88
+ * Check whether a local file can be represented by a carousel slide.
89
+ * @param {File} file File selected by the user or supplied by a drop operation.
90
+ * @returns {boolean} Whether the file is a supported image or video.
91
+ */
92
+ function isSupportedMediaFile(file) {
93
+ return getMediaFileType(file) !== null
94
+ }
95
+
96
+ /**
97
+ * Infer the media type represented by a sanitized URL. URLs without a known
98
+ * video extension remain images because browsers can still decode extensionless
99
+ * image endpoints, while treating them as videos would remove image semantics.
100
+ * @param {string} url Sanitized media URL.
101
+ * @returns {'image' | 'video'} Slide type inferred from the URL path.
102
+ */
103
+ function getMediaUrlType(url) {
104
+ return /\.(?:m4v|mov|mp4|ogg|ogv|webm)(?:[?#]|$)/i.test(url) ? 'video' : 'image'
105
+ }
106
+
107
+ /**
108
+ * @typedef {import('../../shared/carouselData').CarouselData} CarouselData
109
+ * @typedef {import('../../shared/carouselData').CarouselSlide} CarouselSlide
110
+ * @typedef {Object} CarouselAction
111
+ * @property {string} label User-visible label for the application-owned source.
112
+ * @property {string} [icon] Trusted application-provided SVG or HTML markup.
113
+ * @property {(context: { signal: AbortSignal }) => Promise<CarouselSlide[] | null>} handler Loads slides and observes the supplied lifecycle signal.
114
+ * @typedef {Object} CarouselConfig
115
+ * @property {(file: File, context: { signal: AbortSignal }) => Promise<{ url: string, poster?: string }>} [uploadFile] Uploads an image or video selected in the browser. Without this callback images become data URLs, while videos use temporary object URLs retained until editor disposal.
116
+ * @property {CarouselAction[]} [actions] Additional application-owned slide sources such as a media library. Returned slides are normalized and unusable entries are ignored.
117
+ * @property {boolean} [injectStyles=true] Whether the editor should load the built-in carousel stylesheet.
118
+ * @property {string} [css] Additional stylesheet URL, or the replacement URL when `injectStyles` is `false`.
119
+ */
120
+
121
+ /**
122
+ * Editable mixed-media carousel with extensible file sources and display controls.
123
+ * @extends {BlockPluginAbstract<CarouselConfig>}
124
+ */
125
+ export class CarouselBlock extends BlockPluginAbstract {
126
+ static isTextBlock = false
127
+ static styles = [editorStyles, sourceEditorStyles]
128
+ type = 'carousel'
129
+ icon = ICON
130
+ inlineTools = false
131
+ pasteConfig = { files: ['image/*', 'video/*'] }
132
+
133
+ /** @type {WeakMap<HTMLElement, CarouselState>} */
134
+ #states = new WeakMap()
135
+ /** Temporary local URLs retained for undo/redo until editor disposal. @type {Set<string>} */
136
+ #objectUrls = new Set()
137
+ /**
138
+ * Return the localized toolbox label for this block.
139
+ * @returns {string}
140
+ */
141
+ get title() { return this._t('title', 'Carousel') }
142
+
143
+ /** Create a stable identifier for a new slide. @returns {string} */
144
+ #createId() { return `slide-${uid()}` }
145
+ /**
146
+ * Create the editable DOM owned by this block instance.
147
+ * @param {Record<string, unknown>} data Serialized carousel data.
148
+ * @param {import('../../core/types').BlockMutationContext} context Editor mutation and lifecycle context.
149
+ * @returns {HTMLElement} Root element owned by this block render.
150
+ */
151
+ render(data, context) {
152
+ const wrapper = document.createElement('div')
153
+ wrapper.className = 'oe-carousel-block'
154
+ wrapper.contentEditable = 'false'
155
+ wrapper.tabIndex = -1
156
+ const state = new CarouselState(data, () => this.#createId(), context)
157
+ this.#states.set(wrapper, state)
158
+ this.#build(wrapper, state)
159
+
160
+ const pending = /** @type {File | undefined} */ (/** @type {any} */ (data)?._pendingFile)
161
+ if (pending && !context.readOnly) {
162
+ state.pendingUpload = this.#addFiles(wrapper, [pending]).finally(() => {
163
+ if (this.#states.get(wrapper) === state) state.pendingUpload = null
164
+ })
165
+ }
166
+ return wrapper
167
+ }
168
+
169
+ /**
170
+ * Serialize the current block DOM into document data.
171
+ * @param {HTMLElement} element Root element returned by `render()`.
172
+ * @returns {CarouselData} Normalized serializable carousel data.
173
+ */
174
+ save(element) {
175
+ const state = this.#states.get(element)
176
+ return state ? structuredClone(state.data) : normalizeCarouselData({}, () => this.#createId())
177
+ }
178
+
179
+ /**
180
+ * Check whether serialized data satisfies this block's schema.
181
+ * @param {Record<string, unknown>} data Candidate serialized block data.
182
+ * @returns {boolean} Whether the candidate satisfies the carousel schema.
183
+ */
184
+ validate(data) { return validateCarouselData(data) }
185
+
186
+ /**
187
+ * Check whether the block has no meaningful user content.
188
+ * @param {HTMLElement} element Root element returned by `render()`.
189
+ * @returns {boolean} Whether the carousel contains no slides.
190
+ */
191
+ isEmpty(element) { return (this.#states.get(element)?.data.slides.length || 0) === 0 }
192
+
193
+ /**
194
+ * Extract neutral text that can initialize another block type.
195
+ * @param {HTMLElement} element Root element returned by `render()`.
196
+ * @returns {{ text: string }} Text assembled from slide captions and alternatives.
197
+ */
198
+ exportData(element) {
199
+ const state = this.#states.get(element)
200
+ return { text: state?.data.slides.map(slide => slide.caption || slide.alt || '').filter(Boolean).join(', ') || '' }
201
+ }
202
+
203
+ /**
204
+ * Report whether paste handling completes asynchronously.
205
+ * @param {HTMLElement} element Root element returned by `render()`.
206
+ * @returns {Promise<void>} Promise settled after a pending pasted file is handled.
207
+ */
208
+ waitForPaste(element) { return this.#states.get(element)?.pendingUpload ?? Promise.resolve() }
209
+
210
+ /**
211
+ * Release listeners and resources owned by this block element.
212
+ * @param {HTMLElement} element Root element returned by `render()`.
213
+ * @returns {void}
214
+ */
215
+ destroy(element) {
216
+ const state = this.#states.get(element)
217
+ if (!state) return
218
+ state.dispose()
219
+ this.#states.delete(element)
220
+ }
221
+
222
+ /**
223
+ * Release temporary local video URLs after the owning editor has discarded
224
+ * its blocks and history snapshots.
225
+ * @returns {void}
226
+ */
227
+ dispose() {
228
+ for (const url of this.#objectUrls) URL.revokeObjectURL(url)
229
+ this.#objectUrls.clear()
230
+ }
231
+
232
+ /**
233
+ * Handle supported pasted content for this block.
234
+ * @param {import('../../types').PasteEvent} event Normalized editor paste event.
235
+ * @returns {{ _pendingFile: File } | null} Deferred file marker, or `null` for unsupported content.
236
+ */
237
+ onPaste(event) {
238
+ if (event.type === 'file' && event.file) return { _pendingFile: event.file }
239
+ return null
240
+ }
241
+
242
+ /**
243
+ * Rebuild the current carousel view from mutable block state.
244
+ * @param {HTMLElement} wrapper Root element owned by this block render.
245
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
246
+ * @returns {void}
247
+ */
248
+ #build(wrapper, state) {
249
+ const signal = state.resetView()
250
+ wrapper.replaceChildren()
251
+ wrapper.classList.toggle('oe-carousel-block--filled', state.data.slides.length > 0)
252
+ if (state.data.slides.length === 0) {
253
+ this.#renderEmpty(wrapper, state, signal)
254
+ return
255
+ }
256
+ wrapper.appendChild(this.#stage(wrapper, state, signal))
257
+ if (!state.context.readOnly) wrapper.appendChild(this.#actions(wrapper, state, signal))
258
+ if (!state.context.readOnly) preloadSourceEditor(wrapper, state.lifecycleController.signal, ['url', 'html'])
259
+ }
260
+
261
+ /**
262
+ * Render the empty-state file and application-source chooser.
263
+ * @param {HTMLElement} wrapper Root element owned by this block render.
264
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
265
+ * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
266
+ * @returns {void}
267
+ */
268
+ #renderEmpty(wrapper, state, signal) {
269
+ if (state.context.readOnly) {
270
+ const empty = document.createElement('div')
271
+ empty.className = 'oe-carousel-block__empty'
272
+ empty.textContent = this._t('emptyReadonly', 'No slides')
273
+ wrapper.appendChild(empty)
274
+ return
275
+ }
276
+ renderDropzone(wrapper, signal, {
277
+ select: 'oe-carousel-block__select',
278
+ selectIcon: 'oe-carousel-block__select-icon',
279
+ selectText: 'oe-carousel-block__select-text',
280
+ selectLink: 'oe-carousel-block__select-link',
281
+ selectActions: 'oe-carousel-block__select-actions',
282
+ selectAction: 'oe-carousel-block__select-action',
283
+ dropzoneActive: 'oe-carousel-block__select--dragover',
284
+ filled: 'oe-carousel-block--filled',
285
+ }, {
286
+ iconHtml: ICON_SELECT,
287
+ uploadText: this._t('upload', 'Upload'),
288
+ afterText: this._t('dropzoneText', 'images or videos from your device or drag and drop them here'),
289
+ onUploadClick: () => this.#triggerFileInput(wrapper),
290
+ onDrop: dataTransfer => { if (dataTransfer.files.length) void this.#addFiles(wrapper, [...dataTransfer.files]) },
291
+ inlineActions: [
292
+ {
293
+ prefix: this._t('dropzoneUrlPrefix', 'or'),
294
+ label: this._t('dropzoneUrl', 'insert a URL'),
295
+ onSelect: () => this.#addUrl(wrapper, state),
296
+ },
297
+ {
298
+ prefix: this._t('dropzoneHtmlPrefix', 'or'),
299
+ label: this._t('dropzoneHtml', 'insert HTML'),
300
+ onSelect: () => this.#addHtml(wrapper, state),
301
+ },
302
+ ],
303
+ actions: this.#sourceActions(wrapper, state),
304
+ })
305
+ preloadSourceEditor(wrapper, state.lifecycleController.signal, ['url', 'html'])
306
+ }
307
+
308
+ /**
309
+ * Adapt application-provided slide sources to dropzone actions.
310
+ * @param {HTMLElement} wrapper Root element owned by this block render.
311
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
312
+ * @returns {Array<{icon?: string,label: string,onSelect: () => void}>} Selectable source actions.
313
+ */
314
+ #sourceActions(wrapper, state) {
315
+ return (this._config.actions || []).map(action => ({
316
+ icon: action.icon,
317
+ label: action.label,
318
+ onSelect: () => { void this.#runAction(wrapper, state, action) },
319
+ }))
320
+ }
321
+
322
+ /**
323
+ * Build the active media stage and optional navigation controls.
324
+ * @param {HTMLElement} wrapper Root element owned by this block render.
325
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
326
+ * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
327
+ * @returns {HTMLElement} Carousel stage for the active slide.
328
+ */
329
+ #stage(wrapper, state, signal) {
330
+ const slide = state.data.slides[state.activeIndex]
331
+ const stage = document.createElement('div')
332
+ stage.className = 'oe-carousel-block__stage'
333
+ if (state.data.options.aspectRatio && state.data.options.aspectRatio !== 'auto') {
334
+ stage.classList.add('oe-carousel-block__stage--fixed-ratio')
335
+ stage.style.aspectRatio = state.data.options.aspectRatio
336
+ }
337
+
338
+ const media = document.createElement('div')
339
+ media.className = 'oe-carousel-block__media'
340
+ if (slide.type === 'image') {
341
+ const image = document.createElement('img')
342
+ setSafeUrlAttribute(image, 'src', slide.src || '', 'media')
343
+ image.alt = slide.alt || ''
344
+ media.appendChild(image)
345
+ } else if (slide.type === 'video') {
346
+ const video = document.createElement('video')
347
+ video.controls = true
348
+ video.preload = 'metadata'
349
+ setSafeUrlAttribute(video, 'src', slide.src || '', 'media')
350
+ setSafeUrlAttribute(video, 'poster', slide.poster || '', 'media')
351
+ video.setAttribute('aria-label', slide.alt || this._t('video', 'Video slide'))
352
+ media.appendChild(video)
353
+ } else {
354
+ const html = document.createElement('div')
355
+ html.className = 'oe-carousel-block__html'
356
+ setSanitizedRawHtml(html, slide.html || '')
357
+ media.appendChild(html)
358
+ }
359
+ stage.appendChild(media)
360
+
361
+ if (state.data.slides.length > 1 && state.data.options.navigation) {
362
+ const previous = this.#navButton(this._t('previous', 'Previous slide'), ICON_PREVIOUS, 'previous', () => this.#activate(wrapper, state, state.activeIndex - 1), signal)
363
+ const next = this.#navButton(this._t('next', 'Next slide'), ICON_NEXT, 'next', () => this.#activate(wrapper, state, state.activeIndex + 1), signal)
364
+ if (!state.data.options.loop) {
365
+ previous.disabled = state.activeIndex === 0
366
+ next.disabled = state.activeIndex === state.data.slides.length - 1
367
+ }
368
+ stage.append(previous, next)
369
+ }
370
+
371
+ const counter = document.createElement('span')
372
+ counter.className = 'oe-carousel-block__counter'
373
+ counter.setAttribute('aria-live', 'polite')
374
+ counter.textContent = `${state.activeIndex + 1} / ${state.data.slides.length}`
375
+ stage.appendChild(counter)
376
+
377
+ const caption = document.createElement('div')
378
+ caption.className = 'oe-carousel-block__caption'
379
+ caption.contentEditable = state.context.readOnly ? 'false' : 'true'
380
+ caption.dataset.placeholder = this._t('caption', 'Caption')
381
+ caption.textContent = slide.caption || ''
382
+ if (!state.context.readOnly) {
383
+ caption.addEventListener('input', () => { slide.caption = caption.textContent?.trim() || '' }, { signal })
384
+ caption.addEventListener('keydown', event => {
385
+ if (event.key === 'Backspace' && !caption.textContent?.trim()) {
386
+ event.preventDefault()
387
+ event.stopPropagation()
388
+ }
389
+ }, { signal })
390
+ }
391
+ stage.appendChild(caption)
392
+
393
+ if (state.data.slides.length > 1 && state.data.options.pagination) {
394
+ const dots = document.createElement('div')
395
+ dots.className = 'oe-carousel-block__dots'
396
+ state.data.slides.forEach((_, index) => {
397
+ const dot = document.createElement('button')
398
+ dot.type = 'button'
399
+ dot.setAttribute(READ_ONLY_INTERACTIVE_ATTRIBUTE, '')
400
+ dot.className = 'oe-carousel-block__dot'
401
+ dot.classList.toggle('oe-carousel-block__dot--active', index === state.activeIndex)
402
+ dot.setAttribute('aria-label', this._t('goToSlide', 'Go to slide {index}').replace('{index}', String(index + 1)))
403
+ dot.setAttribute('aria-current', index === state.activeIndex ? 'true' : 'false')
404
+ dot.addEventListener('click', () => this.#activate(wrapper, state, index), { signal })
405
+ dots.appendChild(dot)
406
+ })
407
+ stage.appendChild(dots)
408
+ }
409
+
410
+ if (state.data.options.thumbnails && state.data.slides.length > 1) {
411
+ const thumbnails = document.createElement('div')
412
+ thumbnails.className = 'oe-carousel-block__thumbnails'
413
+ state.data.slides.forEach((item, index) => thumbnails.appendChild(this.#thumbnail(wrapper, state, item, index, signal)))
414
+ stage.appendChild(thumbnails)
415
+ }
416
+ return stage
417
+ }
418
+
419
+ /**
420
+ * Create one previous or next navigation button.
421
+ * @param {string} label Accessible button label.
422
+ * @param {string} icon Trusted internal SVG markup.
423
+ * @param {string} direction Direction modifier used by the stylesheet.
424
+ * @param {() => void} activate Callback that activates the adjacent slide.
425
+ * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
426
+ * @returns {HTMLButtonElement} Configured navigation button.
427
+ */
428
+ #navButton(label, icon, direction, activate, signal) {
429
+ const button = document.createElement('button')
430
+ button.type = 'button'
431
+ button.setAttribute(READ_ONLY_INTERACTIVE_ATTRIBUTE, '')
432
+ button.className = `oe-carousel-block__nav oe-carousel-block__nav--${direction}`
433
+ button.innerHTML = icon
434
+ button.setAttribute('aria-label', label)
435
+ button.addEventListener('click', activate, { signal })
436
+ return button
437
+ }
438
+
439
+ /**
440
+ * Create one thumbnail navigation control.
441
+ * @param {HTMLElement} wrapper Root element owned by this block render.
442
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
443
+ * @param {CarouselSlide} slide Slide represented by the thumbnail.
444
+ * @param {number} index Zero-based slide index.
445
+ * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
446
+ * @returns {HTMLButtonElement} Configured thumbnail button.
447
+ */
448
+ #thumbnail(wrapper, state, slide, index, signal) {
449
+ const button = document.createElement('button')
450
+ button.type = 'button'
451
+ button.setAttribute(READ_ONLY_INTERACTIVE_ATTRIBUTE, '')
452
+ button.className = 'oe-carousel-block__thumbnail'
453
+ button.classList.toggle('oe-carousel-block__thumbnail--active', index === state.activeIndex)
454
+ button.setAttribute('aria-label', this._t('goToSlide', 'Go to slide {index}').replace('{index}', String(index + 1)))
455
+ button.setAttribute('aria-current', index === state.activeIndex ? 'true' : 'false')
456
+ if (slide.type === 'image' && slide.src) {
457
+ const image = document.createElement('img')
458
+ setSafeUrlAttribute(image, 'src', slide.src, 'media')
459
+ image.alt = ''
460
+ button.appendChild(image)
461
+ } else {
462
+ button.textContent = String(index + 1)
463
+ }
464
+ button.addEventListener('click', () => this.#activate(wrapper, state, index), { signal })
465
+ return button
466
+ }
467
+
468
+ /**
469
+ * Activate a slide, applying loop or boundary behavior from current options.
470
+ * @param {HTMLElement} wrapper Root element owned by this block render.
471
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
472
+ * @param {number} index Requested zero-based slide index.
473
+ * @returns {void}
474
+ */
475
+ #activate(wrapper, state, index) {
476
+ const count = state.data.slides.length
477
+ if (!count) return
478
+ state.activeIndex = state.data.options.loop ? (index + count) % count : Math.max(0, Math.min(index, count - 1))
479
+ this.#build(wrapper, state)
480
+ }
481
+
482
+ /**
483
+ * Build the editing action bar shown below a populated carousel.
484
+ * @param {HTMLElement} wrapper Root element owned by this block render.
485
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
486
+ * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
487
+ * @returns {HTMLElement} Carousel action bar.
488
+ */
489
+ #actions(wrapper, state, signal) {
490
+ const actions = document.createElement('div')
491
+ actions.className = 'oe-carousel-block__actions'
492
+ const main = document.createElement('div')
493
+ main.className = 'oe-carousel-block__actions-view'
494
+
495
+ const settings = document.createElement('div')
496
+ settings.className = 'oe-carousel-block__dropdown'
497
+ const layer = createPluginLayer(wrapper, signal)
498
+ const panel = this.#settingsPanel(wrapper, state, signal)
499
+ panel.setAttribute('aria-hidden', 'true')
500
+ /** @type {HTMLButtonElement} */
501
+ let settingsButton
502
+ const setSettingsOpen = open => {
503
+ settings.classList.toggle('oe-carousel-block__dropdown--open', open)
504
+ settingsButton?.setAttribute('aria-expanded', String(open))
505
+ panel.setAttribute('aria-hidden', String(!open))
506
+ if (open) layer.open()
507
+ else layer.close()
508
+ }
509
+ settingsButton = makeActionBtn('oe-carousel-block__action-btn', `${ICON_SETTINGS} ${this._t('settings', 'Settings')}`, () => {
510
+ setSettingsOpen(!settings.classList.contains('oe-carousel-block__dropdown--open'))
511
+ }, signal)
512
+ settingsButton.setAttribute('aria-haspopup', 'true')
513
+ settingsButton.setAttribute('aria-expanded', 'false')
514
+ settings.append(settingsButton, panel)
515
+ document.addEventListener('click', event => {
516
+ const target = event.target
517
+ if (!(target instanceof Node) || !settings.contains(target)) {
518
+ setSettingsOpen(false)
519
+ }
520
+ }, { signal })
521
+ settings.addEventListener('keydown', event => {
522
+ if (event.key !== 'Escape') return
523
+ event.stopPropagation()
524
+ setSettingsOpen(false)
525
+ settingsButton.focus()
526
+ }, { signal })
527
+ main.append(settings, makeSep('oe-carousel-block__actions-sep'))
528
+
529
+ main.appendChild(makeActionBtn(
530
+ 'oe-carousel-block__action-btn',
531
+ `${ICON_REPLACE} ${this._t('add', 'Add')} ${ICON_CHEVRON}`,
532
+ () => this.#showAddView(actions, main, wrapper, state, signal),
533
+ signal,
534
+ ))
535
+ main.appendChild(makeSep('oe-carousel-block__actions-sep'))
536
+ const deleteAll = makeActionBtn('oe-carousel-block__action-btn oe-carousel-block__action-btn--danger', ICON_TRASH, () => {
537
+ state.context.mutate(() => {
538
+ state.data.slides = []
539
+ state.activeIndex = 0
540
+ this.#build(wrapper, state)
541
+ })
542
+ }, signal)
543
+ deleteAll.setAttribute('aria-label', this._t('deleteAll', 'Remove all slides'))
544
+ main.appendChild(deleteAll)
545
+ actions.appendChild(main)
546
+ return actions
547
+ }
548
+
549
+ /**
550
+ * Replace the primary action bar with controls for adding more slides.
551
+ * @param {HTMLElement} actions Action bar container.
552
+ * @param {HTMLElement} main Primary action bar view to hide temporarily.
553
+ * @param {HTMLElement} wrapper Root element owned by this block render.
554
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
555
+ * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
556
+ * @returns {void}
557
+ */
558
+ #showAddView(actions, main, wrapper, state, signal) {
559
+ main.hidden = true
560
+ const view = document.createElement('div')
561
+ view.className = 'oe-carousel-block__actions-view'
562
+ const restore = () => { view.remove(); main.hidden = false }
563
+ view.appendChild(makeActionBtn('oe-carousel-block__action-btn', `${ICON_BACK} ${this._t('back', 'Back')}`, restore, signal))
564
+ view.appendChild(makeSep('oe-carousel-block__actions-sep'))
565
+ view.appendChild(makeActionBtn('oe-carousel-block__action-btn', `${ICON_UPLOAD} ${this._t('upload', 'Upload')}`, () => {
566
+ this.#triggerFileInput(wrapper); restore()
567
+ }, signal))
568
+ for (const action of this._config.actions || []) {
569
+ view.appendChild(makeActionBtn('oe-carousel-block__action-btn', `${action.icon || ''} ${action.label}`, () => {
570
+ void this.#runAction(wrapper, state, action); restore()
571
+ }, signal))
572
+ }
573
+ view.appendChild(makeActionBtn('oe-carousel-block__action-btn', `${ICON_URL} URL`, () => {
574
+ restore()
575
+ this.#addUrl(wrapper, state)
576
+ }, signal))
577
+ view.appendChild(makeActionBtn('oe-carousel-block__action-btn', `${ICON_CODE} HTML`, () => {
578
+ restore()
579
+ this.#addHtml(wrapper, state)
580
+ }, signal))
581
+ actions.appendChild(view)
582
+ }
583
+
584
+ /**
585
+ * Build controls for the active slide and global carousel behavior.
586
+ * @param {HTMLElement} wrapper Root element owned by this block render.
587
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
588
+ * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
589
+ * @returns {HTMLElement} Settings panel for the current carousel state.
590
+ */
591
+ #settingsPanel(wrapper, state, signal) {
592
+ const slide = state.data.slides[state.activeIndex]
593
+ const panel = document.createElement('div')
594
+ panel.className = 'oe-carousel-block__dropdown-panel oe-carousel-block__settings'
595
+ panel.setAttribute('role', 'group')
596
+ panel.addEventListener('click', event => event.stopPropagation(), { signal })
597
+
598
+ panel.appendChild(this.#sectionTitle(this._t('currentSlide', 'Current slide')))
599
+ if (slide.type === 'html') {
600
+ panel.appendChild(this.#field(this._t('html', 'HTML'), slide.html || '', true, value => {
601
+ const html = sanitizeRawHtml(value)
602
+ if (html.trim()) slide.html = html
603
+ }, wrapper, state, signal, 'text', true))
604
+ } else {
605
+ const source = slide.src || ''
606
+ const embeddedSource = /^data:/i.test(source)
607
+ const sourceField = this.#field(this._t('source', 'Source URL'), embeddedSource ? '' : source, false, value => {
608
+ const src = sanitizeUrl(value, { policy: 'media', fallback: '' })
609
+ if (src) slide.src = src
610
+ }, wrapper, state, signal, 'text', true)
611
+ const sourceInput = sourceField.querySelector('input')
612
+ if (embeddedSource && sourceInput instanceof HTMLInputElement) {
613
+ sourceInput.placeholder = this._t('embeddedSource', 'Local file — paste a URL to replace it')
614
+ sourceInput.dataset.oeEmbeddedSource = 'true'
615
+ }
616
+ panel.appendChild(sourceField)
617
+ panel.appendChild(this.#field(this._t('alt', 'Alternative text'), slide.alt || '', false, value => { slide.alt = value }, wrapper, state, signal))
618
+ if (slide.type === 'video') {
619
+ panel.appendChild(this.#field(this._t('poster', 'Poster URL'), slide.poster || '', false, value => {
620
+ slide.poster = sanitizeUrl(value, { policy: 'media', fallback: '' })
621
+ }, wrapper, state, signal))
622
+ }
623
+ }
624
+ panel.appendChild(this.#field(this._t('caption', 'Caption'), slide.caption || '', false, value => { slide.caption = value }, wrapper, state, signal))
625
+
626
+ const order = document.createElement('div')
627
+ order.className = 'oe-carousel-block__slide-actions'
628
+ const backward = makeActionBtn('oe-carousel-block__settings-button', `${ICON_PREVIOUS} ${this._t('movePreviousShort', 'Earlier')}`, () => this.#move(wrapper, state, state.activeIndex, state.activeIndex - 1), signal)
629
+ backward.setAttribute('aria-label', this._t('movePrevious', 'Move slide backward'))
630
+ backward.disabled = state.activeIndex === 0
631
+ const forward = makeActionBtn('oe-carousel-block__settings-button', `${this._t('moveNextShort', 'Later')} ${ICON_NEXT}`, () => this.#move(wrapper, state, state.activeIndex, state.activeIndex + 1), signal)
632
+ forward.setAttribute('aria-label', this._t('moveNext', 'Move slide forward'))
633
+ forward.disabled = state.activeIndex === state.data.slides.length - 1
634
+ const remove = makeActionBtn('oe-carousel-block__settings-button oe-carousel-block__settings-button--danger', `${ICON_TRASH} ${this._t('removeSlide', 'Remove slide')}`, () => this.#removeSlide(wrapper, state, state.activeIndex), signal)
635
+ order.append(backward, forward, remove)
636
+ panel.appendChild(order)
637
+
638
+ panel.appendChild(this.#sectionTitle(this._t('behavior', 'Behavior')))
639
+ const switches = document.createElement('div')
640
+ switches.className = 'oe-carousel-block__switches'
641
+ for (const key of ['loop', 'autoplay', 'navigation', 'pagination', 'thumbnails']) {
642
+ switches.appendChild(this.#checkbox(this._t(key, key), !!state.data.options[key], checked => {
643
+ state.data.options[key] = checked
644
+ }, wrapper, state, signal))
645
+ }
646
+ panel.appendChild(switches)
647
+ panel.appendChild(this.#field(this._t('autoplayDelay', 'Autoplay delay, ms'), String(state.data.options.autoplayDelay), false, value => {
648
+ const delay = Number(value)
649
+ if (Number.isFinite(delay) && delay > 0) state.data.options.autoplayDelay = Math.floor(delay)
650
+ }, wrapper, state, signal, 'number'))
651
+ panel.appendChild(this.#field(this._t('aspectRatio', 'Aspect ratio'), state.data.options.aspectRatio || '', false, value => {
652
+ const normalized = normalizeCarouselAspectRatio(value)
653
+ if (normalized) state.data.options.aspectRatio = normalized
654
+ else delete state.data.options.aspectRatio
655
+ }, wrapper, state, signal))
656
+ return panel
657
+ }
658
+
659
+ /**
660
+ * Create a heading inside the settings panel.
661
+ * @param {string} text Localized heading text.
662
+ * @returns {HTMLElement} Settings section heading.
663
+ */
664
+ #sectionTitle(text) {
665
+ const title = document.createElement('div')
666
+ title.className = 'oe-carousel-block__settings-title'
667
+ title.textContent = text
668
+ return title
669
+ }
670
+
671
+ /**
672
+ * Create a settings field that commits one mutation when its value changes.
673
+ * @param {string} label Localized field label.
674
+ * @param {string} value Initial field value.
675
+ * @param {boolean} multiline Whether to create a textarea instead of an input.
676
+ * @param {(value: string) => void} update State update performed inside the mutation.
677
+ * @param {HTMLElement} wrapper Root element owned by this block render.
678
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
679
+ * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
680
+ * @param {string} [type] HTML input type used for a single-line field.
681
+ * @param {boolean} [fullWidth] Whether the field spans the settings panel width.
682
+ * @returns {HTMLElement} Label containing the configured input control.
683
+ */
684
+ #field(label, value, multiline, update, wrapper, state, signal, type = 'text', fullWidth = false) {
685
+ const row = document.createElement('label')
686
+ row.className = 'oe-carousel-block__field'
687
+ row.classList.toggle('oe-carousel-block__field--full', fullWidth)
688
+ row.classList.toggle('oe-carousel-block__field--multiline', multiline)
689
+ const title = document.createElement('span')
690
+ title.textContent = label
691
+ const input = multiline
692
+ ? document.createElement('textarea')
693
+ : document.createElement('input')
694
+ if (input instanceof HTMLInputElement) input.type = type
695
+ input.value = value
696
+ input.addEventListener('change', () => state.context.mutate(() => {
697
+ update(input.value)
698
+ this.#build(wrapper, state)
699
+ }), { signal })
700
+ row.append(title, input)
701
+ return row
702
+ }
703
+
704
+ /**
705
+ * Create a boolean settings control that commits one mutation per change.
706
+ * @param {string} label Localized control label.
707
+ * @param {boolean} checked Initial checked state.
708
+ * @param {(checked: boolean) => void} update State update performed inside the mutation.
709
+ * @param {HTMLElement} wrapper Root element owned by this block render.
710
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
711
+ * @param {AbortSignal} signal Signal invalidated when the current view is rebuilt.
712
+ * @returns {HTMLLabelElement} Label containing the checkbox.
713
+ */
714
+ #checkbox(label, checked, update, wrapper, state, signal) {
715
+ const row = document.createElement('label')
716
+ row.className = 'oe-carousel-block__switch'
717
+ const input = document.createElement('input')
718
+ input.type = 'checkbox'
719
+ input.checked = checked
720
+ input.addEventListener('change', () => state.context.mutate(() => {
721
+ update(input.checked)
722
+ this.#build(wrapper, state)
723
+ }), { signal })
724
+ row.append(input, document.createTextNode(label))
725
+ return row
726
+ }
727
+
728
+ /**
729
+ * Move one slide and keep it active as a single history operation.
730
+ * @param {HTMLElement} wrapper Root element owned by this block render.
731
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
732
+ * @param {number} from Current zero-based slide index.
733
+ * @param {number} to Requested zero-based destination index.
734
+ * @returns {void}
735
+ */
736
+ #move(wrapper, state, from, to) {
737
+ if (from < 0 || from >= state.data.slides.length || to < 0 || to >= state.data.slides.length || from === to) return
738
+ state.context.mutate(() => {
739
+ const [slide] = state.data.slides.splice(from, 1)
740
+ state.data.slides.splice(to, 0, slide)
741
+ state.activeIndex = to
742
+ this.#build(wrapper, state)
743
+ })
744
+ }
745
+
746
+ /**
747
+ * Remove one slide as a single history operation.
748
+ * @param {HTMLElement} wrapper Root element owned by this block render.
749
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
750
+ * @param {number} index Zero-based index of the slide to remove.
751
+ * @returns {void}
752
+ */
753
+ #removeSlide(wrapper, state, index) {
754
+ if (index < 0 || index >= state.data.slides.length) return
755
+ state.context.mutate(() => {
756
+ state.data.slides.splice(index, 1)
757
+ state.activeIndex = Math.max(0, Math.min(index, state.data.slides.length - 1))
758
+ this.#build(wrapper, state)
759
+ })
760
+ }
761
+
762
+ /**
763
+ * Open the temporary local-media picker for this rendered block.
764
+ * @param {HTMLElement} wrapper Root element owned by this block render.
765
+ * @returns {void}
766
+ */
767
+ #triggerFileInput(wrapper) {
768
+ const state = this.#states.get(wrapper)
769
+ if (!state || state.context.readOnly) return
770
+ triggerFileInput({
771
+ accept: 'image/*,video/*',
772
+ multiple: true,
773
+ signal: state.lifecycleController.signal,
774
+ onFiles: files => { void this.#addFiles(wrapper, files) },
775
+ })
776
+ }
777
+
778
+ /**
779
+ * Open the embedded URL editor and append a media slide after validation.
780
+ * @param {HTMLElement} wrapper Root element owned by this block render.
781
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
782
+ * @returns {void}
783
+ */
784
+ #addUrl(wrapper, state) {
785
+ if (state.context.readOnly) return
786
+ openSourceEditor({
787
+ wrapper,
788
+ signal: state.lifecycleController.signal,
789
+ kind: 'url',
790
+ title: this._t('urlEditorTitle', 'Add media by URL'),
791
+ label: this._t('urlEditorLabel', 'Media URL'),
792
+ placeholder: this._t('urlEditorPlaceholder', 'https://example.com/image.jpg'),
793
+ submitText: this._t('sourceSubmit', 'Add'),
794
+ cancelText: this._t('sourceCancel', 'Cancel'),
795
+ invalidText: this._t('invalidUrl', 'Enter a valid media URL.'),
796
+ normalize: value => sanitizeUrl(value, { policy: 'media', fallback: '' }),
797
+ onSubmit: src => {
798
+ if (this.#states.get(wrapper) !== state || state.context.readOnly) return
799
+ const type = getMediaUrlType(src)
800
+ state.context.mutate(() => {
801
+ state.data.slides.push({ id: this.#createId(), type, src, alt: '', caption: '' })
802
+ state.activeIndex = state.data.slides.length - 1
803
+ this.#build(wrapper, state)
804
+ })
805
+ },
806
+ })
807
+ }
808
+
809
+ /**
810
+ * Open the embedded HTML editor and append a sanitized HTML slide.
811
+ * @param {HTMLElement} wrapper Root element owned by this block render.
812
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
813
+ * @returns {void}
814
+ */
815
+ #addHtml(wrapper, state) {
816
+ if (state.context.readOnly) return
817
+ openSourceEditor({
818
+ wrapper,
819
+ signal: state.lifecycleController.signal,
820
+ kind: 'html',
821
+ title: this._t('htmlEditorTitle', 'Add HTML slide'),
822
+ label: this._t('htmlEditorLabel', 'Slide HTML'),
823
+ placeholder: this._t('htmlEditorPlaceholder', '<article>...</article>'),
824
+ submitText: this._t('sourceSubmit', 'Add'),
825
+ cancelText: this._t('sourceCancel', 'Cancel'),
826
+ invalidText: this._t('invalidHtml', 'Enter valid slide HTML.'),
827
+ normalize: value => sanitizeRawHtml(value).trim(),
828
+ onSubmit: html => {
829
+ if (this.#states.get(wrapper) !== state || state.context.readOnly) return
830
+ state.context.mutate(() => {
831
+ state.data.slides.push({ id: this.#createId(), type: 'html', html, caption: '' })
832
+ state.activeIndex = state.data.slides.length - 1
833
+ this.#build(wrapper, state)
834
+ })
835
+ },
836
+ })
837
+ }
838
+
839
+ /**
840
+ * Convert supported local files to slides and commit them atomically.
841
+ * @param {HTMLElement} wrapper Root element owned by this block render.
842
+ * @param {File[]} files Files selected by the user, pasted, or dropped.
843
+ * @returns {Promise<void>} Promise settled after all supported files are processed.
844
+ */
845
+ async #addFiles(wrapper, files) {
846
+ const state = this.#states.get(wrapper)
847
+ if (!state || state.context.readOnly || state.lifecycleController.signal.aborted) return
848
+ const signal = state.lifecycleController.signal
849
+ const supportedFiles = files.filter(isSupportedMediaFile)
850
+ if (!supportedFiles.length) return
851
+ const slides = await Promise.all(supportedFiles.map(async file => {
852
+ try {
853
+ const type = getMediaFileType(file)
854
+ if (!type) return null
855
+ let src = ''
856
+ let poster = ''
857
+ if (this._config.uploadFile) {
858
+ const result = await this._config.uploadFile(file, { signal })
859
+ src = sanitizeUrl(result?.url || '', { policy: 'media', fallback: '' })
860
+ poster = sanitizeUrl(result?.poster || '', { policy: 'media', fallback: '' })
861
+ } else if (type === 'image') {
862
+ src = sanitizeUrl(await readFileDataUrl(file, signal), { policy: 'media', fallback: '' })
863
+ } else {
864
+ src = URL.createObjectURL(file)
865
+ this.#objectUrls.add(src)
866
+ }
867
+ if (!src) return null
868
+ return {
869
+ id: this.#createId(), type, src, alt: file.name, caption: '',
870
+ ...(type === 'video' && poster ? { poster } : {}),
871
+ }
872
+ } catch (error) {
873
+ if (!signal.aborted) console.warn(`[CarouselBlock] Failed to add "${file.name}":`, error)
874
+ return null
875
+ }
876
+ }))
877
+ if (signal.aborted || this.#states.get(wrapper) !== state) return
878
+ const valid = /** @type {CarouselSlide[]} */ (slides.filter(slide => slide !== null))
879
+ if (!valid.length) return
880
+ state.context.mutate(() => {
881
+ state.data.slides.push(...valid)
882
+ state.activeIndex = state.data.slides.length - valid.length
883
+ this.#build(wrapper, state)
884
+ })
885
+ }
886
+
887
+ /**
888
+ * Run an application-provided slide source and commit valid results atomically.
889
+ * @param {HTMLElement} wrapper Root element owned by this block render.
890
+ * @param {CarouselState} state Mutable state associated with `wrapper`.
891
+ * @param {CarouselAction} action Application source to execute.
892
+ * @returns {Promise<void>} Promise settled after the source completes or is cancelled.
893
+ */
894
+ async #runAction(wrapper, state, action) {
895
+ try {
896
+ const signal = state.lifecycleController.signal
897
+ const slides = await action.handler({ signal })
898
+ if (!slides || signal.aborted || this.#states.get(wrapper) !== state) return
899
+ const normalized = normalizeCarouselData({ slides, options: state.data.options }, () => this.#createId()).slides
900
+ .filter(slide => slide.type === 'html' ? !!slide.html?.trim() : !!slide.src)
901
+ if (!normalized.length) return
902
+ const usedIds = new Set(state.data.slides.map(slide => slide.id))
903
+ for (const slide of normalized) {
904
+ while (usedIds.has(slide.id)) slide.id = this.#createId()
905
+ usedIds.add(slide.id)
906
+ }
907
+ state.context.mutate(() => {
908
+ state.data.slides.push(...normalized)
909
+ state.activeIndex = state.data.slides.length - normalized.length
910
+ this.#build(wrapper, state)
911
+ })
912
+ } catch (error) {
913
+ if (!state.lifecycleController.signal.aborted) console.warn('[CarouselBlock] Action failed:', error)
914
+ }
915
+ }
916
+ }
917
+
918
+ export { CarouselBlock as Carousel }