Skip to content

Commit a0a3e46

Browse files
committed
use Photos metadata for pre-sized, blurhashed image placeholders
Signed-off-by: Andrew Backhouse <andrew.backhouse@nextcloud.com>
1 parent 8d8d779 commit a0a3e46

3 files changed

Lines changed: 104 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: 76 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,6 +135,7 @@
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'
132140
import ShowImageModal from '../components/ImageView/ShowImageModal.vue'
133141
import { useAttachmentResolver } from '../components/Editor.provider.js'
@@ -149,6 +157,7 @@ export default {
149157
ImageIcon,
150158
DeleteIcon,
151159
NcButton,
160+
NcBlurHash,
152161
ShowImageModal,
153162
NodeViewWrapper,
154163
},
@@ -160,7 +169,13 @@ export default {
160169
data() {
161170
return {
162171
attachment: null,
172+
attachmentPromise: null,
163173
imageLoaded: false,
174+
imageWidth: 0,
175+
imageHeight: 0,
176+
wrapperWidth: 0,
177+
resizeObserver: null,
178+
imageBlurhash: null,
164179
loaded: false,
165180
failed: false,
166181
showIcons: false,
@@ -199,6 +214,25 @@ export default {
199214
200215
return this.loaded && this.imageLoaded
201216
},
217+
canDisplayPlaceholder() {
218+
return this.imageHeight > 0
219+
},
220+
blurhashSize() {
221+
if (this.imageWidth > 0 && this.imageHeight > 0) {
222+
const ratio = this.imageWidth / this.imageHeight
223+
const newWidth =
224+
this.wrapperWidth - 12 > this.imageWidth
225+
? this.imageWidth
226+
: this.wrapperWidth - 12
227+
const newHeight = newWidth / ratio
228+
229+
return {
230+
width: `${newWidth}px`,
231+
height: `${newHeight}px`,
232+
}
233+
}
234+
return {}
235+
},
202236
src: {
203237
get() {
204238
return this.node.attrs.src || ''
@@ -233,6 +267,10 @@ export default {
233267
})
234268
},
235269
mounted() {
270+
this.attachmentPromise = this.$attachmentResolver.resolve(this.src)
271+
this.loadAttachmentMetadata()
272+
this.setupResizeObserver()
273+
236274
this.$nextTick(() => {
237275
// nextTick is necessary, intersection detection is slightly unreliable without it
238276
const options = {
@@ -254,10 +292,38 @@ export default {
254292
},
255293
beforeUnmount() {
256294
this.loadIntersectionObserver?.disconnect()
295+
this.resizeObserver?.disconnect()
257296
},
258297
methods: {
298+
setupResizeObserver() {
299+
if (!this.$refs.wrapper) return
300+
301+
this.resizeObserver = new ResizeObserver((entries) => {
302+
const width = entries[0].contentRect.width
303+
if (width > 0) {
304+
this.wrapperWidth = width
305+
}
306+
})
307+
308+
this.resizeObserver.observe(this.$refs.wrapper)
309+
},
310+
async loadAttachmentMetadata() {
311+
try {
312+
this.attachment = await this.attachmentPromise
313+
314+
const size = this.attachment?.metadata?.['photos-size']?.value
315+
this.imageWidth = size.width
316+
this.imageHeight = size.height
317+
318+
this.imageBlurhash = this.attachment?.metadata?.blurhash?.value
319+
} catch (err) {
320+
throw new Error('Failed to load attachment metadata')
321+
}
322+
},
259323
async loadPreview() {
260-
this.attachment = await this.$attachmentResolver.resolve(this.src)
324+
if (!this.attachment) {
325+
this.attachment = await this.attachmentPromise
326+
}
261327
if (!this.attachment.previewUrl) {
262328
throw new Error('Attachment source was not resolved')
263329
}
@@ -268,6 +334,9 @@ export default {
268334
this.imageLoaded = true
269335
this.loaded = true
270336
this.attachmentSize = this.attachment.size
337+
// once the image is loaded, we can stop tracking the container width
338+
// since we only use it for sizing the placeholder
339+
this.resizeObserver?.disconnect()
271340
}
272341
img.onerror = (e) => {
273342
reject(new LoadImageError(e, this.attachment.previewUrl))
@@ -426,6 +495,12 @@ export default {
426495
height: 100px;
427496
}
428497
498+
.image__placeholder {
499+
padding: 7px 6px;
500+
margin-bottom: 26px;
501+
position: relative;
502+
}
503+
429504
.image__main {
430505
max-height: calc(100vh - 50px - 50px);
431506
}

0 commit comments

Comments
 (0)