--- /dev/null
+<?php
+/**
+ * @copyright Copyright (C) 2010-2023, the Friendica project
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+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,
+ ]);
+ }
+}
--- /dev/null
+<?php
+/**
+ * @copyright Copyright (C) 2010-2023, the Friendica project
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+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;
+ }
+}
--- /dev/null
+<?php
+/**
+ * @copyright Copyright (C) 2010-2023, the Friendica project
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ *
+ */
+
+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;
+ }
+}
{
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);
+ }
}
}
+ /**
+ * 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.
$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;
+ }
}
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;
}
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);
$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);
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('<a href="' . $PostMedia->url . '"', '<a data-fancybox="' . $uri_id . '" href="' . $PostMedia->url . '"', $s);
+ if ($PostMedia->hasDimensions()) {
+ $pattern = '#<a href="' . preg_quote($PostMedia->url) . '">(.*?)"></a>#';
+
+ $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('<a href="' . $PostMedia->url . '"', '<a data-fancybox="uri-id-' . $PostMedia->uriId . '" href="' . $PostMedia->url . '"', $s);
+ }
}
return $s;
}
}
- $media = '';
- if (count($images) > 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) {
* 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;
+++ /dev/null
-{{if $image->preview}}
-<a data-fancybox="{{$image->uriId}}" href="{{$image->url}}"><img src="{{$image->preview}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy"></a>
-{{else}}
-<img src="{{$image->url}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy">
-{{/if}}
--- /dev/null
+<div class="imagegrid-row">
+ <div class="imagegrid-column">
+ {{foreach $columns.fc as $img}}
+ {{include file="content/image/single.tpl" image=$img}}
+ {{/foreach}}
+ </div>
+ <div class="imagegrid-column">
+ {{foreach $columns.sc as $img}}
+ {{include file="content/image/single.tpl" image=$img}}
+ {{/foreach}}
+ </div>
+</div>
--- /dev/null
+{{foreach $rows as $images}}
+ <div class="masonry-row" style="height: {{$images->getHeightRatio()}}%">
+ {{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}}
+ </div>
+{{/foreach}}
--- /dev/null
+{{if $image->preview}}
+<a data-fancybox="{{$image->uriId}}" href="{{$image->url}}"><img src="{{$image->preview}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy"></a>
+{{else}}
+<img src="{{$image->url}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy">
+{{/if}}
--- /dev/null
+{{* The padding-top height allocation trick only works if the <figure> 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}}
+<div style="max-width: {{$allocated_max_width|default:"auto"}};">
+{{/if}}
+
+<figure class="img-allocated-height" style="width: {{$allocated_width|default:"auto"}}; padding-bottom: {{$allocated_height}}">
+ {{if $image->preview}}
+ <a data-fancybox="uri-id-{{$image->uriId}}" href="{{$image->url}}">
+ <img src="{{$image->preview}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy">
+ </a>
+ {{else}}
+ <img src="{{$image->url}}" alt="{{$image->description}}" title="{{$image->description}}" loading="lazy">
+ {{/if}}
+</figure>
+
+{{if $allocated_max_width}}
+</div>
+{{/if}}
+++ /dev/null
-<div class="imagegrid-row">
- <div class="imagegrid-column">
- {{foreach $columns.fc as $img}}
- {{include file="content/image.tpl" image=$img}}
- {{/foreach}}
- </div>
- <div class="imagegrid-column">
- {{foreach $columns.sc as $img}}
- {{include file="content/image.tpl" image=$img}}
- {{/foreach}}
- </div>
-</div>
\ No newline at end of file
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);
+}
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);
+}