]> BookStack Code Mirror - bookstack/commitdiff
Images: Rolled out image memory handling to image actions
authorDan Brown <redacted>
Sun, 1 Oct 2023 12:05:18 +0000 (13:05 +0100)
committerDan Brown <redacted>
Sun, 1 Oct 2023 12:05:18 +0000 (13:05 +0100)
- 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.

15 files changed:
app/Uploads/Controllers/DrawioImageController.php
app/Uploads/Controllers/GalleryImageController.php
app/Uploads/Controllers/ImageController.php
app/Uploads/Controllers/ImageGalleryApiController.php
app/Uploads/Image.php
app/Uploads/ImageRepo.php
app/Uploads/ImageResizer.php
lang/en/errors.php
resources/js/components/image-manager.js
resources/js/markdown/actions.js
resources/js/wysiwyg/drop-paste-handling.js
resources/js/wysiwyg/plugins-imagemanager.js
resources/sass/_components.scss
resources/views/pages/parts/image-manager-form.blade.php
resources/views/pages/parts/image-manager-list.blade.php

index 49f0c16550db9bb8013a625fc498941b8976d9d1..6293da4f718b24189ae5c70a92529caf4c77a94b 100644 (file)
@@ -5,6 +5,8 @@ namespace BookStack\Uploads\Controllers;
 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;
 
@@ -19,7 +21,7 @@ class DrawioImageController extends Controller
      * 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);
@@ -27,11 +29,20 @@ class DrawioImageController extends Controller
         $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);
     }
 
     /**
index 0696ca62b703b22e1af0d4744dc5f0c7d80d5030..258f2bef6bda19c48e58a12b9d46280084d9997e 100644 (file)
@@ -5,6 +5,7 @@ namespace BookStack\Uploads\Controllers;
 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;
@@ -22,7 +23,7 @@ class GalleryImageController extends Controller
      * 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);
@@ -30,11 +31,20 @@ class GalleryImageController extends Controller
         $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);
     }
 
     /**
index f92338bc82fb70d96daddf598467cb996e38dfe6..c68ffdf6bd0361fae8f904b38499dbe0ea0a5bff 100644 (file)
@@ -4,9 +4,11 @@ namespace BookStack\Uploads\Controllers;
 
 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;
@@ -16,7 +18,8 @@ class ImageController extends Controller
 {
     public function __construct(
         protected ImageRepo $imageRepo,
-        protected ImageService $imageService
+        protected ImageService $imageService,
+        protected ImageResizer $imageResizer,
     ) {
     }
 
@@ -98,12 +101,20 @@ class ImageController extends Controller
             $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);
     }
 
     /**
@@ -135,15 +146,16 @@ class ImageController extends Controller
             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();
index efdff5be4a12ed7024c96f3f2d8a8116cd8b99c1..ec96e4593bc2fa48f71e0e221a5c27be69278156 100644 (file)
@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Page;
 use BookStack\Http\ApiController;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
 use Illuminate\Http\Request;
 
 class ImageGalleryApiController extends ApiController
@@ -15,7 +16,8 @@ class ImageGalleryApiController extends ApiController
     ];
 
     public function __construct(
-        protected ImageRepo $imageRepo
+        protected ImageRepo $imageRepo,
+        protected ImageResizer $imageResizer,
     ) {
     }
 
@@ -130,7 +132,7 @@ class ImageGalleryApiController extends ApiController
      */
     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;
@@ -138,6 +140,7 @@ class ImageGalleryApiController extends ApiController
 
         $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'];
index 1e42f414bbb47c618a23916415dc8885bc855683..0a267a64465ff623ae92c001cd68caf4666f38ef 100644 (file)
@@ -52,7 +52,7 @@ class Image extends Model
      */
     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);
     }
 
     /**
index 4aa36bab9a21292c91b68afcd5e5fd1b28ca0426..0e312d8832730b678e2c35502604966247037761 100644 (file)
@@ -30,19 +30,13 @@ class ImageRepo
      * 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,
         ];
     }
 
@@ -120,7 +114,7 @@ class ImageRepo
         $image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
 
         if ($type !== 'system') {
-            $this->loadThumbs($image, true);
+            $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
         }
 
         return $image;
@@ -134,7 +128,7 @@ class ImageRepo
     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;
     }
@@ -161,7 +155,7 @@ class ImageRepo
         $image->fill($updateDetails);
         $image->updated_by = user()->id;
         $image->save();
-        $this->loadThumbs($image, false);
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
 
         return $image;
     }
@@ -182,7 +176,7 @@ class ImageRepo
         $image->save();
 
         $this->imageService->replaceExistingFromUpload($image->path, $image->type, $file);
-        $this->loadThumbs($image, true);
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
     }
 
     /**
@@ -214,29 +208,6 @@ class ImageRepo
         }
     }
 
-    /**
-     * 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.
      */
index 5fe8a8954551ee5acf7419455d0a660854466cfc..0d090a94b26ce62ed1d29499afb151768cb10f21 100644 (file)
@@ -11,12 +11,42 @@ use Intervention\Image\ImageManager;
 
 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.
@@ -29,8 +59,7 @@ class ImageResizer
         ?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)) {
@@ -52,7 +81,7 @@ class ImageResizer
         // 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);
         }
@@ -61,19 +90,15 @@ class ImageResizer
 
         // 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);
     }
index 285817e475f50e5fc271bcdc629755a9615b7ba9..8813cf90a2da584fb01923b27c58c9e45750b825 100644 (file)
@@ -51,8 +51,9 @@ return [
     '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
index bc0493a88faddac4d1f400575285802cb86eba7e..b6397b0040e7470bf6437fbe286cf230ccea33ac 100644 (file)
@@ -1,6 +1,4 @@
-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 {
@@ -229,8 +227,8 @@ 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');
@@ -238,12 +236,15 @@ export class ImageManager extends Component {
             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) {
@@ -265,6 +266,9 @@ export class ImageManager extends Component {
         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() {
index a7fde9322d61b2b309e18b0015e80bafb2469869..4909a59d066f9627b3756e951be2079940369083 100644 (file)
@@ -34,7 +34,7 @@ export class Actions {
         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 = `[![${selectedText || image.name}](${imageUrl})](${image.url})`;
             this.#replaceSelection(newText, newText.length);
@@ -417,7 +417,7 @@ export class Actions {
             const newContent = `[![](${data.thumbs.display})](${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);
         }
index 33078cd1d5687b74fb877ef8dd2719dd4e0f59ff..9668692c81d1e162cf6f73d954c6cd307bd29735 100644 (file)
@@ -61,7 +61,7 @@ function paste(editor, options, event) {
                 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);
index 37b5bfafd653b7f45fc523fd7b2775cbdd3e0c7c..f1ea120502a4ee99286e82fe94fc56ce5247b8d9 100644 (file)
@@ -11,7 +11,7 @@ function register(editor) {
             /** @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>';
index c1989c1f62b6ba024921178b9a25d2f9f5f5bdc9..150f78e12026e08e26d748e60b6854958ddf2ab0 100644 (file)
@@ -457,6 +457,18 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   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;
index 3a73bee7c0993131a9a6399f4d4cc1b8f010d7f1..bd84e247d912d8b6a09d197f7e15082619fb98e4 100644 (file)
@@ -8,8 +8,17 @@
      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"
index 7e660c747dca135c9d947596dca6d76ace6ad1d8..ff6e23d6ac7e272a504c1b6bc074c000d2c80a48 100644 (file)
@@ -1,3 +1,9 @@
+@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"
@@ -5,7 +11,7 @@
          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"