fix: spa on Filament image gallery column, entry, and component with Viewer.js integration.

This commit is contained in:
al-saloul
2025-12-17 12:09:45 +03:00
parent 6181dbe50b
commit f379ce020b
7 changed files with 316 additions and 287 deletions

View File

@@ -1,3 +1,202 @@
// Image Gallery Plugin - Viewer.js initialization
// The main Viewer.js initialization is done inline via CDN in the viewer-script.blade.php component
// This file is a placeholder for future compiled assets
// This script handles SPA navigation and dynamic content loading
(function () {
const VIEWER_JS_URL = 'https://unpkg.com/viewerjs@1.11.6/dist/viewer.min.js';
const VIEWER_CSS_URL = 'https://unpkg.com/viewerjs@1.11.6/dist/viewer.min.css';
let loadingPromise = null;
// Load Viewer.js CSS dynamically
function loadViewerCSS() {
if (document.querySelector('link[href="' + VIEWER_CSS_URL + '"]')) {
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = VIEWER_CSS_URL;
document.head.appendChild(link);
}
// Load Viewer.js JS dynamically
function loadViewerJS() {
if (loadingPromise) {
return loadingPromise;
}
if (typeof window.Viewer !== 'undefined') {
return Promise.resolve();
}
if (document.querySelector('script[src="' + VIEWER_JS_URL + '"]')) {
// Script tag exists but Viewer may not be loaded yet
return new Promise(function (resolve) {
const checkInterval = setInterval(function () {
if (typeof window.Viewer !== 'undefined') {
clearInterval(checkInterval);
resolve();
}
}, 50);
});
}
loadingPromise = new Promise(function (resolve, reject) {
const script = document.createElement('script');
script.src = VIEWER_JS_URL;
script.onload = function () {
loadingPromise = null;
resolve();
};
script.onerror = function () {
loadingPromise = null;
reject(new Error('Failed to load Viewer.js'));
};
document.head.appendChild(script);
});
return loadingPromise;
}
// Check if Viewer.js is available
function isViewerAvailable() {
return typeof window.Viewer !== 'undefined';
}
function initOne(el) {
if (!el || el._viewer || !isViewerAvailable()) return;
el._viewer = new Viewer(el, {
toolbar: {
zoomIn: 1,
zoomOut: 1,
oneToOne: 1,
reset: 1,
prev: 1,
play: 0,
next: 1,
rotateLeft: 1,
rotateRight: 1,
flipHorizontal: 1,
flipVertical: 1,
},
navbar: false,
inline: false,
movable: true,
rotatable: true,
scalable: true,
fullscreen: true,
transition: true,
title: false,
});
}
function destroyOne(el) {
if (el && el._viewer) {
try {
el._viewer.destroy();
} catch (e) { }
el._viewer = null;
}
}
function scan() {
const galleries = document.querySelectorAll('[data-viewer-gallery]');
if (galleries.length === 0) return;
// Ensure Viewer.js is loaded before initializing
loadViewerCSS();
loadViewerJS().then(function () {
galleries.forEach(function (el) {
// Destroy and reinitialize to handle SPA navigation
destroyOne(el);
initOne(el);
});
}).catch(function (err) {
console.error('ImageGallery:', err);
});
}
// Run on various lifecycle events
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
// DOM is already ready
scan();
}
// Filament/Livewire 3.x SPA navigation
document.addEventListener('livewire:navigated', function () {
setTimeout(scan, 100);
});
// Livewire 3.x morph updates
document.addEventListener('livewire:init', function () {
if (window.Livewire) {
Livewire.hook('morph.updated', function ({ el }) {
setTimeout(scan, 100);
});
}
});
// For Livewire 2.x compatibility
document.addEventListener('livewire:load', scan);
if (window.Livewire && window.Livewire.hook) {
try {
window.Livewire.hook('message.processed', function () {
setTimeout(scan, 100);
});
} catch (e) { }
}
// Turbolinks/Turbo compatibility
document.addEventListener('turbo:load', scan);
document.addEventListener('turbolinks:load', scan);
// Alpine.js x-init hook
document.addEventListener('alpine:init', function () {
if (window.Alpine) {
Alpine.directive('image-gallery-init', function (el) {
loadViewerCSS();
loadViewerJS().then(function () {
initOne(el);
});
});
}
});
// MutationObserver for dynamic content
const observer = new MutationObserver(function (mutations) {
let shouldScan = false;
mutations.forEach(function (mutation) {
if (mutation.addedNodes.length) {
mutation.addedNodes.forEach(function (node) {
if (node.nodeType === 1) {
if (node.hasAttribute && node.hasAttribute('data-viewer-gallery')) {
shouldScan = true;
}
if (node.querySelectorAll) {
const galleries = node.querySelectorAll('[data-viewer-gallery]');
if (galleries.length) {
shouldScan = true;
}
}
}
});
}
});
if (shouldScan) {
setTimeout(scan, 100);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Expose scan function globally for manual triggering if needed
window.ImageGallery = {
scan: scan,
init: initOne,
destroy: destroyOne
};
})();

View File

@@ -1,87 +1,71 @@
@php
$state = $getState();
if ($state instanceof \Illuminate\Support\Collection) {
$state = $state->all();
}
$state = \Illuminate\Support\Arr::wrap($state);
$limit = $getLimit();
$limitedState = $limit ? array_slice($state, 0, $limit) : $state;
$remaining = $limit ? max(0, count($state) - $limit) : 0;
$isCircular = $isCircular();
$isSquare = $isSquare();
$isStacked = $isStacked();
$overlap = $isStacked ? ($getOverlap() ?? 2) : null;
$overlap = $isStacked ? $getOverlap() ?? 2 : null;
$defaultWidth = $getWidth();
$defaultHeight = $getHeight();
$defaultWidth = $defaultWidth ? (is_numeric($defaultWidth) ? $defaultWidth . 'px' : $defaultWidth) : 'auto';
$defaultHeight = $defaultHeight ? (is_numeric($defaultHeight) ? $defaultHeight . 'px' : $defaultHeight) : '40px';
$galleryId = 'gallery-' . str_replace(['{', '}', '-'], '', (string) \Illuminate\Support\Str::uuid());
@endphp
<div
id="{{ $galleryId }}"
{{
$attributes
->merge($getExtraAttributes(), escape: false)
->class([
'fi-ta-image',
'flex items-center',
match ($overlap) {
1 => '-space-x-1 rtl:space-x-reverse',
2 => '-space-x-2 rtl:space-x-reverse',
3 => '-space-x-3 rtl:space-x-reverse',
4 => '-space-x-4 rtl:space-x-reverse',
5 => '-space-x-5 rtl:space-x-reverse',
6 => '-space-x-6 rtl:space-x-reverse',
7 => '-space-x-7 rtl:space-x-reverse',
8 => '-space-x-8 rtl:space-x-reverse',
default => 'gap-1.5',
},
])
}}
data-viewer-gallery
wire:ignore.self
>
<div id="{{ $galleryId }}"
{{ $attributes->merge($getExtraAttributes(), escape: false)->class([
'fi-ta-image',
'flex items-center',
match ($overlap) {
1 => '-space-x-1 rtl:space-x-reverse',
2 => '-space-x-2 rtl:space-x-reverse',
3 => '-space-x-3 rtl:space-x-reverse',
4 => '-space-x-4 rtl:space-x-reverse',
5 => '-space-x-5 rtl:space-x-reverse',
6 => '-space-x-6 rtl:space-x-reverse',
7 => '-space-x-7 rtl:space-x-reverse',
8 => '-space-x-8 rtl:space-x-reverse',
default => 'gap-1.5',
},
]) }}
data-viewer-gallery wire:ignore.self>
@foreach ($limitedState as $stateItem)
<img
src="{{ $getImageUrl($stateItem) }}"
<img src="{{ $getImageUrl($stateItem) }}"
style="
height: {{ $defaultHeight }};
width: {{ $defaultWidth }};
cursor: pointer;
"
{{
$getExtraImgAttributeBag()
->class([
'max-w-none object-cover object-center curor',
'rounded-full' => $isCircular,
'rounded-lg' => $isSquare,
'ring-white dark:ring-gray-900' => $isStacked,
'ring-2' => $isStacked && ($overlap === null || $overlap > 0),
])
}}
/>
{{ $getExtraImgAttributeBag()->class([
'max-w-none object-cover object-center curor',
'rounded-full' => $isCircular,
'rounded-lg' => $isSquare,
'ring-white dark:ring-gray-900' => $isStacked,
'ring-2' => $isStacked && ($overlap === null || $overlap > 0),
]) }} />
@endforeach
@if ($remaining > 0 && ($limitedRemainingText ?? true))
<div
style="
<div style="
min-height: {{ $defaultHeight }};
min-width: {{ $defaultWidth }};
height: {{ $defaultHeight }};
width: {{ $defaultWidth }};
"
@class([
'flex items-center justify-center font-medium text-gray-500',
])
>
@class(['flex items-center justify-center font-medium text-gray-500'])>
<span class="-ms-0.5 text-xs">
+{{ $remaining }}
</span>
@@ -89,6 +73,4 @@
@endif
</div>
@once
<x-image-gallery::viewer-script />
@endonce
{{-- Viewer.js assets are loaded dynamically via image-gallery.js --}}

View File

@@ -12,7 +12,7 @@
$ringWidth = $getRingWidth();
$ringColor = $getRingColor();
$galleryId = 'gallery-col-' . str_replace(['{', '}', '-'], '', (string) \Illuminate\Support\Str::uuid());
// Determine border radius class
if ($isCircular) {
$borderRadiusClass = 'rounded-full';
@@ -21,7 +21,7 @@
} else {
$borderRadiusClass = 'rounded';
}
// Border/Ring styles - only add if ringWidth > 0
$hasRing = $ringWidth > 0;
if ($hasRing) {
@@ -36,14 +36,14 @@
$ringStyle = '';
$borderColorClass = '';
}
// Stacked spacing - use dynamic -space-x value
if ($isStacked) {
$stackedClass = "-space-x-{$stackedOverlap} rtl:space-x-reverse";
} else {
$stackedClass = 'gap-1';
}
// Size styles - only add if width/height specified
$sizeStyle = '';
if ($width) {
@@ -54,30 +54,19 @@
}
@endphp
<div
id="{{ $galleryId }}"
class="flex items-center {{ $stackedClass }}"
data-viewer-gallery
wire:ignore.self
>
@foreach($visibleUrls as $src)
<img
src="{{ $src }}"
loading="lazy"
<div id="{{ $galleryId }}" class="flex items-center {{ $stackedClass }}" data-viewer-gallery wire:ignore.self>
@foreach ($visibleUrls as $src)
<img src="{{ $src }}" loading="lazy"
class="object-cover {{ $borderColorClass }} {{ $borderRadiusClass }} hover:scale-110 transition cursor-pointer"
style="{{ $sizeStyle }} {{ $ringStyle }}"
alt="image"
/>
style="{{ $sizeStyle }} {{ $ringStyle }}" alt="image" />
@endforeach
@if($shouldShowRemainingText() && $remaining > 0 && $width)
@if ($shouldShowRemainingText() && $remaining > 0 && $width)
<span class="flex items-center justify-center text-xs font-medium text-gray-600 dark:text-gray-200"
style="width: {{ $width }}px; height: {{ $height ?? $width }}px; min-width: {{ $width }}px;">
style="width: {{ $width }}px; height: {{ $height ?? $width }}px; min-width: {{ $width }}px;">
+{{ $remaining }}
</span>
@endif
</div>
@once
<x-image-gallery::viewer-script />
@endonce
{{-- Viewer.js assets are loaded dynamically via image-gallery.js --}}

View File

@@ -12,31 +12,35 @@
@php
$galleryId = $id ?? 'gallery-' . str_replace(['{', '}', '-'], '', (string) \Illuminate\Support\Str::uuid());
$urls = collect($images)->map(function($item) {
if (is_string($item)) return $item;
if (is_array($item)) return $item['image'] ?? $item['url'] ?? null;
if (is_object($item)) return $item->image ?? $item->url ?? null;
return null;
})->filter()->values();
$urls = collect($images)
->map(function ($item) {
if (is_string($item)) {
return $item;
}
if (is_array($item)) {
return $item['image'] ?? ($item['url'] ?? null);
}
if (is_object($item)) {
return $item->image ?? ($item->url ?? null);
}
return null;
})
->filter()
->values();
$emptyTextDisplay = $emptyText ?? __('image-gallery::messages.empty');
@endphp
<div
id="{{ $galleryId }}"
<div id="{{ $galleryId }}"
class="image-gallery flex overflow-x-auto {{ $gap }} my-4 pb-2 select-none {{ $wrapperClass }}"
data-viewer-gallery
>
data-viewer-gallery>
@forelse($urls as $src)
<img
src="{{ $src }}"
loading="lazy"
<img src="{{ $src }}" loading="lazy"
class="{{ $rounded }} shadow object-cover border border-gray-200 dark:border-gray-700 hover:scale-105 transition {{ $zoomCursor ? 'cursor-zoom-in' : '' }}"
style="width: {{ (int) $thumbWidth }}px; height: {{ (int) $thumbHeight }}px; flex-shrink: 0;"
alt="image"
/>
alt="image" />
@empty
<span class="text-gray-400 dark:text-gray-500">{{ $emptyTextDisplay }}</span>
@endforelse
</div>
<x-image-gallery::viewer-script />
{{-- Viewer.js assets are loaded dynamically via image-gallery.js --}}

View File

@@ -1,125 +1,4 @@
@once
<!-- Viewer.js CDN assets -->
<link rel="stylesheet" href="https://unpkg.com/viewerjs@1.11.6/dist/viewer.min.css" />
<script src="https://unpkg.com/viewerjs@1.11.6/dist/viewer.min.js"></script>
<script>
(function() {
function initOne(el) {
if (!el || el._viewer || !window.Viewer) return;
el._viewer = new Viewer(el, {
toolbar: {
zoomIn: 1,
zoomOut: 1,
oneToOne: 1,
reset: 1,
prev: 1,
play: 0,
next: 1,
rotateLeft: 1,
rotateRight: 1,
flipHorizontal: 1,
flipVertical: 1,
},
navbar: false,
inline: false,
movable: true,
rotatable: true,
scalable: true,
fullscreen: true,
transition: true,
title: false,
});
}
function destroyOne(el) {
if (el && el._viewer) {
try {
el._viewer.destroy();
} catch(e) {}
el._viewer = null;
}
}
function scan() {
document.querySelectorAll('[data-viewer-gallery]').forEach(function(el) {
// Destroy and reinitialize to handle SPA navigation
destroyOne(el);
initOne(el);
});
}
// Run on various lifecycle events
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan);
} else {
// DOM is already ready
scan();
}
// Filament/Livewire 3.x SPA navigation
document.addEventListener('livewire:navigated', function() {
setTimeout(scan, 100);
});
// Livewire 3.x morph updates
document.addEventListener('livewire:init', function() {
if (window.Livewire) {
Livewire.hook('morph.updated', function({ el }) {
setTimeout(scan, 100);
});
}
});
// For Livewire 2.x compatibility
document.addEventListener('livewire:load', scan);
if (window.Livewire && window.Livewire.hook) {
try {
window.Livewire.hook('message.processed', function() {
setTimeout(scan, 100);
});
} catch (e) {}
}
// Turbolinks/Turbo compatibility
document.addEventListener('turbo:load', scan);
document.addEventListener('turbolinks:load', scan);
// Alpine.js x-init hook
document.addEventListener('alpine:init', function() {
Alpine.directive('image-gallery-init', function(el) {
initOne(el);
});
});
// MutationObserver for dynamic content
const observer = new MutationObserver(function(mutations) {
let shouldScan = false;
mutations.forEach(function(mutation) {
if (mutation.addedNodes.length) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) {
if (node.hasAttribute && node.hasAttribute('data-viewer-gallery')) {
shouldScan = true;
}
if (node.querySelectorAll) {
const galleries = node.querySelectorAll('[data-viewer-gallery]');
if (galleries.length) {
shouldScan = true;
}
}
}
});
}
});
if (shouldScan) {
setTimeout(scan, 100);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
})();
</script>
@endonce
{{--
Viewer.js assets are now loaded dynamically via image-gallery.js
This component is kept for backward compatibility but can be safely removed from blade files
--}}

