- Moved thumnbail loading out of repo into ImageResizer.
- Updated gallery and editor image handling to show errors where
possible to indicate memory issues for resizing/thumbs.
- Updated gallery to load image data in a per-image basis via edit form
for more resiliant thumb/data fetching. Data was previously provided
via gallery listing, which could be affected by failing generation
of other images.
- Updated image manager double click handling to be more pleasant and
not flash away the edit form.
- Updated editor handlers to use main URL when thumbs fail to load.
use BookStack\Exceptions\ImageUploadException;
use BookStack\Http\Controller;
use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
+use BookStack\Util\OutOfMemoryHandler;
use Exception;
use Illuminate\Http\Request;
* Get a list of gallery images, in a list.
* Can be paged and filtered by entity.
*/
- public function list(Request $request)
+ public function list(Request $request, ImageResizer $resizer)
{
$page = $request->get('page', 1);
$searchTerm = $request->get('search', null);
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
-
- return view('pages.parts.image-manager-list', [
+ $viewData = [
+ 'warning' => '',
'images' => $imgData['images'],
'hasMore' => $imgData['has_more'],
- ]);
+ ];
+
+ new OutOfMemoryHandler(function () use ($viewData) {
+ $viewData['warning'] = trans('errors.image_gallery_thumbnail_memory_limit');
+ return response()->view('pages.parts.image-manager-list', $viewData, 200);
+ });
+
+ $resizer->loadGalleryThumbnailsForMany($imgData['images']);
+
+ return view('pages.parts.image-manager-list', $viewData);
}
/**
use BookStack\Exceptions\ImageUploadException;
use BookStack\Http\Controller;
use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
use BookStack\Util\OutOfMemoryHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
* Get a list of gallery images, in a list.
* Can be paged and filtered by entity.
*/
- public function list(Request $request)
+ public function list(Request $request, ImageResizer $resizer)
{
$page = $request->get('page', 1);
$searchTerm = $request->get('search', null);
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm);
-
- return view('pages.parts.image-manager-list', [
+ $viewData = [
+ 'warning' => '',
'images' => $imgData['images'],
'hasMore' => $imgData['has_more'],
- ]);
+ ];
+
+ new OutOfMemoryHandler(function () use ($viewData) {
+ $viewData['warning'] = trans('errors.image_gallery_thumbnail_memory_limit');
+ return response()->view('pages.parts.image-manager-list', $viewData, 200);
+ });
+
+ $resizer->loadGalleryThumbnailsForMany($imgData['images']);
+
+ return view('pages.parts.image-manager-list', $viewData);
}
/**
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
+use BookStack\Exceptions\NotifyException;
use BookStack\Http\Controller;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
use BookStack\Uploads\ImageService;
use BookStack\Util\OutOfMemoryHandler;
use Exception;
{
public function __construct(
protected ImageRepo $imageRepo,
- protected ImageService $imageService
+ protected ImageService $imageService,
+ protected ImageResizer $imageResizer,
) {
}
$dependantPages = $this->imageRepo->getPagesUsingImage($image);
}
- $this->imageRepo->loadThumbs($image, false);
-
- return view('pages.parts.image-manager-form', [
+ $viewData = [
'image' => $image,
'dependantPages' => $dependantPages ?? null,
- ]);
+ 'warning' => '',
+ ];
+
+ new OutOfMemoryHandler(function () use ($viewData) {
+ $viewData['warning'] = trans('errors.image_thumbnail_memory_limit');
+ return response()->view('pages.parts.image-manager-form', $viewData);
+ });
+
+ $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
+
+ return view('pages.parts.image-manager-form', $viewData);
}
/**
return $this->jsonError(trans('errors.image_thumbnail_memory_limit'));
});
- $this->imageRepo->loadThumbs($image, true);
+ $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
return response(trans('components.image_rebuild_thumbs_success'));
}
/**
* Check related page permission and ensure type is drawio or gallery.
+ * @throws NotifyException
*/
- protected function checkImagePermission(Image $image)
+ protected function checkImagePermission(Image $image): void
{
if ($image->type !== 'drawio' && $image->type !== 'gallery') {
$this->showPermissionError();
use BookStack\Http\ApiController;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
use Illuminate\Http\Request;
class ImageGalleryApiController extends ApiController
];
public function __construct(
- protected ImageRepo $imageRepo
+ protected ImageRepo $imageRepo,
+ protected ImageResizer $imageResizer,
) {
}
*/
protected function formatForSingleResponse(Image $image): array
{
- $this->imageRepo->loadThumbs($image, false);
+ $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
$data = $image->toArray();
$data['created_by'] = $image->createdBy;
$data['updated_by'] = $image->updatedBy;
$escapedUrl = htmlentities($image->url);
$escapedName = htmlentities($image->name);
+
if ($image->type === 'drawio') {
$data['content']['html'] = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$escapedUrl}\"></div>";
$data['content']['markdown'] = $data['content']['html'];
*/
public function getThumb(?int $width, ?int $height, bool $keepRatio = false): ?string
{
- return app()->make(ImageResizer::class)->resizeToThumbnailUrl($this, $width, $height, $keepRatio, false, true);
+ return app()->make(ImageResizer::class)->resizeToThumbnailUrl($this, $width, $height, $keepRatio, false);
}
/**
* Execute a paginated query, returning in a standard format.
* Also runs the query through the restriction system.
*/
- private function returnPaginated(Builder $query, int $page = 1, int $pageSize = 24): array
+ protected function returnPaginated(Builder $query, int $page = 1, int $pageSize = 24): array
{
$images = $query->orderBy('created_at', 'desc')->skip($pageSize * ($page - 1))->take($pageSize + 1)->get();
- $hasMore = count($images) > $pageSize;
-
- $returnImages = $images->take($pageSize);
- $returnImages->each(function (Image $image) {
- $this->loadThumbs($image, false);
- });
return [
- 'images' => $returnImages,
- 'has_more' => $hasMore,
+ 'images' => $images->take($pageSize),
+ 'has_more' => count($images) > $pageSize,
];
}
$image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
if ($type !== 'system') {
- $this->loadThumbs($image, true);
+ $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
}
return $image;
public function saveNewFromData(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
{
$image = $this->imageService->saveNew($imageName, $imageData, $type, $uploadedTo);
- $this->loadThumbs($image, true);
+ $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
return $image;
}
$image->fill($updateDetails);
$image->updated_by = user()->id;
$image->save();
- $this->loadThumbs($image, false);
+ $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
return $image;
}
$image->save();
$this->imageService->replaceExistingFromUpload($image->path, $image->type, $file);
- $this->loadThumbs($image, true);
+ $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
}
/**
}
}
- /**
- * Load thumbnails onto an image object.
- */
- public function loadThumbs(Image $image, bool $shouldCreate): void
- {
- $image->setAttribute('thumbs', [
- 'gallery' => $this->getThumbnail($image, 150, 150, false, $shouldCreate),
- 'display' => $this->getThumbnail($image, 1680, null, true, $shouldCreate),
- ]);
- }
-
- /**
- * Get a thumbnail URL for the given image.
- */
- protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio, bool $shouldCreate): ?string
- {
- try {
- return $this->imageResizer->resizeToThumbnailUrl($image, $width, $height, $keepRatio, $shouldCreate);
- } catch (Exception $exception) {
- return null;
- }
- }
-
/**
* Get the raw image data from an Image.
*/
class ImageResizer
{
+ protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
+
public function __construct(
protected ImageManager $intervention,
protected ImageStorage $storage,
) {
}
+ /**
+ * Load gallery thumbnails for a set of images.
+ * @param iterable<Image> $images
+ */
+ public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
+ {
+ foreach ($images as $image) {
+ $this->loadGalleryThumbnailsForImage($image, $shouldCreate);
+ }
+ }
+
+ /**
+ * Load gallery thumbnails into the given image instance.
+ */
+ public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
+ {
+ $thumbs = ['gallery' => null, 'display' => null];
+
+ try {
+ $thumbs['gallery'] = $this->resizeToThumbnailUrl($image, 150, 150, false, $shouldCreate);
+ $thumbs['display'] = $this->resizeToThumbnailUrl($image, 1680, null, true, $shouldCreate);
+ } catch (Exception $exception) {
+ // Prevent thumbnail errors from stopping execution
+ }
+
+ $image->setAttribute('thumbs', $thumbs);
+ }
+
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
?int $width,
?int $height,
bool $keepRatio = false,
- bool $shouldCreate = false,
- bool $canCreate = false,
+ bool $shouldCreate = false
): ?string {
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
// If thumbnail has already been generated, serve that and cache path
$disk = $this->storage->getDisk($image->type);
if (!$shouldCreate && $disk->exists($thumbFilePath)) {
- Cache::put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
+ Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
return $this->storage->getPublicUrl($thumbFilePath);
}
// Do not resize apng images where we're not cropping
if ($keepRatio && $this->isApngData($image, $imageData)) {
- Cache::put($thumbCacheKey, $image->path, 60 * 60 * 72);
+ Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
return $this->storage->getPublicUrl($image->path);
}
- if (!$shouldCreate && !$canCreate) {
- return null;
- }
-
// If not in cache and thumbnail does not exist, generate thumb and cache path
$thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
$disk->put($thumbFilePath, $thumbData, true);
- Cache::put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
+ Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
return $this->storage->getPublicUrl($thumbFilePath);
}
'image_upload_error' => 'An error occurred uploading the image',
'image_upload_type_error' => 'The image type being uploaded is invalid',
'image_upload_replace_type' => 'Image file replacements must be of the same type',
- 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits',
- 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits',
+ 'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.',
+ 'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.',
+ 'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.',
'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
// Attachments
-import {
- onChildEvent, onSelect, removeLoading, showLoading,
-} from '../services/dom';
+import {onChildEvent, onSelect, removeLoading, showLoading,} from '../services/dom';
import {Component} from './component';
export class ImageManager extends Component {
this.loadGallery();
}
- onImageSelectEvent(event) {
- const image = JSON.parse(event.detail.data);
+ async onImageSelectEvent(event) {
+ let image = JSON.parse(event.detail.data);
const isDblClick = ((image && image.id === this.lastSelected.id)
&& Date.now() - this.lastSelectedTime < 400);
const alreadySelected = event.target.classList.contains('selected');
el.classList.remove('selected');
});
- if (!alreadySelected) {
+ if (!alreadySelected && !isDblClick) {
event.target.classList.add('selected');
- this.loadImageEditForm(image.id);
- } else {
+ image = await this.loadImageEditForm(image.id);
+ } else if (!isDblClick) {
this.resetEditForm();
+ } else if (isDblClick) {
+ image = this.lastSelected;
}
+
this.selectButton.classList.toggle('hidden', alreadySelected);
if (isDblClick && this.callback) {
this.formContainer.innerHTML = formHtml;
this.formContainerPlaceholder.setAttribute('hidden', '');
window.$components.init(this.formContainer);
+
+ const imageDataEl = this.formContainer.querySelector('#image-manager-form-image-data');
+ return JSON.parse(imageDataEl.text);
}
runLoadMore() {
const imageManager = window.$components.first('image-manager');
imageManager.show(image => {
- const imageUrl = image.thumbs.display || image.url;
+ const imageUrl = image.thumbs?.display || image.url;
const selectedText = this.#getSelectionText();
const newText = `[](${image.url})`;
this.#replaceSelection(newText, newText.length);
const newContent = `[](${data.url})`;
this.#findAndReplaceContent(placeHolderText, newContent);
} catch (err) {
- window.$events.emit('error', this.editor.config.text.imageUploadError);
+ window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError);
this.#findAndReplaceContent(placeHolderText, '');
console.error(err);
}
editor.dom.replace(newEl, id);
}).catch(err => {
editor.dom.remove(id);
- window.$events.emit('error', options.translations.imageUploadErrorText);
+ window.$events.error(err?.data?.message || options.translations.imageUploadErrorText);
console.error(err);
});
}, 10);
/** @type {ImageManager} * */
const imageManager = window.$components.first('image-manager');
imageManager.show(image => {
- const imageUrl = image.thumbs.display || image.url;
+ const imageUrl = image.thumbs?.display || image.url;
let html = `<a href="${image.url}" target="_blank">`;
html += `<img src="${imageUrl}" alt="${image.name}">`;
html += '</a>';
text-align: center;
}
+.image-manager-list .image-manager-list-warning {
+ grid-column: 1 / -1;
+ aspect-ratio: auto;
+}
+
+.image-manager-warning {
+ @include lightDark(background, #FFF, #333);
+ color: var(--color-warning);
+ font-weight: bold;
+ border-inline: 3px solid var(--color-warning);
+}
+
.image-manager-sidebar {
width: 300px;
margin: 0 auto;
option:dropzone:file-accept="image/*"
class="image-manager-details">
+ @if($warning ?? '')
+ <div class="image-manager-warning px-m py-xs flex-container-row gap-xs items-center mb-l">
+ <div>@icon('warning')</div>
+ <div class="flex">{{ $warning }}</div>
+ </div>
+ @endif
+
<div refs="dropzone@status-area dropzone@drop-target"></div>
+ <script id="image-manager-form-image-data" type="application/json">@json($image)</script>
+
<form component="ajax-form"
option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
option:ajax-form:method="put"
+@if($warning ?? '')
+ <div class="image-manager-list-warning image-manager-warning px-m py-xs flex-container-row gap-xs items-center">
+ <div>@icon('warning')</div>
+ <div class="flex">{{ $warning }}</div>
+ </div>
+@endif
@foreach($images as $index => $image)
<div>
<button component="event-emit-select"
option:event-emit-select:data="{{ json_encode($image) }}"
class="image anim fadeIn text-link"
style="animation-delay: {{ min($index * 10, 260) . 'ms' }};">
- <img src="{{ $image->thumbs['gallery'] }}"
+ <img src="{{ $image->thumbs['gallery'] ?? '' }}"
alt="{{ $image->name }}"
role="none"
width="150"