Files
2024-12-31 11:07:09 +01:00

1095 lines
26 KiB
PHP

<?php
/**
* @author Tassos Marinos <info@tassos.gr>
* @link https://www.tassos.gr
* @copyright Copyright © 2024 Tassos All Rights Reserved
* @license GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> or later
*/
namespace NRFramework\Widgets;
defined('_JEXEC') or die;
use Joomla\CMS\Language\Text;
use \NRFramework\Helpers\Widgets\Gallery as GalleryHelper;
use NRFramework\Mimes;
use NRFramework\File;
use NRFramework\Image;
/**
* Gallery
*/
class Gallery extends Widget
{
/**
* Widget default options
*
* @var array
*/
protected $widget_options = [
/**
* The gallery items source.
*
* This can be one or combination of the following:
*
* - Path to a relative folder (String)
* /path/to/folder
* - Path to a relative image (String)
* /path/to/folder/image.png
* - URL of an image (String)
* https://example.com/path/to/image.png
* - Array of images (Array)
* [
* 'url' => 'https://example.com/path/to/image.png',
* 'thumbnail_url' => 'https://example.com/path/to/image_thumb.png',
* 'caption' => 'This is a caption',
* 'thumbnail_size' => [
* 'width' => '200',
* 'height' => '200'
* ],
* 'module' => 'position-2'
* ]
*
* - The `url` property is required.
* - All other properties are optional.
*/
'items' => [],
/**
* Set the ordering.
*
* Available values:
* - default
* - alphabetical
* - reverse_alphabetical
* - random
*/
'ordering' => 'default',
// Set the module key to display whenever we are viewing a single item's lightbox, appearing after the image
'module' => '',
/**
* Set the style of the gallery:
*
* - masonry
* - grid
* - justified
*/
'style' => 'masonry',
// Each item height (in pixels) in Justified layout
'justified_item_height' => null,
/**
* Define the columns per supported device.
*
* Example value:
* - An integer representing the columns for all devices: 3
* - A value for each device:
* [
* 'desktop' => 3,
* 'tablet' => 2,
* 'mobile' => 1
* ]
*/
'columns' => 4,
/**
* Define the gap per gallery item per supported device.
*
* Example value:
* - An integer representing the gap for all devices: 30
* - A value for each device:
* [
* 'desktop' => 30,
* 'tablet' => 20,
* 'mobile' => 10
* ]
*/
'gap' => 15,
/**
* Set the allowed file types.
*
* This is used to validate the files loaded via a directory or a fixed path to an image.
*
* Given URLs are not validated by this setting.
*/
'allowed_file_types' => '.jpg, .jpeg, .png',
// Gallery Items wrapper CSS classes
'gallery_items_css' => '',
// Set whether to display a lightbox
'lightbox' => false,
/**
* Source Image
*/
/**
* Should the source image be resized?
*
* If `original_image_resize` is false, then the source image will appear
* in the lightbox (also if `thumbnails` is false, the source image will also appear as the thumbnail)
*
* Issue: if this image is a raw photo, there are chances it will increase the page load in order for the browser to display the image.
*
* By enabling this, we resize the source image to our desired dimensions and reduce the page load in the above scenario.
*
* Note: Always ensure the source image is backed up to a safe place.
* Note 2: We require thumbnails or original image resize to be enabled for this to work.
* Reason: The above options if enabled generate the gallery_info.txt file in the /cache folder which helps us
* generate the source images only if necessary(image has been edited), otherwise, the source image would
* be generated on each page refresh.
*/
'source_image_resize' => false,
// Source image resize width
'source_image_resize_width' => 1920,
// Source image resize height
'source_image_resize_height' => null,
// Source image resize method (crop, stretch, fit)
'source_image_resize_method' => 'crop',
// Source image resize quality
'source_image_resize_image_quality' => 80,
/**
* Original Image
*/
// Should the original uploaded image be resized?
'original_image_resize' => false,
// Resize method (crop, stretch, fit)
'original_image_resize_method' => 'crop',
/**
* Original Image Resize Width.
*
* If `original_image_resize_height` is null, resizes via the width to keep the aspect ratio.
*/
'original_image_resize_width' => 1920,
// Original Image Resize Height
'original_image_resize_height' => null,
// Original Image Resize Quality
'original_image_resize_image_quality' => 80,
/**
* Thumbnails
*/
// Set whether to generate thumbnails on-the-fly
'thumbnails' => false,
// Resize method (crop, stretch, fit)
'thumb_resize_method' => 'crop',
// Thumbnails width
'thumb_width' => 300,
// Thumbnails height
'thumb_height' => null,
// The CSS class of the thumbnail
'thumb_class' => '',
/**
* Set whether to resize the images whenever their source file changes.
*
* i.e. If we edit the source image and also need to recreate the resized original image or thumbnail.
* This is rather useful otherwise we would have to delete the resized image or thumbnail in order for it to be recreated.
*/
'force_resizing' => false,
// Destination folder
'destination_folder' => 'cache/tassos/gallery',
// Attributes set to the wrapper
'atts' => '',
// The unique hash of this gallery based on its options
'hash' => null,
// Set whether to show warnings when an image that has been set to appear does not exist.
'show_warnings' => true,
/**
* This is a list that's populated
* automatically by looking for tags
* in each gallery item.
*/
'tags' => [],
/**
* Set the tags position.
*
* Available values:
* - disabled (No tags will appear in the gallery)
* - above
* - below
*/
'tags_position' => 'disabled',
/**
* Set the tags ordering.
*
* Available values:
* - default
* - alphabetical
* - reverse_alphabetical
* - random
*/
'tags_ordering' => 'default',
// Set the label of the "All Tags" option
'all_tags_item_label' => 'All',
/**
* Set whether to show the tags filter on mobile devices,
* show them as a dropdown or disable them.
*
* Available values:
* - show
* - dropdown
* - disabled
*/
'tags_mobile' => 'show',
'tags_text_color' => '#555',
'tags_text_color_hover' => '#fff',
'tags_bg_color_hover' => '#1E3148',
// Widget Custom CSS
'custom_css' => ''
];
public function __construct($options = [])
{
parent::__construct($options);
$this->prepare();
}
/**
* Prepares the Gallery.
*
* @return void
*/
private function prepare()
{
$this->options['hash'] = $this->getHash();
$this->options['destination_folder'] = JPATH_ROOT . DIRECTORY_SEPARATOR . $this->options['destination_folder'] . DIRECTORY_SEPARATOR . $this->options['hash'] . DIRECTORY_SEPARATOR;
$this->parseGalleryItems();
$this->cleanDestinationFolder();
$this->resizeSourceImages();
$this->resizeOriginalImages();
$this->createThumbnails();
// Set style on the gallery items container.
$this->options['gallery_items_css'] .= ' ' . $this->getStyle();
// Set class to trigger lightbox.
if ($this->options['lightbox'])
{
$this->options['css_class'] .= ' lightbox';
}
$this->setAtts();
$this->prepareItems();
$this->setOrdering();
if ($this->options['load_css_vars'])
{
$this->options['custom_css'] = $this->getWidgetCSS();
}
$this->prepareTags();
}
/**
* Sets the data attributes.
*
* @return void
*/
private function setAtts()
{
$atts = [];
$atts[] = 'data-id="' . $this->options['id'] . '"';
if ($this->options['style'] === 'justified' && $this->options['justified_item_height'])
{
$atts[] = 'data-item-height="' . $this->options['justified_item_height'] . '"';
}
$this->options['atts'] = implode(' ', $atts);
}
/**
* Sets the ordering of the gallery.
*
* @return void
*/
private function setOrdering()
{
switch ($this->options['ordering']) {
case 'random':
shuffle($this->options['items']);
break;
case 'alphabetical':
usort($this->options['items'], [$this, 'compareByThumbnailASC']);
break;
case 'reverse_alphabetical':
usort($this->options['items'], [$this, 'compareByThumbnailDESC']);
break;
}
}
/**
* Compares tag names in ASC order
*
* @param array $a
* @param array $b
*
* @return bool
*/
public function compareByTagNameASC($a, $b)
{
return strcmp($a, $b);
}
/**
* Compares tag names in DESC order
*
* @param array $a
* @param array $b
*
* @return bool
*/
public function compareByTagNameDESC($a, $b)
{
return strcmp($b, $a);
}
/**
* Compares thumbnail file names in ASC order
*
* @param array $a
* @param array $b
*
* @return bool
*/
public function compareByThumbnailASC($a, $b)
{
return strcmp(basename($a['thumbnail']), basename($b['thumbnail']));
}
/**
* Compares thumbnail file names in DESC order
*
* @param array $a
* @param array $b
*
* @return bool
*/
public function compareByThumbnailDESC($a, $b)
{
return strcmp(basename($b['thumbnail']), basename($a['thumbnail']));
}
/**
* Get the hash of this gallery.
*
* Generate the hash with only the essential options of the Gallery widget.
* i.e. with the data that are related to the images.
*
* @return string
*/
private function getHash()
{
$opts = [
'items',
'style',
'allowed_file_types',
'source_image_resize',
'source_image_resize_width',
'source_image_resize_height',
'source_image_resize_method',
'source_image_resize_image_quality',
'original_image_resize',
'original_image_resize_method',
'original_image_resize_width',
'original_image_resize_height',
'original_image_resize_image_quality',
'thumbnails',
'thumb_resize_method',
'thumb_width',
'thumb_height',
'force_resizing',
'destination_folder'
];
$payload = [];
foreach ($opts as $opt)
{
$payload[$opt] = $this->options[$opt];
}
return md5(serialize($payload));
}
/**
* Cleans the source folder.
*
* If an image from the source folder is removed, we also remove the
* original image/thumbnail from the destination folder as well as
* from the gallery info file.
*
* @return void
*/
private function cleanDestinationFolder()
{
if (!$this->options['original_image_resize'] && !$this->options['thumbnails'])
{
return;
}
// Find all folders that we need to search
$dirs_to_search = [];
// Store all source files
$source_files = [];
foreach ($this->options['items'] as $key => $item)
{
if (!isset($item['path']))
{
continue;
}
$source_files[] = pathinfo($item['path'], PATHINFO_BASENAME);
$directory = is_dir($item['path']) ? $item['path'] : dirname($item['path']);
if (in_array($directory, $dirs_to_search))
{
continue;
}
$dirs_to_search[] = $directory;
}
if (empty($dirs_to_search))
{
return;
}
// Loop each directory found and check which files we need to delete
foreach ($dirs_to_search as $dir)
{
$source_folder_info_file = GalleryHelper::getGalleryInfoFileData($dir);
// Find all soon to be deleted files
$to_be_deleted = array_diff(array_keys($source_folder_info_file), $source_files);
if (!count($to_be_deleted))
{
continue;
}
foreach ($to_be_deleted as $source)
{
// Original image delete
if (isset($source_folder_info_file[$source]))
{
$file = $this->options['destination_folder'] . $source_folder_info_file[$source]['filename'];
if (file_exists($file))
{
unlink($file);
}
}
// Thumbnail delete
$parts = pathinfo($file);
$thumbnail = $this->options['destination_folder'] . $parts['filename'] . '_thumb.' . $parts['extension'];
if (file_exists($thumbnail))
{
unlink($thumbnail);
}
// Also remove the image from the gallery info file.
GalleryHelper::removeImageFromGalleryInfoFile(rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $source);
}
}
}
/**
* Returns the gallery style.
*
* @return string
*/
private function getStyle()
{
$style = $this->options['style'];
if ($style === 'justified')
{
return $style;
}
// Get aspect ratio for source image, original image resized and thumbnail
$thumb_height = intval($this->options['thumb_height']);
$thumb_aspect_ratio = $thumb_height ? intval($this->options['thumb_width']) / $thumb_height : 0;
$source_image_height = intval($this->options['source_image_resize_height']);
$source_image_aspect_ratio = $source_image_height ? intval($this->options['source_image_resize_width']) / $source_image_height : 0;
$original_image_height = intval($this->options['original_image_resize_height']);
$original_image_aspect_ratio = $original_image_height ? intval($this->options['original_image_resize_width']) / $original_image_height : 0;
// Check whether the aspect ratio for thumb and lightbox image are the same and use `masonry` style
$checking_aspect_ratio = $this->options['original_image_resize'] ? $original_image_aspect_ratio : $source_image_aspect_ratio;
if ($thumb_aspect_ratio && $checking_aspect_ratio && $thumb_aspect_ratio === $checking_aspect_ratio)
{
return 'masonry';
}
/**
* If both thumbnail width & height are equal we use the `grid` style.
*/
if ($this->options['thumb_width'] === $this->options['thumb_height'])
{
$style = 'grid';
}
/**
* If the style is grid and we do not have a null or 0 thumb_height set the fade lightbox CSS Class.
*
* This CSS Class tells PhotoSwipe to use the fade transition.
*/
if ($style === 'grid' && (!is_null($this->options['thumb_height']) && $this->options['thumb_height'] !== '0'))
{
$this->options['css_class'] .= ' lightbox-fade';
}
return $style;
}
/**
* Prepare the tags.
*
* @return void
*/
private function prepareTags()
{
if ($this->options['tags_position'] === 'disabled')
{
return;
}
if (!is_array($this->options['items']))
{
return;
}
if ($this->options['all_tags_item_label'])
{
$this->options['all_tags_item_label'] = Text::_($this->options['all_tags_item_label']);
}
$tags = $this->options['tags'];
if (count($tags) === 0)
{
foreach ($this->options['items'] as $key => &$item)
{
if (!isset($item['tags']))
{
continue;
}
if (!is_array($item['tags']))
{
continue;
}
$tags = array_merge($tags, $item['tags']);
}
$tags = array_unique($tags);
}
// Sort tags
switch ($this->options['tags_ordering'])
{
case 'random':
shuffle($tags);
break;
case 'alphabetical':
usort($tags, [$this, 'compareByTagNameASC']);
break;
case 'reverse_alphabetical':
usort($tags, [$this, 'compareByTagNameDESC']);
break;
}
$this->options['tags'] = $tags;
}
/**
* Parses the gallery items by finding all iamges to display from all
* different sources.
*
* @return void
*/
private function parseGalleryItems()
{
// If it's a string, we assume its a path to a folder and we convert it to an array.
$this->options['items'] = (array) $this->options['items'];
$items = [];
foreach ($this->options['items'] as $key => $value)
{
if (!$data = GalleryHelper::parseGalleryItems($value, $this->getAllowedFileTypes()))
{
continue;
}
$items = array_merge($items, $data);
}
// Ensure only unique image paths are used
$items = array_unique($items, SORT_REGULAR);
$this->options['items'] = $items;
}
/**
* Returns the allowed file types in an array format.
*
* @return array
*/
public function getAllowedFileTypes()
{
$types = explode(',', $this->options['allowed_file_types']);
$types = array_filter(array_map('trim', array_map('strtolower', $types)));
return $types;
}
/**
* Resizes the source images.
*
* @return mixed
*/
private function resizeSourceImages()
{
if (!$this->options['source_image_resize'])
{
return;
}
// We require either original image resize or thumbnails to be enabled
if (!$this->options['original_image_resize'] && !$this->options['thumbnails'])
{
return;
}
foreach ($this->options['items'] as $key => &$item)
{
if (!isset($item['path']))
{
continue;
}
// Skip if source does not exist
if (!is_file($item['path']))
{
continue;
}
$source = $item['path'];
// Find source image in the destination folder
if ($image_data = GalleryHelper::findSourceImageDetails($source, $this->options['destination_folder']))
{
// If force resizing is disabled, continue
if (!$this->options['force_resizing'])
{
continue;
}
else
{
// If the destination image has not been edited and exists, abort
if (!$image_data['edited'] && file_exists($image_data['path']))
{
continue;
}
}
}
if (is_null($this->options['source_image_resize_height']))
{
Image::resizeAndKeepAspectRatio(
$source,
$this->options['source_image_resize_width'],
$this->options['source_image_resize_image_quality']
);
}
else
{
Image::resize(
$source,
$this->options['source_image_resize_width'],
$this->options['source_image_resize_height'],
$this->options['source_image_resize_image_quality'],
$this->options['source_image_resize_method']
);
}
}
}
/**
* Resizes the original images.
*
* @return mixed
*/
private function resizeOriginalImages()
{
if (!$this->options['original_image_resize'])
{
return;
}
// Create destination folder if missing
File::createDirs($this->options['destination_folder']);
foreach ($this->options['items'] as $key => &$item)
{
if (!isset($item['path']))
{
continue;
}
// Skip if source does not exist
if (!is_file($item['path']))
{
continue;
}
$source = $item['path'];
$unique = true;
// Path to resized image in destination folder
$destination = $this->options['destination_folder'] . basename($source);
// Find source image in the destination folder
if ($image_data = GalleryHelper::findSourceImageDetails($source, $this->options['destination_folder']))
{
// If force resizing is disabled and the original image exists, set the URL of the destination image
if (!$this->options['force_resizing'] && file_exists($image_data['path']))
{
$item['url'] = GalleryHelper::directoryImageToURL($image_data['path']);
continue;
}
else
{
// If the destination image has not been edited and exists, abort
if (!$image_data['edited'] && file_exists($image_data['path']))
{
$item['url'] = GalleryHelper::directoryImageToURL($image_data['path']);
continue;
}
// Since we are forcing resizing, overwrite the existing image, do not create a new unique image
$unique = false;
// The destination path is the same resized image
$destination = $image_data['path'];
}
}
$original_image_file = is_null($this->options['original_image_resize_height'])
?
Image::resizeAndKeepAspectRatio(
$source,
$this->options['original_image_resize_width'],
$this->options['original_image_resize_image_quality'],
$destination,
$unique
)
:
Image::resize(
$source,
$this->options['original_image_resize_width'],
$this->options['original_image_resize_height'],
$this->options['original_image_resize_image_quality'],
$this->options['original_image_resize_method'],
$destination,
$unique
);
if (!$original_image_file)
{
continue;
}
// Set image URL
$item = array_merge($item, [
'url' => GalleryHelper::directoryImageToURL($original_image_file)
]);
// Update image data in Gallery Info File
GalleryHelper::updateImageDataInGalleryInfoFile($source, $item);
}
}
/**
* Creates thumbnails.
*
* If `force_resizing` is enabled, it will re-generate thumbnails under the following cases:
*
* - If a thumbnail does not exist.
* - If the original image has been edited.
*
* @return mixed
*/
private function createThumbnails()
{
if (!$this->options['thumbnails'])
{
return false;
}
// Create destination folder if missing
File::createDirs($this->options['destination_folder']);
foreach ($this->options['items'] as $key => &$item)
{
// Skip items that do not have a path set
if (!isset($item['path']))
{
continue;
}
// Skip if source does not exist
if (!is_file($item['path']))
{
continue;
}
$source = $item['path'];
$unique = true;
$parts = pathinfo($source);
$destination = $this->options['destination_folder'] . $parts['filename'] . '_thumb.' . $parts['extension'];
// Find source image in the destination folder
if ($image_data = GalleryHelper::findSourceImageDetails($source, $this->options['destination_folder']))
{
/**
* Use the found original image path to produce the thumb file path.
*
* This is used as we have multiple files with the same which produce file names of _copy_X
* and thus the above $destination will not be valid. Instead, we use the original file name
* to find the thumbnail file.
*/
if ($this->options['original_image_resize'])
{
$parts = pathinfo($image_data['path']);
$destination = $this->options['destination_folder'] . $parts['filename'] . '_thumb.' . $parts['extension'];
}
// If force resizing is disabled and the thumbnail exists, set the URL of the destination image
if (!$this->options['force_resizing'] && file_exists($destination))
{
$item['thumbnail_url'] = GalleryHelper::directoryImageToURL($destination);
continue;
}
else
{
// If the destination image has not been edited and exists, abort
if (!$image_data['edited'] && file_exists($destination))
{
$item['thumbnail_url'] = GalleryHelper::directoryImageToURL($destination);
continue;
}
// Since we are forcing resizing, overwrite the existing image, do not create a new unique image
$unique = false;
}
}
// Generate thumbnails
$thumb_file = is_null($this->options['thumb_height'])
?
Image::resizeAndKeepAspectRatio(
$source,
$this->options['thumb_width'],
100,
$destination,
$unique,
true,
'resize'
)
:
Image::resize(
$source,
$this->options['thumb_width'],
$this->options['thumb_height'],
100,
$this->options['thumb_resize_method'],
$destination,
$unique,
true,
'resize'
);
if (!$thumb_file)
{
continue;
}
// Set image thumbnail URL
$item = array_merge($item, [
'thumbnail_url' => GalleryHelper::directoryImageToURL($thumb_file)
]);
// Update image data in Gallery Info File
GalleryHelper::updateImageDataInGalleryInfoFile($source, $item);
}
}
/**
* Prepares the items.
*
* - Sets the thumbnails image dimensions.
* - Assures caption property exist.
*
* @return mixed
*/
private function prepareItems()
{
if (!is_array($this->options['items']) || !count($this->options['items']))
{
return;
}
$smartTagsInstance = \NRFramework\SmartTags::getInstance();
foreach ($this->options['items'] as $key => &$item)
{
// Initialize image atts
$item['img_atts'] = '';
// Initializes caption if none given
if (!isset($item['caption']))
{
$item['caption'] = '';
}
if (!isset($item['alt']) || empty($item['alt']))
{
$item['alt'] = !empty($item['caption']) ? mb_substr($item['caption'], 0, 100) : pathinfo($item['url'], PATHINFO_FILENAME);
}
// Replace Smart Tags in alt
$item['alt'] = $smartTagsInstance->replace($item['alt']);
if ($item['caption'])
{
$item['caption'] = $smartTagsInstance->replace($item['caption']);
}
// Ensure a thumbnail is given
if (!isset($item['thumbnail_url']))
{
// If no thumbnail is given, set it to the full image
$item['thumbnail_url'] = $item['url'];
continue;
}
// If the thumbnail size for this item is given, set the image attributes
if (isset($item['thumbnail_size']))
{
$item['img_atts'] = 'width="' . $item['thumbnail_size']['width'] . '" height="' . $item['thumbnail_size']['height'] . '"';
continue;
}
}
}
/**
* Returns the CSS for the widget.
*
* @param array $exclude_breakpoints Define breakpoints to exclude their CSS
*
* @return string
*/
public function getWidgetCSS($exclude_breakpoints = [])
{
$controls = [
[
'property' => '--gap',
'value' => $this->options['gap'],
'unit' => 'px'
],
[
'property' => '--tags-text-color',
'value' => $this->options['tags_text_color']
],
[
'property' => '--tags-text-color-hover',
'value' => $this->options['tags_text_color_hover']
],
[
'property' => '--tags-bg-color-hover',
'value' => $this->options['tags_bg_color_hover']
]
];
if ($this->options['style'] !== 'justified')
{
$controls[] = [
'property' => [
'--columns' => '%value_raw%',
'--display-items' => 'grid',
'--image-width' => '100%'
],
'fallback_value' => [
'--display-items' => 'flex',
'--display-items-flex-wrap' => 'wrap',
'--image-width' => 'auto'
],
'value' => $this->options['columns'],
];
}
$selector = '.nrf-widget.' . $this->options['id'];
$controlsInstance = new \NRFramework\Controls\Controls(null, $selector, $exclude_breakpoints);
if (!$controlsCSS = $controlsInstance->generateCSS($controls))
{
return;
}
return $controlsCSS;
}
}