View File

@@ -7,7 +7,7 @@
$zoomCursor = $hasZoomCursor();
$wrapperClass = $getWrapperClass() ?? '';
$galleryId = 'gallery-entry-' . str_replace(['{', '}', '-'], '', (string) \Illuminate\Support\Str::uuid());
// Size styles - only add if width/height specified
$sizeStyle = '';
if ($width) {
@@ -17,28 +17,20 @@
$sizeStyle .= " height: {$height}px;";
}
if ($width || $height) {
$sizeStyle .= " flex-shrink: 0;";
$sizeStyle .= ' flex-shrink: 0;';
}
@endphp
<x-dynamic-component :component="$getEntryWrapperView()" :entry="$entry">
<div
id="{{ $galleryId }}"
<div id="{{ $galleryId }}"
class="image-gallery flex overflow-x-auto {{ $gap }} my-2 pb-2 select-none {{ $wrapperClass }}"
data-viewer-gallery
>
@foreach($urls as $src)
<img
src="{{ $src }}"
loading="lazy"
data-viewer-gallery>
@foreach ($urls as $src)
<img src="{{ $src }}" loading="lazy"
class="{{ $rounded }} shadow object-cover border border-gray-200 dark:border-gray-700 hover:scale-105 transition cursor-pointer"
@if($sizeStyle) style="{{ $sizeStyle }}" @endif
alt="image"
/>
@if ($sizeStyle) style="{{ $sizeStyle }}" @endif alt="image" />
@endforeach
</div>
</x-dynamic-component>
@once
<x-image-gallery::viewer-script />
@endonce
{{-- Viewer.js assets are loaded dynamically via image-gallery.js --}}

