Skip to content

Commit 9852d4b

Browse files
authored
Merge pull request #7275 from nextcloud/feat/image-size-metadata
feat(ImageView): use Photos metadata for pre-sized, blurhashed image placeholders
2 parents bd2b318 + c1de72b commit 9852d4b

3 files changed

Lines changed: 110 additions & 9 deletions

File tree

lib/Controller/AttachmentController.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,13 +216,18 @@ public function getImageFile(string $imageFileName, string $shareToken = '',
216216
$userId = $this->getUserId();
217217
$imageFile = $this->attachmentService->getImageFile($documentId, $imageFileName, $userId, $preferRawImage === 1);
218218
}
219-
return $imageFile !== null
220-
? new DataDownloadResponse(
219+
220+
if ($imageFile !== null) {
221+
$response = new DataDownloadResponse(
221222
$imageFile->getContent(),
222223
$imageFile->getName(),
223224
$this->getSecureMimeType($imageFile->getMimeType())
224-
)
225-
: new DataResponse('', Http::STATUS_NOT_FOUND);
225+
);
226+
$response->cacheFor(3600 * 24, false, true);
227+
return $response;
228+
}
229+
230+
return new DataResponse('', Http::STATUS_NOT_FOUND);
226231
} catch (Exception $e) {
227232
$this->logger->error('getImageFile error', ['exception' => $e]);
228233
return new DataResponse('', Http::STATUS_NOT_FOUND);

lib/Service/AttachmentService.php

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use OCP\Files\NotFoundException;
2424
use OCP\Files\NotPermittedException;
2525
use OCP\Files\SimpleFS\ISimpleFile;
26+
use OCP\FilesMetadata\IFilesMetadataManager;
2627
use OCP\IPreview;
2728
use OCP\IURLGenerator;
2829
use OCP\Lock\LockedException;
@@ -39,6 +40,7 @@ public function __construct(
3940
private IMimeTypeDetector $mimeTypeDetector,
4041
private IURLGenerator $urlGenerator,
4142
private IFilenameValidator $filenameValidator,
43+
private IFilesMetadataManager $filesMetadataManager,
4244
) {
4345
}
4446

@@ -219,21 +221,34 @@ public function getAttachmentList(int $documentId, ?string $userId = null, ?Sess
219221

220222
$attachments = [];
221223
$userFolder = $userId !== null ? $this->rootFolder->getUserFolder($userId) : null;
224+
225+
$fileNodes = [];
226+
$fileIds = [];
222227
foreach ($attachmentDir->getDirectoryListing() as $node) {
223-
if (!($node instanceof File)) {
224-
// Ignore anything but files
225-
continue;
228+
if ($node instanceof File) {
229+
// we only want Files
230+
$fileNodes[] = $node;
231+
$fileIds[] = $node->getId();
226232
}
233+
}
234+
235+
// this is done outside the loop for efficiency
236+
$metadataMap = $this->filesMetadataManager->getMetadataForFiles($fileIds);
237+
238+
foreach ($fileNodes as $node) {
227239
$isImage = in_array($node->getMimetype(), AttachmentController::IMAGE_MIME_TYPES, true);
228240
$name = $node->getName();
241+
$fileId = $node->getId();
242+
$metadata = $metadataMap[$fileId] ?? null;
229243
$attachments[] = [
230-
'fileId' => $node->getId(),
244+
'fileId' => $fileId,
231245
'name' => $name,
232246
'size' => Util::humanFileSize($node->getSize()),
233247
'mimetype' => $node->getMimeType(),
234248
'mtime' => $node->getMTime(),
235249
'isImage' => $isImage,
236250
'davPath' => $userFolder?->getRelativePath($node->getPath()),
251+
'metadata' => $metadata,
237252
'fullUrl' => $isImage
238253
? $this->urlGenerator->linkToRouteAbsolute('text.Attachment.getImageFile') . $urlParamsBase . '&imageFileName=' . rawurlencode($name) . '&preferRawImage=1'
239254
: $this->urlGenerator->linkToRouteAbsolute('text.Attachment.getMediaFile') . $urlParamsBase . '&mediaFileName=' . rawurlencode($name),

src/nodes/ImageView.vue

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<template>
77
<NodeViewWrapper :contenteditable="isEditable">
88
<figure
9+
ref="wrapper"
910
class="image image-view"
1011
data-component="image-view"
1112
:data-attachment-type="attachmentType"
@@ -105,6 +106,12 @@
105106
@close="showImageModal = false" />
106107
</div>
107108
</div>
109+
<div v-else-if="canDisplayPlaceholder" class="image__placeholder">
110+
<NcBlurHash
111+
:hash="imageBlurhash"
112+
:style="blurhashSize"
113+
aria-hidden="true" />
114+
</div>
108115
<div v-else class="image-view__cant_display">
109116
<transition name="fade">
110117
<div v-show="loaded" class="image__caption">
@@ -128,7 +135,9 @@
128135
<script>
129136
import ClickOutside from 'vue-click-outside'
130137
import NcButton from '@nextcloud/vue/components/NcButton'
138+
import NcBlurHash from '@nextcloud/vue/components/NcBlurHash'
131139
import { showError } from '@nextcloud/dialogs'
140+
import { logger } from '../helpers/logger.js'
132141
import ShowImageModal from '../components/ImageView/ShowImageModal.vue'
133142
import { useAttachmentResolver } from '../components/Editor.provider.js'
134143
import { emit } from '@nextcloud/event-bus'
@@ -149,6 +158,7 @@ export default {
149158
ImageIcon,
150159
DeleteIcon,
151160
NcButton,
161+
NcBlurHash,
152162
ShowImageModal,
153163
NodeViewWrapper,
154164
},
@@ -160,7 +170,13 @@ export default {
160170
data() {
161171
return {
162172
attachment: null,
173+
attachmentPromise: null,
163174
imageLoaded: false,
175+
imageWidth: 0,
176+
imageHeight: 0,
177+
wrapperWidth: 0,
178+
resizeObserver: null,
179+
imageBlurhash: null,
164180
loaded: false,
165181
failed: false,
166182
showIcons: false,
@@ -199,6 +215,25 @@ export default {
199215
200216
return this.loaded && this.imageLoaded
201217
},
218+
canDisplayPlaceholder() {
219+
return this.imageHeight > 0
220+
},
221+
blurhashSize() {
222+
if (this.imageWidth > 0 && this.imageHeight > 0) {
223+
const ratio = this.imageWidth / this.imageHeight
224+
const newWidth =
225+
this.wrapperWidth - 12 > this.imageWidth
226+
? this.imageWidth
227+
: this.wrapperWidth - 12
228+
const newHeight = newWidth / ratio
229+
230+
return {
231+
width: `${newWidth}px`,
232+
height: `${newHeight}px`,
233+
}
234+
}
235+
return {}
236+
},
202237
src: {
203238
get() {
204239
return this.node.attrs.src || ''
@@ -233,6 +268,10 @@ export default {
233268
})
234269
},
235270
mounted() {
271+
this.attachmentPromise = this.$attachmentResolver.resolve(this.src)
272+
this.loadAttachmentMetadata()
273+
this.setupResizeObserver()
274+
236275
this.$nextTick(() => {
237276
// nextTick is necessary, intersection detection is slightly unreliable without it
238277
const options = {
@@ -254,10 +293,43 @@ export default {
254293
},
255294
beforeUnmount() {
256295
this.loadIntersectionObserver?.disconnect()
296+
this.resizeObserver?.disconnect()
257297
},
258298
methods: {
299+
setupResizeObserver() {
300+
if (!this.$refs.wrapper) return
301+
302+
this.resizeObserver = new ResizeObserver((entries) => {
303+
const width = entries[0].contentRect.width
304+
if (width > 0) {
305+
this.wrapperWidth = width
306+
}
307+
})
308+
309+
this.resizeObserver.observe(this.$refs.wrapper)
310+
},
311+
async loadAttachmentMetadata() {
312+
try {
313+
this.attachment = await this.attachmentPromise
314+
315+
const metadata = this.attachment?.metadata || null
316+
317+
if (metadata) {
318+
const size = metadata['photos-size']?.value
319+
this.imageWidth = size?.width || 0
320+
this.imageHeight = size?.height || 0
321+
322+
this.imageBlurhash = metadata.blurhash?.value || null
323+
}
324+
} catch (err) {
325+
// TODO: bump up to warn when the Photos dependency is gone (i.e., we can expect the metadata to exist)
326+
logger.debug('Failed to load attachment metadata', { err })
327+
}
328+
},
259329
async loadPreview() {
260-
this.attachment = await this.$attachmentResolver.resolve(this.src)
330+
if (!this.attachment) {
331+
this.attachment = await this.attachmentPromise
332+
}
261333
if (!this.attachment.previewUrl) {
262334
throw new Error('Attachment source was not resolved')
263335
}
@@ -268,6 +340,9 @@ export default {
268340
this.imageLoaded = true
269341
this.loaded = true
270342
this.attachmentSize = this.attachment.size
343+
// once the image is loaded, we can stop tracking the container width
344+
// since we only use it for sizing the placeholder
345+
this.resizeObserver?.disconnect()
271346
}
272347
img.onerror = (e) => {
273348
reject(new LoadImageError(e, this.attachment.previewUrl))
@@ -426,6 +501,12 @@ export default {
426501
height: 100px;
427502
}
428503
504+
.image__placeholder {
505+
padding: 7px 6px;
506+
margin-bottom: 26px;
507+
position: relative;
508+
}
509+
429510
.image__main {
430511
max-height: calc(100vh - 50px - 50px);
431512
}

0 commit comments

Comments
 (0)