@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.
- package/README.md +25 -24
- package/dist/core/clipboard/Clipboard.d.ts +2 -0
- package/dist/core/clipboard/Clipboard.js +52 -24
- package/dist/core/index.js +195 -194
- package/dist/core/locale/en.d.ts +1 -0
- package/dist/core/locale/en.js +1 -0
- package/dist/core/locale/ru.d.ts +1 -0
- package/dist/core/locale/ru.js +1 -0
- package/dist/core/themes/light.css +1 -1
- package/dist/core/themes/variables.css +72 -10
- package/dist/locale/en.d.ts +41 -0
- package/dist/locale/ru.d.ts +41 -0
- package/dist/plugins/attaches/attaches.css +16 -14
- package/dist/plugins/attaches/index.js +997 -920
- package/dist/plugins/attaches/locale/en.d.ts +9 -0
- package/dist/plugins/attaches/locale/en.js +11 -2
- package/dist/plugins/attaches/locale/ru.d.ts +9 -0
- package/dist/plugins/attaches/locale/ru.js +11 -2
- package/dist/plugins/carousel/carousel.css +213 -49
- package/dist/plugins/carousel/index.js +918 -858
- package/dist/plugins/carousel/locale/en.d.ts +15 -0
- package/dist/plugins/carousel/locale/en.js +18 -3
- package/dist/plugins/carousel/locale/ru.d.ts +15 -0
- package/dist/plugins/carousel/locale/ru.js +18 -3
- package/dist/plugins/gallery/README.md +92 -92
- package/dist/plugins/gallery/README.ru.md +17 -17
- package/dist/plugins/gallery/gallery.css +44 -19
- package/dist/plugins/gallery/index.js +375 -354
- package/dist/plugins/gallery/locale/en.d.ts +8 -0
- package/dist/plugins/gallery/locale/en.js +11 -3
- package/dist/plugins/gallery/locale/ru.d.ts +8 -0
- package/dist/plugins/gallery/locale/ru.js +11 -3
- package/dist/plugins/gallery/settings.js +15 -10
- package/dist/plugins/gallery/view-empty.d.ts +2 -0
- package/dist/plugins/gallery/view-empty.js +21 -15
- package/dist/plugins/gallery/view-filled.d.ts +2 -2
- package/dist/plugins/gallery/view-filled.js +84 -72
- package/dist/plugins/image/image.css +7 -2
- package/dist/plugins/image/index.js +365 -345
- package/dist/plugins/image/locale/en.d.ts +8 -0
- package/dist/plugins/image/locale/en.js +11 -3
- package/dist/plugins/image/locale/ru.d.ts +8 -0
- package/dist/plugins/image/locale/ru.js +11 -3
- package/dist/plugins/image/view-empty.d.ts +2 -0
- package/dist/plugins/image/view-empty.js +21 -15
- package/dist/plugins/image/view-filled.d.ts +2 -2
- package/dist/plugins/image/view-filled.js +77 -72
- package/dist/plugins/shared/dropzone.d.ts +6 -0
- package/dist/plugins/shared/dropzone.js +47 -36
- package/dist/plugins/shared/layer.d.ts +13 -0
- package/dist/plugins/shared/layer.js +45 -0
- package/dist/plugins/shared/sourceEditor.css +165 -0
- package/dist/plugins/shared/sourceEditor.d.ts +97 -0
- package/dist/plugins/shared/sourceEditor.js +260 -0
- package/dist/renderer/renderers/carousel/index.js +24 -23
- package/dist/renderer/renderers/carousel/styles.css +8 -2
- package/dist/renderer/renderers/gallery/README.md +41 -41
- package/dist/renderer/renderers/gallery/README.ru.md +41 -41
- package/dist/renderer/renderers/gallery/index.js +100 -85
- package/dist/renderer/renderers/gallery/styles.css +417 -401
- package/dist/shared/galleryMasonry.d.ts +34 -0
- package/dist/shared/galleryMasonry.js +152 -0
- 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 {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
if (
|
|
81
|
-
return null
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
* @
|
|
109
|
-
* @
|
|
110
|
-
* @
|
|
111
|
-
* @
|
|
112
|
-
* @property {
|
|
113
|
-
* @property {
|
|
114
|
-
* @
|
|
115
|
-
* @property {string} [
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
*
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
wrapper
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
this.#states.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
*
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
*
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
*
|
|
311
|
-
* @param {
|
|
312
|
-
* @
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
stage.appendChild(
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
stage.appendChild(
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
*
|
|
425
|
-
* @param {
|
|
426
|
-
* @
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
button.
|
|
435
|
-
button.
|
|
436
|
-
button
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
*
|
|
470
|
-
* @param {
|
|
471
|
-
* @
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
document.
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
)
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
*
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
if (
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
*
|
|
673
|
-
* @param {
|
|
674
|
-
* @
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
row.
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
*
|
|
710
|
-
* @param {
|
|
711
|
-
* @param {
|
|
712
|
-
* @
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
*
|
|
764
|
-
* @
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
if (!
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
state.
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
*
|
|
781
|
-
* @param {
|
|
782
|
-
* @
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
this
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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 }
|