blacklight-gallery 5.1.0 → 6.1.0

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. checksums.yaml +4 -4
  2. data/.env +2 -0
  3. data/.github/workflows/ruby.yml +10 -26
  4. data/Gemfile +5 -12
  5. data/README.md +3 -5
  6. data/Rakefile +53 -33
  7. data/app/assets/config/blacklight_gallery/manifest.js +1 -2
  8. data/app/assets/javascripts/blacklight_gallery/blacklight-gallery.esm.js +959 -484
  9. data/app/assets/javascripts/blacklight_gallery/blacklight-gallery.esm.js.map +1 -1
  10. data/app/assets/javascripts/blacklight_gallery/blacklight-gallery.js +959 -484
  11. data/app/assets/javascripts/blacklight_gallery/blacklight-gallery.js.map +1 -1
  12. data/app/assets/stylesheets/blacklight_gallery/{_gallery.scss → gallery.css} +8 -9
  13. data/app/assets/stylesheets/blacklight_gallery/masonry.css +104 -0
  14. data/app/assets/stylesheets/blacklight_gallery/{_osd_viewer.scss → osd_viewer.css} +1 -3
  15. data/app/assets/stylesheets/blacklight_gallery/{_slideshow.scss → slideshow.css} +19 -32
  16. data/app/components/blacklight/gallery/document_component.html.erb +2 -6
  17. data/app/components/blacklight/gallery/document_component.rb +0 -4
  18. data/app/components/blacklight/gallery/openseadragon_embed_component.rb +3 -4
  19. data/app/components/blacklight/gallery/slideshow_component.html.erb +1 -1
  20. data/app/components/blacklight/gallery/slideshow_component.rb +3 -6
  21. data/app/{views/catalog/_slideshow_modal.html.erb → components/blacklight/gallery/slideshow_modal_component.html.erb} +2 -3
  22. data/app/components/blacklight/gallery/slideshow_modal_component.rb +6 -0
  23. data/app/components/blacklight/gallery/slideshow_presenter_component.html.erb +14 -0
  24. data/app/components/blacklight/gallery/slideshow_presenter_component.rb +17 -0
  25. data/app/components/blacklight/gallery/slideshow_preview_component.html.erb +1 -5
  26. data/app/components/blacklight/gallery/slideshow_preview_component.rb +0 -13
  27. data/app/javascript/blacklight-gallery/grid-lanes-polyfill.js +1067 -0
  28. data/app/javascript/blacklight-gallery/index.js +18 -4
  29. data/app/views/catalog/_document_gallery.html.erb +1 -1
  30. data/app/views/catalog/_document_masonry.html.erb +1 -1
  31. data/app/views/catalog/_document_slideshow.html.erb +5 -25
  32. data/app/views/catalog/_openseadragon_default.html.erb +1 -1
  33. data/blacklight-gallery.gemspec +19 -20
  34. data/compose.yaml +18 -0
  35. data/config/importmap.rb +0 -1
  36. data/config/locales/blacklight-gallery.ar.yml +0 -3
  37. data/config/locales/blacklight-gallery.de.yml +3 -0
  38. data/config/locales/blacklight-gallery.en.yml +0 -3
  39. data/config/locales/blacklight-gallery.es.yml +3 -3
  40. data/config/locales/blacklight-gallery.fr.yml +3 -3
  41. data/config/locales/blacklight-gallery.it.yml +3 -3
  42. data/config/locales/blacklight-gallery.pt-BR.yml +3 -3
  43. data/config/locales/blacklight-gallery.zh.yml +3 -3
  44. data/lib/blacklight/gallery/version.rb +1 -1
  45. data/lib/generators/blacklight_gallery/install_generator.rb +24 -51
  46. data/package.json +4 -6
  47. data/rollup.config.js +13 -16
  48. data/spec/components/blacklight/gallery/document_component_spec.rb +2 -10
  49. data/spec/components/blacklight/gallery/openseadragon_embed_component_spec.rb +1 -6
  50. data/spec/components/blacklight/gallery/slideshow_component_spec.rb +28 -25
  51. data/spec/components/blacklight/gallery/slideshow_preview_component_spec.rb +3 -12
  52. data/spec/features/slideshow_spec.rb +23 -1
  53. data/spec/views/catalog/_document_slideshow.html.erb_spec.rb +6 -10
  54. metadata +48 -56
  55. data/app/assets/stylesheets/blacklight_gallery/_masonry.scss +0 -81
  56. data/app/assets/stylesheets/blacklight_gallery/default.scss +0 -7
  57. data/app/components/blacklight/gallery/icons/pause_slideshow_component.rb +0 -18
  58. data/app/components/blacklight/gallery/icons/start_slideshow_component.rb +0 -18
  59. data/app/javascript/blacklight-gallery/masonry.js +0 -12
  60. data/app/javascript/blacklight-gallery/slideshow.js +0 -122
  61. data/lib/generators/blacklight_gallery/templates/blacklight_gallery.css.scss +0 -3
  62. data/vendor/assets/javascripts/imagesloaded.pkgd.js +0 -441
  63. data/vendor/assets/javascripts/masonry.min.js +0 -9