View File

@@ -1,79 +1,66 @@
@php
$state = $getState();
if ($state instanceof \Illuminate\Support\Collection) {
$state = $state->all();
}
$state = \Illuminate\Support\Arr::wrap($state);
$limit = $getLimit();
$limitedState = $limit ? array_slice($state, 0, $limit) : $state;
$remaining = $limit ? max(0, count($state) - $limit) : 0;
$isCircular = $isCircular();
$isSquare = $isSquare();
$isStacked = $isStacked();
$overlap = $isStacked ? ($getOverlap() ?? 2) : null;
$overlap = $isStacked ? $getOverlap() ?? 2 : null;
$defaultWidth = $getWidth();
$defaultHeight = $getHeight();
$defaultWidth = $defaultWidth ? (is_numeric($defaultWidth) ? $defaultWidth . 'px' : $defaultWidth) : 'auto';
$defaultHeight = $defaultHeight ? (is_numeric($defaultHeight) ? $defaultHeight . 'px' : $defaultHeight) : '150px';
$galleryId = 'gallery-' . str_replace(['{', '}', '-'], '', (string) \Illuminate\Support\Str::uuid());
@endphp
<x-dynamic-component :component="$getEntryWrapperView()" :entry="$entry">
<div
id="{{ $galleryId }}"
{{
$attributes
->merge($getExtraAttributes(), escape: false)
->class([
'fi-in-image',
'flex items-center',
match ($overlap) {
1 => '-space-x-1 rtl:space-x-reverse',
2 => '-space-x-2 rtl:space-x-reverse',
3 => '-space-x-3 rtl:space-x-reverse',
4 => '-space-x-4 rtl:space-x-reverse',
5 => '-space-x-5 rtl:space-x-reverse',
6 => '-space-x-6 rtl:space-x-reverse',
7 => '-space-x-7 rtl:space-x-reverse',
8 => '-space-x-8 rtl:space-x-reverse',
default => 'gap-1.5',
},
])
}}
data-viewer-gallery
wire:ignore.self
>
<div id="{{ $galleryId }}"
{{ $attributes->merge($getExtraAttributes(), escape: false)->class([
'fi-in-image',
'flex items-center',
match ($overlap) {
1 => '-space-x-1 rtl:space-x-reverse',
2 => '-space-x-2 rtl:space-x-reverse',
3 => '-space-x-3 rtl:space-x-reverse',
4 => '-space-x-4 rtl:space-x-reverse',
5 => '-space-x-5 rtl:space-x-reverse',
6 => '-space-x-6 rtl:space-x-reverse',
7 => '-space-x-7 rtl:space-x-reverse',
8 => '-space-x-8 rtl:space-x-reverse',
default => 'gap-1.5',
},
]) }}
data-viewer-gallery wire:ignore.self>
@foreach ($limitedState as $stateItem)
<img
src="{{ $getImageUrl($stateItem) }}"
<img src="{{ $getImageUrl($stateItem) }}"
style="
height: {{ $defaultHeight }};
width: {{ $defaultWidth }};
cursor: pointer;
"
{{
$getExtraImgAttributeBag()
->class([
'max-w-none object-cover object-center',
'rounded-full' => $isCircular,
'rounded-lg' => $isSquare,
'ring-white dark:ring-gray-900' => $isStacked,
'ring-2' => $isStacked && ($overlap === null || $overlap > 0),
])
}}
/>
{{ $getExtraImgAttributeBag()->class([
'max-w-none object-cover object-center',
'rounded-full' => $isCircular,
'rounded-lg' => $isSquare,
'ring-white dark:ring-gray-900' => $isStacked,
'ring-2' => $isStacked && ($overlap === null || $overlap > 0),
]) }} />
@endforeach
@if ($remaining > 0 && ($limitedRemainingText ?? true))
<div
style="
<div style="
min-height: {{ $defaultHeight }};
min-width: {{ $defaultWidth }};
height: {{ $defaultHeight }};
@@ -84,8 +71,7 @@
'rounded-full' => $isCircular,
'rounded-lg' => $isSquare,
'ring-2' => $isStacked && ($overlap === null || $overlap > 0),
])
>
])>
<span class="-ms-0.5 text-xs">
+{{ $remaining }}
</span>
@@ -93,7 +79,5 @@
@endif
</div>
@once
<x-image-gallery::viewer-script />
@endonce
{{-- Viewer.js assets are loaded dynamically via image-gallery.js --}}
</x-dynamic-component>