diff --git a/src/Content/Image.php b/src/Content/Image.php new file mode 100644 index 0000000000..cc2fd5122c --- /dev/null +++ b/src/Content/Image.php @@ -0,0 +1,154 @@ +. + * + */ + +namespace Friendica\Content; + +use Friendica\Content\Image\Collection\MasonryImageRow; +use Friendica\Content\Image\Entity\MasonryImage; +use Friendica\Content\Post\Collection\PostMedias; +use Friendica\Core\Renderer; + +class Image +{ + public static function getBodyAttachHtml(PostMedias $PostMediaImages): string + { + $media = ''; + + if ($PostMediaImages->haveDimensions()) { + if (count($PostMediaImages) > 1) { + $media = self::getHorizontalMasonryHtml($PostMediaImages); + } elseif (count($PostMediaImages) == 1) { + $media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/single_with_height_allocation.tpl'), [ + '$image' => $PostMediaImages[0], + '$allocated_height' => $PostMediaImages[0]->getAllocatedHeight(), + '$allocated_max_width' => ($PostMediaImages[0]->previewWidth ?? $PostMediaImages[0]->width) . 'px', + ]); + } + } else { + if (count($PostMediaImages) > 1) { + $media = self::getImageGridHtml($PostMediaImages); + } elseif (count($PostMediaImages) == 1) { + $media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/single.tpl'), [ + '$image' => $PostMediaImages[0], + ]); + } + } + + return $media; + } + + /** + * @param PostMedias $images + * @return string + * @throws \Friendica\Network\HTTPException\ServiceUnavailableException + */ + private static function getImageGridHtml(PostMedias $images): string + { + // Image for first column (fc) and second column (sc) + $images_fc = []; + $images_sc = []; + + for ($i = 0; $i < count($images); $i++) { + ($i % 2 == 0) ? ($images_fc[] = $images[$i]) : ($images_sc[] = $images[$i]); + } + + return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/grid.tpl'), [ + 'columns' => [ + 'fc' => $images_fc, + 'sc' => $images_sc, + ], + ]); + } + + /** + * Creates a horizontally masoned gallery with a fixed maximum number of pictures per row. + * + * For each row, we calculate how much of the total width each picture will take depending on their aspect ratio + * and how much relative height it needs to accomodate all pictures next to each other with their height normalized. + * + * @param array $images + * @return string + * @throws \Friendica\Network\HTTPException\ServiceUnavailableException + */ + private static function getHorizontalMasonryHtml(PostMedias $images): string + { + static $column_size = 2; + + $rows = array_map( + function (PostMedias $PostMediaImages) { + if ($singleImageInRow = count($PostMediaImages) == 1) { + $PostMediaImages[] = $PostMediaImages[0]; + } + + $widths = []; + $heights = []; + foreach ($PostMediaImages as $PostMediaImage) { + if ($PostMediaImage->width && $PostMediaImage->height) { + $widths[] = $PostMediaImage->width; + $heights[] = $PostMediaImage->height; + } else { + $widths[] = $PostMediaImage->previewWidth; + $heights[] = $PostMediaImage->previewHeight; + } + } + + $maxHeight = max($heights); + + // Corrected width preserving aspect ratio when all images on a row are the same height + $correctedWidths = []; + foreach ($widths as $i => $width) { + $correctedWidths[] = $width * $maxHeight / $heights[$i]; + } + + $totalWidth = array_sum($correctedWidths); + + $row_images2 = []; + + if ($singleImageInRow) { + unset($PostMediaImages[1]); + } + + foreach ($PostMediaImages as $i => $PostMediaImage) { + $row_images2[] = new MasonryImage( + $PostMediaImage->uriId, + $PostMediaImage->url, + $PostMediaImage->preview, + $PostMediaImage->description ?? '', + 100 * $correctedWidths[$i] / $totalWidth, + 100 * $maxHeight / $correctedWidths[$i] + ); + } + + // This magic value will stay constant for each image of any given row and is ultimately + // used to determine the height of the row container relative to the available width. + $commonHeightRatio = 100 * $correctedWidths[0] / $totalWidth / ($widths[0] / $heights[0]); + + return new MasonryImageRow($row_images2, count($row_images2), $commonHeightRatio); + }, + $images->chunk($column_size) + ); + + return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/horizontal_masonry.tpl'), [ + '$rows' => $rows, + '$column_size' => $column_size, + ]); + } +} diff --git a/src/Content/Image/Collection/MasonryImageRow.php b/src/Content/Image/Collection/MasonryImageRow.php new file mode 100644 index 0000000000..ff507786f5 --- /dev/null +++ b/src/Content/Image/Collection/MasonryImageRow.php @@ -0,0 +1,57 @@ +. + * + */ + +namespace Friendica\Content\Image\Collection; + +use Friendica\Content\Image\Entity; +use Friendica\BaseCollection; +use Friendica\Content\Image\Entity\MasonryImage; + +class MasonryImageRow extends BaseCollection +{ + /** @var ?float */ + protected $heightRatio; + + /** + * @param MasonryImage[] $entities + * @param int|null $totalCount + * @param float|null $heightRatio + */ + public function __construct(array $entities = [], int $totalCount = null, float $heightRatio = null) + { + parent::__construct($entities, $totalCount); + + $this->heightRatio = $heightRatio; + } + + /** + * @return Entity\MasonryImage + */ + public function current(): Entity\MasonryImage + { + return parent::current(); + } + + public function getHeightRatio(): ?float + { + return $this->heightRatio; + } +} diff --git a/src/Content/Image/Entity/MasonryImage.php b/src/Content/Image/Entity/MasonryImage.php new file mode 100644 index 0000000000..e85688ea25 --- /dev/null +++ b/src/Content/Image/Entity/MasonryImage.php @@ -0,0 +1,60 @@ +. + * + */ + +namespace Friendica\Content\Image\Entity; + +use Friendica\BaseEntity; +use Psr\Http\Message\UriInterface; + +/** + * @property-read int $uriId + * @property-read UriInterface $url + * @property-read ?UriInterface $preview + * @property-read string $description + * @property-read float $heightRatio + * @property-read float $widthRatio + * @see \Friendica\Content\Image::getHorizontalMasonryHtml() + */ +class MasonryImage extends BaseEntity +{ + /** @var int */ + protected $uriId; + /** @var UriInterface */ + protected $url; + /** @var ?UriInterface */ + protected $preview; + /** @var string */ + protected $description; + /** @var float Ratio of the width of the image relative to the total width of the images on the row */ + protected $widthRatio; + /** @var float Ratio of the height of the image relative to its width for height allocation */ + protected $heightRatio; + + public function __construct(int $uriId, UriInterface $url, ?UriInterface $preview, string $description, float $widthRatio, float $heightRatio) + { + $this->url = $url; + $this->uriId = $uriId; + $this->preview = $preview; + $this->description = $description; + $this->widthRatio = $widthRatio; + $this->heightRatio = $heightRatio; + } +} diff --git a/src/Content/Post/Collection/PostMedias.php b/src/Content/Post/Collection/PostMedias.php index 5e75d908a7..9f7d10d0ce 100644 --- a/src/Content/Post/Collection/PostMedias.php +++ b/src/Content/Post/Collection/PostMedias.php @@ -42,4 +42,16 @@ class PostMedias extends BaseCollection { return parent::current(); } + + /** + * Determine whether all the collection's item have at least one set of dimensions provided + * + * @return bool + */ + public function haveDimensions(): bool + { + return array_reduce($this->getArrayCopy(), function (bool $carry, Entity\PostMedia $item) { + return $carry && $item->hasDimensions(); + }, true); + } } diff --git a/src/Content/Post/Entity/PostMedia.php b/src/Content/Post/Entity/PostMedia.php index e03246315c..8220624198 100644 --- a/src/Content/Post/Entity/PostMedia.php +++ b/src/Content/Post/Entity/PostMedia.php @@ -188,6 +188,30 @@ class PostMedia extends BaseEntity } + /** + * Computes the allocated height value used in the content/image/single_with_height_allocation.tpl template + * + * Either base or preview dimensions need to be set at runtime. + * + * @return string + */ + public function getAllocatedHeight(): string + { + if (!$this->hasDimensions()) { + throw new \RangeException('Either width and height or previewWidth and previewHeight must be defined to use this method.'); + } + + if ($this->width && $this->height) { + $width = $this->width; + $height = $this->height; + } else { + $width = $this->previewWidth; + $height = $this->previewHeight; + } + + return (100 * $height / $width) . '%'; + } + /** * Return a new PostMedia entity with a different preview URI and an optional proxy size name. * The new entity preview's width and height are rescaled according to the provided size. @@ -263,4 +287,14 @@ class PostMedia extends BaseEntity $this->id, ); } + + /** + * Checks the media has at least one full set of dimensions, needed for the height allocation feature + * + * @return bool + */ + public function hasDimensions(): bool + { + return $this->width && $this->height || $this->previewWidth && $this->previewHeight; + } } diff --git a/src/Model/Item.php b/src/Model/Item.php index 53183f1d2f..457d76ec78 100644 --- a/src/Model/Item.php +++ b/src/Model/Item.php @@ -22,6 +22,7 @@ namespace Friendica\Model; use Friendica\Contact\LocalRelationship\Entity\LocalRelationship; +use Friendica\Content\Image; use Friendica\Content\Post\Collection\PostMedias; use Friendica\Content\Post\Entity\PostMedia; use Friendica\Content\Text\BBCode; @@ -3241,7 +3242,7 @@ class Item } if (!empty($sharedSplitAttachments)) { - $s = self::addGallery($s, $sharedSplitAttachments['visual'], $item['uri-id']); + $s = self::addGallery($s, $sharedSplitAttachments['visual']); $s = self::addVisualAttachments($sharedSplitAttachments['visual'], $shared_item, $s, true); $s = self::addLinkAttachment($shared_uri_id ?: $item['uri-id'], $sharedSplitAttachments, $body, $s, true, $quote_shared_links); $s = self::addNonVisualAttachments($sharedSplitAttachments['additional'], $item, $s, true); @@ -3254,7 +3255,7 @@ class Item $s = substr($s, 0, $pos); } - $s = self::addGallery($s, $itemSplitAttachments['visual'], $item['uri-id']); + $s = self::addGallery($s, $itemSplitAttachments['visual']); $s = self::addVisualAttachments($itemSplitAttachments['visual'], $item, $s, false); $s = self::addLinkAttachment($item['uri-id'], $itemSplitAttachments, $body, $s, false, $shared_links); $s = self::addNonVisualAttachments($itemSplitAttachments['additional'], $item, $s, false); @@ -3285,45 +3286,32 @@ class Item return $hook_data['html']; } - /** - * @param PostMedias $images - * @return string - * @throws \Friendica\Network\HTTPException\ServiceUnavailableException - */ - private static function makeImageGrid(PostMedias $images): string - { - // Image for first column (fc) and second column (sc) - $images_fc = []; - $images_sc = []; - - for ($i = 0; $i < count($images); $i++) { - ($i % 2 == 0) ? ($images_fc[] = $images[$i]) : ($images_sc[] = $images[$i]); - } - - return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image_grid.tpl'), [ - 'columns' => [ - 'fc' => $images_fc, - 'sc' => $images_sc, - ], - ]); - } - /** * Modify links to pictures to links for the "Fancybox" gallery * * @param string $s * @param PostMedias $PostMedias - * @param int $uri_id * @return string */ - private static function addGallery(string $s, PostMedias $PostMedias, int $uri_id): string + private static function addGallery(string $s, PostMedias $PostMedias): string { foreach ($PostMedias as $PostMedia) { if (!$PostMedia->preview || ($PostMedia->type !== Post\Media::IMAGE)) { continue; } - $s = str_replace('hasDimensions()) { + $pattern = '#(.*?)">#'; + + $s = preg_replace_callback($pattern, function () use ($PostMedia) { + return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image/single_with_height_allocation.tpl'), [ + '$image' => $PostMedia, + '$allocated_height' => $PostMedia->getAllocatedHeight(), + ]); + }, $s); + } else { + $s = str_replace(' 1) { - $media = self::makeImageGrid($images); - } elseif (count($images) == 1) { - $media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image.tpl'), [ - '$image' => $images[0], - ]); - } + $media = Image::getBodyAttachHtml($images); // On Diaspora posts the attached pictures are leading if ($item['network'] == Protocol::DIASPORA) { diff --git a/view/global.css b/view/global.css index 714bb55dbd..ecab5a1c15 100644 --- a/view/global.css +++ b/view/global.css @@ -706,6 +706,39 @@ audio { * Image grid settings END **/ +/* This helps allocating space for image before they are loaded, preventing content shifting once they are. + * Inspired by https://www.smashingmagazine.com/2016/08/ways-to-reduce-content-shifting-on-page-load/ + * Please note: The space is effectively allocated using padding-bottom using the image ratio as a value. + * This ratio is never known in advance so no value is set in the stylesheet. + */ +figure.img-allocated-height { + position: relative; + background: center / auto rgba(0, 0, 0, 0.05) url(/images/icons/image.png) no-repeat; + margin: 0; +} +figure.img-allocated-height img{ + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + width: 100%; +} + +/** + * Horizontal masonry settings START + **/ +.masonry-row { + display: -ms-flexbox; /* IE10 */ + display: flex; + /* Both the following values should be the same to ensure consistent margins between images in the grid */ + column-gap: 5px; + margin-top: 5px; +} +/** + * Horizontal masonry settings AND + **/ + #contactblock .icon { width: 48px; height: 48px; diff --git a/view/templates/content/image_grid.tpl b/view/templates/content/image/grid.tpl similarity index 62% rename from view/templates/content/image_grid.tpl rename to view/templates/content/image/grid.tpl index 95e49ee3e1..091e69e8e2 100644 --- a/view/templates/content/image_grid.tpl +++ b/view/templates/content/image/grid.tpl @@ -1,12 +1,12 @@
{{foreach $columns.fc as $img}} - {{include file="content/image.tpl" image=$img}} + {{include file="content/image/single.tpl" image=$img}} {{/foreach}}
{{foreach $columns.sc as $img}} - {{include file="content/image.tpl" image=$img}} + {{include file="content/image/single.tpl" image=$img}} {{/foreach}}
-
\ No newline at end of file + diff --git a/view/templates/content/image/horizontal_masonry.tpl b/view/templates/content/image/horizontal_masonry.tpl new file mode 100644 index 0000000000..223a9c4a43 --- /dev/null +++ b/view/templates/content/image/horizontal_masonry.tpl @@ -0,0 +1,12 @@ +{{foreach $rows as $images}} +
+ {{foreach $images as $image}} + {{* The absolute pixel value in the calc() should be mirrored from the .imagegrid-row column-gap value *}} + {{include file="content/image/single_with_height_allocation.tpl" + image=$image + allocated_height="calc(`$image->heightRatio * $image->widthRatio / 100`% - 5px / `$column_size`)" + allocated_width="`$image->widthRatio`%" + }} + {{/foreach}} +
+{{/foreach}} diff --git a/view/templates/content/image.tpl b/view/templates/content/image/single.tpl similarity index 100% rename from view/templates/content/image.tpl rename to view/templates/content/image/single.tpl diff --git a/view/templates/content/image/single_with_height_allocation.tpl b/view/templates/content/image/single_with_height_allocation.tpl new file mode 100644 index 0000000000..1d70194bef --- /dev/null +++ b/view/templates/content/image/single_with_height_allocation.tpl @@ -0,0 +1,20 @@ +{{* The padding-top height allocation trick only works if the
fills its parent's width completely or with flex. 🤷‍♂️ + As a result, we need to add a wrapping element for non-flex (non-image grid) environments, mostly single-image cases. + *}} +{{if $allocated_max_width}} +
+{{/if}} + +
+ {{if $image->preview}} + + {{$image->description}} + + {{else}} + {{$image->description}} + {{/if}} +
+ +{{if $allocated_max_width}} +
+{{/if}} diff --git a/view/theme/frio/scheme/black.css b/view/theme/frio/scheme/black.css index debf9d99b3..561f708a81 100644 --- a/view/theme/frio/scheme/black.css +++ b/view/theme/frio/scheme/black.css @@ -394,3 +394,7 @@ input[type="text"].tt-input { textarea#profile-jot-text:focus + #preview_profile-jot-text, textarea.comment-edit-text:focus + .comment-edit-form .preview { border-color: $link_color; } + +figure.img-allocated-height { + background-color: rgba(255, 255, 255, 0.15); +} diff --git a/view/theme/frio/scheme/dark.css b/view/theme/frio/scheme/dark.css index add36fff10..434681c558 100644 --- a/view/theme/frio/scheme/dark.css +++ b/view/theme/frio/scheme/dark.css @@ -354,3 +354,7 @@ input[type="text"].tt-input { textarea#profile-jot-text:focus + #preview_profile-jot-text, textarea.comment-edit-text:focus + .comment-edit-form .preview { border-color: $link_color; } + +figure.img-allocated-height { + background-color: rgba(255, 255, 255, 0.05); +}