@@ -0,0 +1,1067 @@
1
+ /**
2
+ * CSS Grid Lanes Polyfill
3
+ *
4
+ * Polyfills the new `display: grid-lanes` CSS feature for browsers
5
+ * that don't support it natively. Based on the WebKit implementation
6
+ * described at: https://webkit.org/blog/17660/introducing-css-grid-lanes/
7
+ *
8
+ * Features supported:
9
+ * - display: grid-lanes
10
+ * - grid-template-columns / grid-template-rows for lane definition
11
+ * - gap, column-gap, row-gap
12
+ * - --flow-tolerance for placement sensitivity
13
+ * - Spanning items (grid-column: span N)
14
+ * - Explicit placement (grid-column: N / M)
15
+ * - Responsive auto-fill/auto-fit with minmax()
16
+ * - Both waterfall (columns) and brick (rows) layouts
17
+ *
18
+ * Features that do not work:
19
+ * - fr units with grid-template-rows
20
+ *
21
+ * @version 1.0.0
22
+ * @author Simon Willison
23
+ * @author ninjamar
24
+ * @license MIT
25
+ */
26
+
27
+ // ============================================================================
28
+ // CONSTANTS & STATE
29
+ // ============================================================================
30
+
31
+ const POLYFILL_NAME = "GridLanesPolyfill"
32
+ const POLYFILL_ATTR = "data-grid-lanes-polyfilled"
33
+ const DEFAULT_TOLERANCE = 16 // ~1em in pixels
34
+
35
+ // Cache computed styles to reduce redundant calls
36
+ let styleMap = new WeakMap() // Not const because map has no clear meaning it has to be initialized later
37
+
38
+ // ============================================================================
39
+ // FEATURE DETECTION
40
+ // ============================================================================
41
+
42
+ /**
43
+ * Check if the browser natively supports display: grid-lanes
44
+ */
45
+ function supportsGridLanes() {
46
+ if (typeof CSS === "undefined" || !CSS.supports) {
47
+ return false
48
+ }
49
+ return CSS.supports("display", "grid-lanes")
50
+ }
51
+
52
+ // ============================================================================
53
+ // PARSING UTILITIES
54
+ // ============================================================================
55
+
56
+ /**
57
+ * Parse a CSS length value to pixels
58
+ */
59
+ function parseLengthToPixels(
60
+ value,
61
+ containerSize,
62
+ fontSize = 16,
63
+ rootFontSize = 16
64
+ ) {
65
+ if (!value || value === "auto" || value === "none") return null
66
+
67
+ const num = parseFloat(value)
68
+ if (isNaN(num)) return null
69
+
70
+ if (value.endsWith("px")) return num
71
+ if (value.endsWith("rem")) return num * rootFontSize
72
+ if (value.endsWith("em")) return num * fontSize
73
+ if (value.endsWith("ch")) return num * fontSize * 0.5 // Approximate
74
+ if (value.endsWith("lh")) return num * fontSize * 1.2 // Approximate line-height
75
+ if (value.endsWith("%")) return (num / 100) * containerSize
76
+ if (value.endsWith("vw")) return (num / 100) * window.innerWidth
77
+ if (value.endsWith("vh")) return (num / 100) * window.innerHeight
78
+ if (value.endsWith("vmin"))
79
+ return (num / 100) * Math.min(window.innerWidth, window.innerHeight)
80
+ if (value.endsWith("vmax"))
81
+ return (num / 100) * Math.max(window.innerWidth, window.innerHeight)
82
+ if (value.endsWith("fr")) return null // Handled separately
83
+
84
+ // Unitless number treated as pixels
85
+ if (!isNaN(num) && value === String(num)) return num
86
+
87
+ return null
88
+ }
89
+
90
+ /**
91
+ * Parse minmax() function
92
+ */
93
+ function parseMinMax(value) {
94
+ const match = value.match(/minmax\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/)
95
+ if (!match) return null
96
+ return {
97
+ min: match[1].trim(),
98
+ max: match[2].trim()
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Parse repeat() function
104
+ */
105
+ function parseRepeat(value) {
106
+ const match = value.match(/repeat\(\s*([^,]+)\s*,\s*(.+)\s*\)/)
107
+ if (!match) return null
108
+ return {
109
+ count: match[1].trim(),
110
+ pattern: match[2].trim()
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Tokenize a grid template string
116
+ */
117
+ function tokenizeTemplate(template) {
118
+ const tokens = []
119
+ let current = ""
120
+ let parenDepth = 0
121
+
122
+ for (let i = 0; i < template.length; i++) {
123
+ const char = template[i]
124
+
125
+ if (char === "(") {
126
+ parenDepth++
127
+ current += char
128
+ } else if (char === ")") {
129
+ parenDepth--
130
+ current += char
131
+ } else if (char === " " && parenDepth === 0) {
132
+ if (current.trim()) {
133
+ tokens.push(current.trim())
134
+ }
135
+ current = ""
136
+ } else {
137
+ current += char
138
+ }
139
+ }
140
+
141
+ if (current.trim()) {
142
+ tokens.push(current.trim())
143
+ }
144
+
145
+ return tokens
146
+ }
147
+
148
+ /**
149
+ * Calculate lane sizes from grid-template-columns/rows
150
+ */
151
+ function calculateLaneSizes(
152
+ template,
153
+ containerSize,
154
+ gap,
155
+ fontSize,
156
+ rootFontSize
157
+ ) {
158
+ if (!template || template === "none" || template === "auto") {
159
+ return null
160
+ }
161
+
162
+ const availableSpace = containerSize
163
+ let lanes = []
164
+ let totalFr = 0
165
+ let fixedSpace = 0
166
+
167
+ // Parse the template
168
+ const tokens = tokenizeTemplate(template)
169
+
170
+ for (const token of tokens) {
171
+ // Handle repeat()
172
+ const repeatInfo = parseRepeat(token)
173
+ if (repeatInfo) {
174
+ const { count, pattern } = repeatInfo
175
+ const patternTokens = tokenizeTemplate(pattern)
176
+
177
+ if (count === "auto-fill" || count === "auto-fit") {
178
+ // Calculate how many repetitions fit
179
+ let minSize = 0
180
+
181
+ for (const pt of patternTokens) {
182
+ const minmax = parseMinMax(pt)
183
+ if (minmax) {
184
+ const minVal = parseLengthToPixels(
185
+ minmax.min,
186
+ containerSize,
187
+ fontSize,
188
+ rootFontSize
189
+ )
190
+ if (minmax.min === "max-content" || minmax.min === "min-content") {
191
+ minSize += 100 // Fallback estimate
192
+ } else if (minVal !== null) {
193
+ minSize += minVal
194
+ }
195
+ } else {
196
+ const size = parseLengthToPixels(
197
+ pt,
198
+ containerSize,
199
+ fontSize,
200
+ rootFontSize
201
+ )
202
+ if (size !== null) {
203
+ minSize += size
204
+ } else if (pt.endsWith("fr")) {
205
+ minSize += 100 // Minimum fallback for fr units
206
+ }
207
+ }
208
+ }
209
+
210
+ // Calculate repetitions
211
+ const patternCount = patternTokens.length
212
+ const gapCount = patternCount - 1
213
+ const minPatternSize = minSize + gapCount * gap
214
+
215
+ let reps = Math.max(
216
+ 1,
217
+ Math.floor((availableSpace + gap) / (minPatternSize + gap))
218
+ )
219
+
220
+ // Expand pattern
221
+ for (let i = 0; i < reps; i++) {
222
+ for (const pt of patternTokens) {
223
+ const minmax = parseMinMax(pt)
224
+ if (minmax) {
225
+ const minVal = parseLengthToPixels(
226
+ minmax.min,
227
+ containerSize,
228
+ fontSize,
229
+ rootFontSize
230
+ )
231
+ const maxVal = minmax.max.endsWith("fr")
232
+ ? { fr: parseFloat(minmax.max) }
233
+ : parseLengthToPixels(
234
+ minmax.max,
235
+ containerSize,
236
+ fontSize,
237
+ rootFontSize
238
+ )
239
+
240
+ lanes.push({
241
+ min: minVal || 0,
242
+ max: maxVal,
243
+ size: 0
244
+ })
245
+
246
+ if (typeof maxVal === "object" && maxVal.fr) {
247
+ totalFr += maxVal.fr
248
+ } else {
249
+ // Only add to fixedSpace if max is NOT fr (non-flexible tracks)
250
+ fixedSpace += minVal || 0
251
+ }
252
+ } else if (pt.endsWith("fr")) {
253
+ const fr = parseFloat(pt)
254
+ lanes.push({ min: 0, max: { fr }, size: 0 })
255
+ totalFr += fr
256
+ } else {
257
+ const size =
258
+ parseLengthToPixels(
259
+ pt,
260
+ containerSize,
261
+ fontSize,
262
+ rootFontSize
263
+ ) || 0
264
+ lanes.push({ min: size, max: size, size })
265
+ fixedSpace += size
266
+ }
267
+ }
268
+ }
269
+ } else {
270
+ // Fixed repeat count
271
+ const reps = parseInt(count, 10)
272
+ for (let i = 0; i < reps; i++) {
273
+ for (const pt of patternTokens) {
274
+ const size = parseLengthToPixels(
275
+ pt,
276
+ containerSize,
277
+ fontSize,
278
+ rootFontSize
279
+ )
280
+ if (pt.endsWith("fr")) {
281
+ const fr = parseFloat(pt)
282
+ lanes.push({ min: 0, max: { fr }, size: 0 })
283
+ totalFr += fr
284
+ } else if (size !== null) {
285
+ lanes.push({ min: size, max: size, size })
286
+ fixedSpace += size
287
+ }
288
+ }
289
+ }
290
+ }
291
+ continue
292
+ }
293
+
294
+ // Handle minmax()
295
+ const minmax = parseMinMax(token)
296
+ if (minmax) {
297
+ const minVal = parseLengthToPixels(
298
+ minmax.min,
299
+ containerSize,
300
+ fontSize,
301
+ rootFontSize
302
+ )
303
+ const maxVal = minmax.max.endsWith("fr")
304
+ ? { fr: parseFloat(minmax.max) }
305
+ : parseLengthToPixels(minmax.max, containerSize, fontSize, rootFontSize)
306
+
307
+ lanes.push({ min: minVal || 0, max: maxVal, size: 0 })
308
+ if (typeof maxVal === "object" && maxVal.fr) {
309
+ totalFr += maxVal.fr
310
+ } else {
311
+ // Only add to fixedSpace if max is NOT fr (non-flexible tracks)
312
+ fixedSpace += minVal || 0
313
+ }
314
+ continue
315
+ }
316
+
317
+ // Handle fr units
318
+ if (token.endsWith("fr")) {
319
+ const fr = parseFloat(token)
320
+ lanes.push({ min: 0, max: { fr }, size: 0 })
321
+ totalFr += fr
322
+ continue
323
+ }
324
+
325
+ // Handle fixed sizes
326
+ const size = parseLengthToPixels(
327
+ token,
328
+ containerSize,
329
+ fontSize,
330
+ rootFontSize
331
+ )
332
+ if (size !== null) {
333
+ lanes.push({ min: size, max: size, size })
334
+ fixedSpace += size
335
+ }
336
+ }
337
+
338
+ // Calculate final sizes
339
+ const totalGaps = Math.max(0, lanes.length - 1) * gap
340
+ const flexSpace = Math.max(0, availableSpace - fixedSpace - totalGaps)
341
+ const frUnit = totalFr > 0 ? flexSpace / totalFr : 0
342
+
343
+ for (const lane of lanes) {
344
+ if (typeof lane.max === "object" && lane.max.fr) {
345
+ lane.size = lane.min + frUnit * lane.max.fr
346
+ } else {
347
+ lane.size = lane.min
348
+ }
349
+ }
350
+
351
+ return lanes.map(l => l.size)
352
+ }
353
+
354
+ // ============================================================================
355
+ // STYLE & ITEM UTILITIES
356
+ // ============================================================================
357
+
358
+ /**
359
+ * Get computed styles for grid-lanes properties
360
+ */
361
+ function getGridLanesStyles(element) {
362
+ const computed = window.getComputedStyle(element)
363
+ const fontSize = parseFloat(computed.getPropertyValue("font-size")) || 16
364
+ const rootFontSize =
365
+ parseFloat(
366
+ window
367
+ .getComputedStyle(document.documentElement)
368
+ .getPropertyValue("font-size")
369
+ ) || 16
370
+
371
+ // Get gap values
372
+ let gap =
373
+ computed.getPropertyValue("gap") ||
374
+ computed.getPropertyValue("grid-gap") ||
375
+ "0px"
376
+ let columnGap =
377
+ computed.getPropertyValue("column-gap") ||
378
+ computed.getPropertyValue("grid-column-gap") ||
379
+ gap
380
+ let rowGap =
381
+ computed.getPropertyValue("row-gap") ||
382
+ computed.getPropertyValue("grid-row-gap") ||
383
+ gap
384
+
385
+ // Handle combined gap shorthand like "24px 16px"
386
+ if (gap.includes(" ")) {
387
+ const [rg, cg] = gap.split(/\s+/)
388
+ rowGap = rg
389
+ columnGap = cg
390
+ }
391
+
392
+ // Parse flow-tolerance
393
+ let tolerance = DEFAULT_TOLERANCE
394
+ const toleranceValue = computed.getPropertyValue("--flow-tolerance").trim()
395
+
396
+ if (toleranceValue) {
397
+ const parsed = parseLengthToPixels(
398
+ toleranceValue,
399
+ 0,
400
+ fontSize,
401
+ rootFontSize
402
+ )
403
+ if (parsed !== null) tolerance = parsed
404
+ }
405
+
406
+ return {
407
+ gridTemplateColumns: computed.getPropertyValue("grid-template-columns"),
408
+ gridTemplateRows: computed.getPropertyValue("grid-template-rows"),
409
+ columnGap:
410
+ parseLengthToPixels(
411
+ String(columnGap).split(" ")[0],
412
+ 0,
413
+ fontSize,
414
+ rootFontSize
415
+ ) || 0,
416
+ rowGap:
417
+ parseLengthToPixels(
418
+ String(rowGap).split(" ")[0],
419
+ 0,
420
+ fontSize,
421
+ rootFontSize
422
+ ) || 0,
423
+ fontSize,
424
+ rootFontSize,
425
+ tolerance
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Get item placement properties
431
+ */
432
+ function getItemStyles(element) {
433
+ const computed = window.getComputedStyle(element)
434
+ const gridColumn = computed.gridColumn || computed.gridColumnStart
435
+ const gridRow = computed.gridRow || computed.gridRowStart
436
+
437
+ let columnSpan = 1
438
+ let columnStart = null
439
+ let columnEnd = null
440
+ let rowSpan = 1
441
+ let rowStart = null
442
+ let rowEnd = null
443
+
444
+ // Parse grid-column
445
+ if (gridColumn && gridColumn !== "auto") {
446
+ const spanMatch = gridColumn.match(/span\s+(\d+)/)
447
+ if (spanMatch) {
448
+ columnSpan = parseInt(spanMatch[1], 10)
449
+ } else if (gridColumn.includes("/")) {
450
+ const [start, end] = gridColumn.split("/").map(s => s.trim())
451
+ columnStart = parseInt(start, 10)
452
+ columnEnd = parseInt(end, 10)
453
+ if (!isNaN(columnStart) && !isNaN(columnEnd)) {
454
+ columnSpan = Math.abs(columnEnd - columnStart)
455
+ }
456
+ } else {
457
+ const num = parseInt(gridColumn, 10)
458
+ if (!isNaN(num)) {
459
+ columnStart = num
460
+ }
461
+ }
462
+ }
463
+
464
+ // Parse grid-row
465
+ if (gridRow && gridRow !== "auto") {
466
+ const spanMatch = gridRow.match(/span\s+(\d+)/)
467
+ if (spanMatch) {
468
+ rowSpan = parseInt(spanMatch[1], 10)
469
+ } else if (gridRow.includes("/")) {
470
+ const [start, end] = gridRow.split("/").map(s => s.trim())
471
+ rowStart = parseInt(start, 10)
472
+ rowEnd = parseInt(end, 10)
473
+ if (!isNaN(rowStart) && !isNaN(rowEnd)) {
474
+ rowSpan = Math.abs(rowEnd - rowStart)
475
+ }
476
+ } else {
477
+ const num = parseInt(gridRow, 10)
478
+ if (!isNaN(num)) {
479
+ rowStart = num
480
+ }
481
+ }
482
+ }
483
+
484
+ return {
485
+ columnSpan,
486
+ columnStart,
487
+ columnEnd,
488
+ rowSpan,
489
+ rowStart,
490
+ rowEnd
491
+ }
492
+ }
493
+
494
+ // ============================================================================
495
+ // STYLE CACHE MANAGEMENT
496
+ // ============================================================================
497
+
498
+ /**
499
+ * Get computed style with caching to reduce redundant calls
500
+ */
501
+ function catchedGetComputedStyle(elem, useCatch = true) {
502
+ if (useCatch && styleMap.has(elem)) {
503
+ return styleMap.get(elem)
504
+ }
505
+ const style = window.getComputedStyle(elem)
506
+ styleMap.set(elem, style)
507
+ return style
508
+ }
509
+
510
+ /**
511
+ * Clear cached style for an element
512
+ */
513
+ function removeCachedStyle(elem) {
514
+ styleMap.delete(elem)
515
+ }
516
+
517
+ // ============================================================================
518
+ // DOM UTILITIES
519
+ // ============================================================================
520
+
521
+ /**
522
+ * Check if element has the grid-lanes polyfill marker
523
+ */
524
+ function hasGridLanesProperty(elem) {
525
+ return (
526
+ catchedGetComputedStyle(elem)
527
+ .getPropertyValue("--grid-lanes-polyfill")
528
+ .trim() === "1"
529
+ )
530
+ }
531
+
532
+ /**
533
+ * Find all elements with --grid-lanes-polyfill down to the granular level.
534
+ * Prefers children over parent elements. Note: nested grid-lanes are not
535
+ * supported because the custom property cascades to all descendants.
536
+ *
537
+ * Uses TreeWalker for efficient traversal and routes through hasGridLanesProperty()
538
+ * to leverage the catchedGetComputedStyle cache. Correctly handles subtree skipping.
539
+ */
540
+ function findElements(root = document.body) {
541
+ const results = []
542
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
543
+
544
+ let node = walker.nextNode() // start from first child, skip root itself
545
+ while (node) {
546
+ if (hasGridLanesProperty(node)) {
547
+ results.push(node)
548
+ // Skip the entire subtree of this match by finding the next
549
+ // node outside of it: try nextSibling, then bubble up ancestors.
550
+ let next = null
551
+ let ancestor = node
552
+ while (ancestor && ancestor !== root) {
553
+ walker.currentNode = ancestor
554
+ next = walker.nextSibling()
555
+ if (next) break
556
+ ancestor = ancestor.parentElement
557
+ }
558
+ node = next // null means no more nodes in tree
559
+ } else {
560
+ node = walker.nextNode()
561
+ }
562
+ }
563
+ return results
564
+ }
565
+
566
+ /**
567
+ * Process a subtree for grid-lanes containers
568
+ */
569
+ function processSubtree(root, instances, options) {
570
+ const containers = findElements(root)
571
+ for (const container of containers) {
572
+ if (!instances.has(container) && !container.hasAttribute(POLYFILL_ATTR)) {
573
+ instances.set(container, new GridLanesLayout(container, options))
574
+ }
575
+ }
576
+ }
577
+
578
+ // ============================================================================
579
+ // GRID LANES LAYOUT CLASS
580
+ // ============================================================================
581
+
582
+ /**
583
+ * Main Grid Lanes layout class
584
+ */
585
+ class GridLanesLayout {
586
+ constructor(container, options = {}) {
587
+ this.container = container
588
+ this.options = options
589
+ this.isVertical = true // true = waterfall (columns), false = brick (rows)
590
+ this.lanes = []
591
+ this.laneHeights = []
592
+ this.resizeObserver = null
593
+ this.mutationObserver = null
594
+
595
+ this.init()
596
+ }
597
+
598
+ init() {
599
+ // Mark as polyfilled
600
+ this.container.setAttribute(POLYFILL_ATTR, "true")
601
+
602
+ // Set up container styles
603
+ this.container.style.position = "relative"
604
+ this.container.style.display = "block"
605
+
606
+ // Initial layout
607
+ this.layout()
608
+
609
+ // Set up observers
610
+ this.setupObservers()
611
+ }
612
+
613
+ setupObservers() {
614
+ // Debounce layout calls to avoid excessive recalculations
615
+ let layoutTimeout = null
616
+ const debouncedLayout = () => {
617
+ if (layoutTimeout) clearTimeout(layoutTimeout)
618
+ layoutTimeout = setTimeout(() => this.layout(), 16)
619
+ }
620
+
621
+ // Resize observer for responsive layouts AND child size changes
622
+ this.resizeObserver = new ResizeObserver(entries => {
623
+ // Check if it's the container or a child that resized
624
+ for (const entry of entries) {
625
+ if (
626
+ entry.target === this.container ||
627
+ entry.target.parentElement === this.container
628
+ ) {
629
+ debouncedLayout()
630
+ break
631
+ }
632
+ }
633
+ })
634
+ this.resizeObserver.observe(this.container)
635
+
636
+ // Observe all direct children for size changes
637
+ for (const child of this.container.children) {
638
+ if (child.nodeType === Node.ELEMENT_NODE) {
639
+ this.resizeObserver.observe(child)
640
+ }
641
+ }
642
+
643
+ // Mutation observer for dynamic content
644
+ this.mutationObserver = new MutationObserver(mutations => {
645
+ let shouldRelayout = false
646
+ for (const mutation of mutations) {
647
+ if (mutation.type === "childList") {
648
+ // Observe new children
649
+ for (const node of mutation.addedNodes) {
650
+ if (node.nodeType === Node.ELEMENT_NODE) {
651
+ this.resizeObserver.observe(node)
652
+ // Also watch for images in new nodes
653
+ this.observeImages(node)
654
+ }
655
+ }
656
+ shouldRelayout = true
657
+ } else if (
658
+ mutation.type === "attributes" &&
659
+ mutation.attributeName === "style"
660
+ ) {
661
+ shouldRelayout = true
662
+ }
663
+ }
664
+ if (shouldRelayout) {
665
+ debouncedLayout()
666
+ }
667
+ })
668
+ this.mutationObserver.observe(this.container, {
669
+ childList: true,
670
+ subtree: false,
671
+ attributes: true,
672
+ attributeFilter: ["style", "class"]
673
+ })
674
+
675
+ // Observe all images for load events
676
+ this.observeImages(this.container)
677
+ }
678
+
679
+ observeImages(root) {
680
+ const images = root.querySelectorAll("img")
681
+ for (const img of images) {
682
+ if (!img.complete) {
683
+ img.addEventListener("load", () => this.layout(), { once: true })
684
+ img.addEventListener("error", () => this.layout(), { once: true })
685
+ }
686
+ }
687
+ }
688
+
689
+ layout() {
690
+ const styles = getGridLanesStyles(this.container)
691
+ const containerRect = this.container.getBoundingClientRect()
692
+
693
+ // Determine direction based on which template is defined
694
+ const hasColumns =
695
+ styles.gridTemplateColumns &&
696
+ styles.gridTemplateColumns !== "none" &&
697
+ !styles.gridTemplateColumns.startsWith("auto")
698
+ const hasRows =
699
+ styles.gridTemplateRows &&
700
+ styles.gridTemplateRows !== "none" &&
701
+ !styles.gridTemplateRows.startsWith("auto")
702
+
703
+ this.isVertical = hasColumns || !hasRows
704
+
705
+ // Calculate lane sizes
706
+ if (this.isVertical) {
707
+ this.lanes = calculateLaneSizes(
708
+ styles.gridTemplateColumns,
709
+ containerRect.width,
710
+ styles.columnGap,
711
+ styles.fontSize,
712
+ styles.rootFontSize
713
+ ) || [containerRect.width]
714
+ } else {
715
+ this.lanes = calculateLaneSizes(
716
+ styles.gridTemplateRows,
717
+ containerRect.height,
718
+ styles.rowGap,
719
+ styles.fontSize,
720
+ styles.rootFontSize
721
+ ) || [containerRect.height]
722
+ }
723
+
724
+ // Initialize lane positions (heights for vertical, widths for horizontal)
725
+ this.laneHeights = new Array(this.lanes.length).fill(0)
726
+
727
+ // Get all direct children
728
+ const items = Array.from(this.container.children).filter(
729
+ el =>
730
+ el.nodeType === Node.ELEMENT_NODE &&
731
+ window.getComputedStyle(el).display !== "none"
732
+ )
733
+
734
+ // Separate explicitly placed items from auto-placed items
735
+ const explicitItems = []
736
+ const autoItems = []
737
+
738
+ for (const item of items) {
739
+ const itemStyles = getItemStyles(item)
740
+ if (this.isVertical && itemStyles.columnStart !== null) {
741
+ explicitItems.push({ element: item, styles: itemStyles })
742
+ } else if (!this.isVertical && itemStyles.rowStart !== null) {
743
+ explicitItems.push({ element: item, styles: itemStyles })
744
+ } else {
745
+ autoItems.push({ element: item, styles: itemStyles })
746
+ }
747
+ }
748
+
749
+ // Place explicitly positioned items first
750
+ for (const { element, styles: itemStyles } of explicitItems) {
751
+ this.placeExplicitItem(element, itemStyles, styles)
752
+ }
753
+
754
+ // Place auto-positioned items
755
+ for (const { element, styles: itemStyles } of autoItems) {
756
+ this.placeAutoItem(element, itemStyles, styles)
757
+ }
758
+
759
+ // Set container height
760
+ const containerHeight = Math.max(...this.laneHeights)
761
+ this.container.style.minHeight = `${containerHeight}px`
762
+ }
763
+
764
+ placeExplicitItem(element, itemStyles, containerStyles) {
765
+ const gap = this.isVertical
766
+ ? containerStyles.columnGap
767
+ : containerStyles.rowGap
768
+ const crossGap = this.isVertical
769
+ ? containerStyles.rowGap
770
+ : containerStyles.columnGap
771
+
772
+ let laneIndex
773
+ let span
774
+
775
+ if (this.isVertical) {
776
+ // Handle negative indices
777
+ laneIndex = itemStyles.columnStart
778
+ if (laneIndex < 0) {
779
+ laneIndex = this.lanes.length + laneIndex + 1
780
+ }
781
+ laneIndex = Math.max(0, Math.min(laneIndex - 1, this.lanes.length - 1))
782
+ span = itemStyles.columnSpan
783
+ } else {
784
+ laneIndex = itemStyles.rowStart
785
+ if (laneIndex < 0) {
786
+ laneIndex = this.lanes.length + laneIndex + 1
787
+ }
788
+ laneIndex = Math.max(0, Math.min(laneIndex - 1, this.lanes.length - 1))
789
+ span = itemStyles.rowSpan
790
+ }
791
+
792
+ // Calculate position
793
+ let position = 0
794
+ for (let i = 0; i < laneIndex; i++) {
795
+ position += this.lanes[i] + gap
796
+ }
797
+
798
+ // Calculate width (for spanning)
799
+ let size = 0
800
+ const endLane = Math.min(laneIndex + span, this.lanes.length)
801
+ for (let i = laneIndex; i < endLane; i++) {
802
+ size += this.lanes[i]
803
+ if (i < endLane - 1) size += gap
804
+ }
805
+
806
+ // Get the tallest lane in the span
807
+ let maxHeight = 0
808
+ for (let i = laneIndex; i < endLane; i++) {
809
+ maxHeight = Math.max(maxHeight, this.laneHeights[i])
810
+ }
811
+
812
+ // Position the element
813
+ element.style.position = "absolute"
814
+
815
+ if (this.isVertical) {
816
+ element.style.left = `${position}px`
817
+ element.style.top = `${maxHeight > 0 ? maxHeight + crossGap : 0}px`
818
+ element.style.width = `${size}px`
819
+ } else {
820
+ element.style.top = `${position}px`
821
+ element.style.left = `${maxHeight > 0 ? maxHeight + crossGap : 0}px`
822
+ element.style.height = `${size}px`
823
+ element.style.width = ""
824
+ }
825
+
826
+ // Update lane heights
827
+ const itemRect = element.getBoundingClientRect()
828
+ const itemSize = this.isVertical ? itemRect.height : itemRect.width
829
+ const newHeight = maxHeight + (maxHeight > 0 ? crossGap : 0) + itemSize
830
+
831
+ for (let i = laneIndex; i < endLane; i++) {
832
+ this.laneHeights[i] = newHeight
833
+ }
834
+ }
835
+
836
+ placeAutoItem(element, itemStyles, containerStyles) {
837
+ const gap = this.isVertical
838
+ ? containerStyles.columnGap
839
+ : containerStyles.rowGap
840
+ const crossGap = this.isVertical
841
+ ? containerStyles.rowGap
842
+ : containerStyles.columnGap
843
+ const tolerance = containerStyles.tolerance
844
+ const span = Math.min(
845
+ this.isVertical ? itemStyles.columnSpan : itemStyles.rowSpan,
846
+ this.lanes.length
847
+ )
848
+
849
+ // Find the best lane(s) considering tolerance
850
+ let bestLane = 0
851
+ let bestHeight = Infinity
852
+
853
+ for (let i = 0; i <= this.lanes.length - span; i++) {
854
+ // Get the max height across the span
855
+ let maxHeight = 0
856
+ for (let j = i; j < i + span; j++) {
857
+ maxHeight = Math.max(maxHeight, this.laneHeights[j])
858
+ }
859
+
860
+ // Use tolerance to determine if this is meaningfully better
861
+ if (bestHeight - maxHeight > tolerance) {
862
+ bestHeight = maxHeight
863
+ bestLane = i
864
+ }
865
+ // Within tolerance: keep the current (earlier) bestLane — spec prefers earliest equivalent lane
866
+ }
867
+
868
+ // Calculate position
869
+ let position = 0
870
+ for (let i = 0; i < bestLane; i++) {
871
+ position += this.lanes[i] + gap
872
+ }
873
+
874
+ // Calculate size (for spanning)
875
+ let size = 0
876
+ const endLane = Math.min(bestLane + span, this.lanes.length)
877
+ for (let i = bestLane; i < endLane; i++) {
878
+ size += this.lanes[i]
879
+ if (i < endLane - 1) size += gap
880
+ }
881
+
882
+ // Position the element
883
+ element.style.position = "absolute"
884
+
885
+ if (this.isVertical) {
886
+ element.style.left = `${position}px`
887
+ element.style.top = `${bestHeight > 0 ? bestHeight + crossGap : 0}px`
888
+ element.style.width = `${size}px`
889
+ } else {
890
+ element.style.top = `${position}px`
891
+ element.style.left = `${bestHeight > 0 ? bestHeight + crossGap : 0}px`
892
+ element.style.height = `${size}px`
893
+ element.style.width = ""
894
+ }
895
+
896
+ // Update lane heights
897
+ const itemRect = element.getBoundingClientRect()
898
+ const itemSize = this.isVertical ? itemRect.height : itemRect.width
899
+ const newHeight = bestHeight + (bestHeight > 0 ? crossGap : 0) + itemSize
900
+
901
+ for (let i = bestLane; i < endLane; i++) {
902
+ this.laneHeights[i] = newHeight
903
+ }
904
+ }
905
+
906
+ destroy() {
907
+ if (this.resizeObserver) {
908
+ this.resizeObserver.disconnect()
909
+ }
910
+ if (this.mutationObserver) {
911
+ this.mutationObserver.disconnect()
912
+ }
913
+
914
+ this.container.removeAttribute(POLYFILL_ATTR)
915
+ this.container.style.position = ""
916
+ this.container.style.display = ""
917
+ this.container.style.minHeight = ""
918
+
919
+ // Reset item styles
920
+ for (const item of this.container.children) {
921
+ if (item.nodeType === Node.ELEMENT_NODE) {
922
+ item.style.position = ""
923
+ item.style.left = ""
924
+ item.style.top = ""
925
+ item.style.width = ""
926
+ item.style.height = ""
927
+ }
928
+ }
929
+ }
930
+
931
+ refresh() {
932
+ this.layout()
933
+ }
934
+ }
935
+
936
+ // ============================================================================
937
+ // PUBLIC API
938
+ // ============================================================================
939
+
940
+ /**
941
+ * Initialize the polyfill on all grid-lanes elements in the document
942
+ */
943
+ function init(options = {}) {
944
+ // Check if native support exists
945
+ if (supportsGridLanes() && !options.force) {
946
+ console.log(
947
+ `${POLYFILL_NAME}: Native support detected, polyfill not needed.`
948
+ )
949
+ return { supported: true, instances: [] }
950
+ }
951
+
952
+ const instances = new Map()
953
+
954
+ // Process existing containers
955
+ const containers = findElements()
956
+
957
+ for (const container of containers) {
958
+ if (!container.hasAttribute(POLYFILL_ATTR)) {
959
+ instances.set(container, new GridLanesLayout(container, options))
960
+ }
961
+ }
962
+
963
+ // Watch for new stylesheets and elements
964
+ // Debounce for ancestor attribute changes to avoid excessive relayouts
965
+ let ancestorLayoutTimeout = null
966
+ const pendingAncestorInstances = new Map()
967
+ const debouncedRelayoutAll = () => {
968
+ if (ancestorLayoutTimeout) clearTimeout(ancestorLayoutTimeout)
969
+ ancestorLayoutTimeout = setTimeout(() => {
970
+ for (const [container, instance] of pendingAncestorInstances) {
971
+ if (instances.has(container)) {
972
+ removeCachedStyle(container)
973
+ instance.refresh()
974
+ }
975
+ }
976
+ pendingAncestorInstances.clear()
977
+ }, 16)
978
+ }
979
+
980
+ const observer = new MutationObserver(mutations => {
981
+ for (const mutation of mutations) {
982
+ if (mutation.type === "attributes") {
983
+ removeCachedStyle(mutation.target)
984
+ // If the changed node is an ancestor of (or is) a container,
985
+ // cascaded/inherited styles on the container may have changed.
986
+ for (const [container] of instances) {
987
+ if (mutation.target.contains(container)) {
988
+ pendingAncestorInstances.set(container, instances.get(container))
989
+ }
990
+ }
991
+ if (pendingAncestorInstances.size > 0) {
992
+ debouncedRelayoutAll()
993
+ }
994
+ }
995
+
996
+ if (mutation.type === "childList") {
997
+ for (const node of mutation.addedNodes) {
998
+ if (node.nodeType !== node.ELEMENT_NODE) continue
999
+
1000
+ processSubtree(node, instances, options)
1001
+ }
1002
+ for (const node of mutation.removedNodes) {
1003
+ if (node.nodeType !== node.ELEMENT_NODE) continue
1004
+ if (instances.has(node)) {
1005
+ instances.get(node).destroy()
1006
+ instances.delete(node)
1007
+ }
1008
+ styleMap.delete(node)
1009
+ }
1010
+ }
1011
+ }
1012
+ })
1013
+
1014
+ observer.observe(document.documentElement, {
1015
+ childList: true,
1016
+ subtree: true,
1017
+ attributes: true,
1018
+ attributeFilter: ["style", "class"]
1019
+ })
1020
+
1021
+ console.log(
1022
+ `${POLYFILL_NAME}: Initialized, ${instances.size} container(s) found.`
1023
+ )
1024
+
1025
+ return {
1026
+ supported: false,
1027
+ instances,
1028
+ observer,
1029
+ refresh() {
1030
+ for (const instance of instances.values()) {
1031
+ instance.refresh()
1032
+ }
1033
+ },
1034
+ destroy() {
1035
+ observer.disconnect()
1036
+ for (const instance of instances.values()) {
1037
+ instance.destroy()
1038
+ }
1039
+ instances.clear()
1040
+ // TODO: Ensure GC is triggered
1041
+ }
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * Apply layout to a specific element
1047
+ */
1048
+ function apply(element, options = {}) {
1049
+ if (supportsGridLanes() && !options.force) {
1050
+ return null
1051
+ }
1052
+ return new GridLanesLayout(element, options)
1053
+ }
1054
+
1055
+ // ============================================================================
1056
+ // EXPORTS
1057
+ // ============================================================================
1058
+
1059
+ export { supportsGridLanes, init, apply, GridLanesLayout }
1060
+
1061
+ export default {
1062
+ supportsGridLanes,
1063
+ init,
1064
+ apply,
1065
+ GridLanesLayout,
1066
+ version: "1.1.0"
1067
+ }