* @link https://www.tassos.gr * @copyright Copyright © 2024 Tassos All Rights Reserved * @license GNU GPLv3 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; } }