Add new horizontal masonry and image height allocation

- Move image templates to content/image sub-folder
This commit is contained in:
Hypolite Petovan 2023-09-29 03:28:22 -04:00
parent e01040a2e8
commit 163a85c78f
13 changed files with 410 additions and 39 deletions

154
src/Content/Image.php Normal file
View File

@ -0,0 +1,154 @@
<?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,
]);
}
}

View File

@ -0,0 +1,57 @@
<?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;
}
}

View File

@ -0,0 +1,60 @@
<?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;
}
}

View File

@ -42,4 +42,16 @@ class PostMedias extends BaseCollection
{ {
return parent::current(); 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);
}
} }

View File

@ -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. * 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. * 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, $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;
}
} }

View File

@ -22,6 +22,7 @@
namespace Friendica\Model; namespace Friendica\Model;
use Friendica\Contact\LocalRelationship\Entity\LocalRelationship; use Friendica\Contact\LocalRelationship\Entity\LocalRelationship;
use Friendica\Content\Image;
use Friendica\Content\Post\Collection\PostMedias; use Friendica\Content\Post\Collection\PostMedias;
use Friendica\Content\Post\Entity\PostMedia; use Friendica\Content\Post\Entity\PostMedia;
use Friendica\Content\Text\BBCode; use Friendica\Content\Text\BBCode;
@ -3241,7 +3242,7 @@ class Item
} }
if (!empty($sharedSplitAttachments)) { 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::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::addLinkAttachment($shared_uri_id ?: $item['uri-id'], $sharedSplitAttachments, $body, $s, true, $quote_shared_links);
$s = self::addNonVisualAttachments($sharedSplitAttachments['additional'], $item, $s, true); $s = self::addNonVisualAttachments($sharedSplitAttachments['additional'], $item, $s, true);
@ -3254,7 +3255,7 @@ class Item
$s = substr($s, 0, $pos); $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::addVisualAttachments($itemSplitAttachments['visual'], $item, $s, false);
$s = self::addLinkAttachment($item['uri-id'], $itemSplitAttachments, $body, $s, false, $shared_links); $s = self::addLinkAttachment($item['uri-id'], $itemSplitAttachments, $body, $s, false, $shared_links);
$s = self::addNonVisualAttachments($itemSplitAttachments['additional'], $item, $s, false); $s = self::addNonVisualAttachments($itemSplitAttachments['additional'], $item, $s, false);
@ -3285,45 +3286,32 @@ class Item
return $hook_data['html']; 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 * Modify links to pictures to links for the "Fancybox" gallery
* *
* @param string $s * @param string $s
* @param PostMedias $PostMedias * @param PostMedias $PostMedias
* @param int $uri_id
* @return string * @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) { foreach ($PostMedias as $PostMedia) {
if (!$PostMedia->preview || ($PostMedia->type !== Post\Media::IMAGE)) { if (!$PostMedia->preview || ($PostMedia->type !== Post\Media::IMAGE)) {
continue; 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; return $s;
@ -3494,14 +3482,7 @@ class Item
} }
} }
$media = ''; $media = Image::getBodyAttachHtml($images);
if (count($images) > 1) {
$media = self::makeImageGrid($images);
} elseif (count($images) == 1) {
$media = Renderer::replaceMacros(Renderer::getMarkupTemplate('content/image.tpl'), [
'$image' => $images[0],
]);
}
// On Diaspora posts the attached pictures are leading // On Diaspora posts the attached pictures are leading
if ($item['network'] == Protocol::DIASPORA) { if ($item['network'] == Protocol::DIASPORA) {

View File

@ -706,6 +706,39 @@ audio {
* Image grid settings END * 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 { #contactblock .icon {
width: 48px; width: 48px;
height: 48px; height: 48px;

View File

@ -1,12 +1,12 @@
<div class="imagegrid-row"> <div class="imagegrid-row">
<div class="imagegrid-column"> <div class="imagegrid-column">
{{foreach $columns.fc as $img}} {{foreach $columns.fc as $img}}
{{include file="content/image.tpl" image=$img}} {{include file="content/image/single.tpl" image=$img}}
{{/foreach}} {{/foreach}}
</div> </div>
<div class="imagegrid-column"> <div class="imagegrid-column">
{{foreach $columns.sc as $img}} {{foreach $columns.sc as $img}}
{{include file="content/image.tpl" image=$img}} {{include file="content/image/single.tpl" image=$img}}
{{/foreach}} {{/foreach}}
</div> </div>
</div> </div>

View File

@ -0,0 +1,12 @@
{{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}}

View File

@ -0,0 +1,20 @@
{{* 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}}

View File

@ -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 { textarea#profile-jot-text:focus + #preview_profile-jot-text, textarea.comment-edit-text:focus + .comment-edit-form .preview {
border-color: $link_color; border-color: $link_color;
} }
figure.img-allocated-height {
background-color: rgba(255, 255, 255, 0.15);
}

View File

@ -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 { textarea#profile-jot-text:focus + #preview_profile-jot-text, textarea.comment-edit-text:focus + .comment-edit-form .preview {
border-color: $link_color; border-color: $link_color;
} }
figure.img-allocated-height {
background-color: rgba(255, 255, 255, 0.05);
}