UNPKG

apostrophe

Version:
823 lines (800 loc) 25.8 kB
const _ = require('lodash'); const { computeMinSizes } = require('../../../lib/image'); // A subclass of `@apostrophecms/piece-type`, `@apostrophecms/image` // establishes a library of uploaded images in formats suitable for use on the // web. // // Together with // [@apostrophecms/image-widget](../@apostrophecms/image-widget/index.html), // this module provides a simple way to add downloadable PDFs and the like to a // website, and to manage a library of them for reuse. // // Each `@apostrophecms/image` doc has an `attachment` schema field, implemented // by the [@apostrophecms/attachment](../@apostrophecms/attachment/index.html) // module. module.exports = { extend: '@apostrophecms/piece-type', options: { name: '@apostrophecms/image', label: 'apostrophe:image', pluralLabel: 'apostrophe:images', alias: 'image', perPage: 50, sort: { createdAt: -1 }, quickCreate: false, insertViaUpload: true, searchable: false, slugPrefix: 'image-', autopublish: true, versions: true, editRole: 'editor', publishRole: 'editor', showPermissions: true, // Images should by default be considered "related documents" when // localizing another document that references them relatedDocument: true, relationshipEditor: 'AposImageRelationshipEditor', relationshipEditorLabel: 'apostrophe:editImageAdjustments', relationshipEditorIcon: 'image-edit-outline', relationshipFields: { add: { top: { type: 'integer' }, left: { type: 'integer' }, width: { type: 'integer' }, height: { type: 'integer' }, x: { type: 'integer' }, y: { type: 'integer' } } }, relationshipPostprocessor: 'autocrop', relationshipSuggestionIcon: 'image-icon', relationshipSuggestionFields: [] }, batchOperations: { add: { tag: { label: 'apostrophe:tag', messages: { create: { progress: 'apostrophe:batchTagProgress' }, add: { progress: 'apostrophe:batchTagProgress' }, remove: { progress: 'apostrophe:batchUntagProgress' } }, icon: 'label-icon', action: 'tag', permission: 'edit' } }, order: [ 'tag', 'archive', 'restore' ] }, utilityOperations: { remove: [ 'new' ] }, fields: { remove: [ 'visibility' ], add: { slug: { type: 'slug', label: 'apostrophe:slug', prefix: 'image', required: true, following: [ 'title', 'archived' ] }, attachment: { type: 'attachment', label: 'apostrophe:imageFile', fileGroup: 'images', required: true }, alt: { type: 'string', label: 'apostrophe:altText', help: 'apostrophe:altTextHelp' }, credit: { type: 'string', label: 'apostrophe:credit' }, creditUrl: { type: 'url', label: 'apostrophe:creditUrl' }, _tags: { type: 'relationship', label: 'apostrophe:tags', withType: '@apostrophecms/image-tag', modifiers: [ 'no-search' ] } }, group: { // The image editor has only one group. basics: { label: 'apostrophe:basics', fields: [ 'attachment', 'title', 'alt', '_tags', 'credit', 'creditUrl', 'slug', 'archived' ] } } }, filters: { remove: [ 'visibility' ], add: { _tags: { label: 'apostrophe:tags' } } }, handlers(self) { return { beforeUpdate: { // Ensure the crop fields are updated on publishing an image async autoCropRelations(req, piece, options) { if (piece.aposMode !== 'published') { return; } await self.updateImageCropRelationships(req, piece); } } }; }, commands(self) { return { remove: [ `${self.__meta.name}:archive-selected` ] }; }, extendRestApiRoutes: (self) => ({ async getAll (_super, req) { const pieces = await _super(req); self.apos.attachment.all(pieces, { annotate: true }); return pieces; } }), apiRoutes: (self) => ({ post: { async autocrop(req) { if (!self.apos.permission.can(req, 'upload-attachment')) { throw self.apos.error('forbidden'); } const widgetOptions = sanitizeOptions(req.body.widgetOptions); if (!widgetOptions.aspectRatio) { return { // This is OK because there will be further sanitization // when we actually save the relationship. At this stage // we only need to sanitize if we're going to autocrop relationship: req.body.relationship }; } const relationship = await sanitizeRelationship(req.body.relationship); for (const image of relationship) { if (!closeEnough(image) && self.apos.attachment.isCroppable(image.attachment)) { await autocrop(image, widgetOptions); } } return { relationship }; async function sanitizeRelationship(input) { const output = []; if (!Array.isArray(input)) { return output; } for (const inputImage of input) { const outputImage = await sanitizeImage(inputImage); if (!outputImage) { continue; } outputImage._fields = sanitizeFields(inputImage); output.push(outputImage); } return output; } function sanitizeOptions(input) { if (input == null) { return {}; } if ((typeof input) !== 'object') { return {}; } if (!input.aspectRatio) { return {}; } if (!Array.isArray(input.aspectRatio)) { return {}; } const w = self.apos.launder.float(input.aspectRatio[0], 0); const h = self.apos.launder.float(input.aspectRatio[1], 0); if ((w <= 0) || (h <= 0)) { return {}; } return { aspectRatio: [ w, h ] }; } function sanitizeFields(inputImage) { const input = inputImage._fields; const output = {}; if ((input == null) || ((typeof input) !== 'object')) { return output; } const props = [ 'top', 'left', 'width', 'height', 'x', 'y' ]; for (const prop of props) { if ((typeof input[prop]) === 'number') { output[prop] = self.apos.launder.integer(input[prop]); if (output[prop] < 0) { return {}; } } } const mandatory = [ 'top', 'left', 'width', 'height' ]; for (const prop of mandatory) { if (!_.has(output, prop)) { return {}; } } if (output.width === 0) { return {}; } if (output.height === 0) { return {}; } if (output.left + output.width > inputImage.attachment.width) { // An older crop that does not work with a new attachment file return {}; } if (output.top + output.height > inputImage.attachment.height) { // An older crop that does not work with a new attachment file return {}; } return output; } function sanitizeImage(input) { if (!input) { return null; } return self.find(req, { _id: self.apos.launder.id(input._id) }).toObject(); } function closeEnough(image) { const testRatio = image._fields ? (image._fields.width / image._fields.height) : (image.attachment.width / image.attachment.height); const configuredRatio = widgetOptions.aspectRatio[0] / widgetOptions.aspectRatio[1]; return withinOnePercent(testRatio, configuredRatio); } async function autocrop(image, widgetOptions) { const crop = self.calculateAutocrop(image, widgetOptions.aspectRatio); await self.apos.attachment.crop(req, image.attachment._id, crop); image._fields = crop; // For ease of testing send back the cropped image URLs now image._crop = crop; await self.all(image, { crop, annotate: true }); } // Compare two ratios and decide if they are within 1% of the // largest of the two function withinOnePercent(a, b) { const max = Math.max(a, b); a = a * 100 / max; b = b * 100 / max; return Math.abs(a - b) < 1; } }, async tag(req) { const title = self.apos.launder.string(req.body.title); const slug = self.apos.launder.string(req.body.slug); const operation = self.apos.launder.string(req.body.operation); if (!Array.isArray(req.body._ids)) { throw self.apos.error('invalid', 'Missing _ids'); } if (![ 'create', 'add', 'remove' ].includes(operation)) { throw self.apos.error('invalid', `"${operation}" is not a valid operation`); } req.body._ids = req.body._ids.map(_id => { return self.inferIdLocaleAndMode(req, self.apos.launder.id(_id)); }); const imageTagManager = self.apos.doc.getManager('@apostrophecms/image-tag'); const tag = (operation === 'create') ? await imageTagManager.insert( req, { ...imageTagManager.newInstance(), title } ) : await imageTagManager.find(req, { slug }).toObject(); return self.apos.modules['@apostrophecms/job'].runBatch( req, req.body._ids, async function(req, id) { const piece = await self.findOneForEditing(req, { _id: id }); if (!piece) { throw self.apos.error('notfound'); } piece._tags = operation === 'remove' ? piece._tags.filter(pieceTag => pieceTag.aposDocId !== tag.aposDocId) : piece._tags .filter(pieceTag => pieceTag.aposDocId !== tag.aposDocId) .concat(tag); await self.update(req, piece); }, { action: 'tag', docTypes: [ self.__meta.name ] } ); } } }), routes(self, options) { return { get: { // Convenience route to get the URL of the image // knowing only the image id. Useful in the rich text editor. // Not performant for frontend use ':imageId/src': async (req, res) => { const size = req.query.size || self.getLargestSize(); try { const image = await self.find(req, { aposDocId: req.params.imageId }).toObject(); if (!image) { return res.status(404).send('notfound'); } const url = image.attachment && image.attachment._urls && image.attachment._urls[size]; if (url) { return res.redirect(image.attachment._urls[size]); } return res.status(404).send('notfound'); } catch (e) { self.apos.util.error(e); return res.status(500).send('error'); } } } }; }, methods(self) { return { // Image docs are attachment containers themselves — their // attachments are discovered via relationship walking from // the content docs that reference them. Iterating all // published images here would make the "used" scope // equivalent to "all", defeating scoped attachment builds. async getAllUrlMetadata() { return { metadata: [], attachmentDocIds: [] }; }, // This method is available as a template helper: apos.image.first // // Find the first image attachment referenced within an object that may // have attachments as properties or sub-properties. // // For best performance be reasonably specific; don't pass an entire page // or piece object if you can pass page.thumbnail to avoid an exhaustive // search, especially if the page has many relationships. // // For ease of use, a null or undefined `within` argument is accepted. // // Note that this method doesn't actually care if the attachment is part // of an `@apostrophecms/image` piece or not. It simply checks whether the // `group` property is set to `images`. // // Examples: // // 1. First image in the body area please // // apos.image.first(page.body) // // 2. Must be a GIF // // apos.image.first(page.body, { extension: 'gif' }) // // (Note Apostrophe always uses .jpg for JPEGs.) // // OPTIONS: // // You may specify `extension` or `extensions` (an array of extensions) // to filter the results. first(within, options) { options = options ? _.clone(options) : {}; options.group = 'images'; const result = self.apos.attachment.first(within, options); return result; }, // This method is available as a template helper: apos.image.all // // Find all image attachments referenced within an object that may have // attachments as properties or sub-properties. // // For best performance be reasonably specific; don't pass an entire page // or piece object if you can pass page.thumbnail to avoid an exhaustive // search, especially if the page has many relationships. // // When available, the `_description`, `_credit` and `_creditUrl` are // also returned as part of the object. // // For ease of use, a null or undefined `within` argument is accepted. // // Note that this method doesn't actually care if the attachment is part // of an `@apostrophecms/image` piece or not. It simply checks whether the // `group` property is set to `images`. // // Examples: // // 1. All images in the body area please // // apos.image.all(page.body) // // 2. Must be GIFs // // apos.image.all(page.body, { extension: 'gif' }) // // (Note Apostrophe always uses .jpg for JPEGs.) // // OPTIONS: // // You may specify `extension` or `extensions` (an array of extensions) // to filter the results. all(within, options) { options = options ? _.clone(options) : {}; options.group = 'images'; return self.apos.attachment.all(within, options); }, // This method is available as a template helper: apos.image.srcset // // Given an image attachment, return a string that can be used as the // value of a `srcset` HTML attribute. srcset(attachment, cropFields) { if (!self.apos.attachment.isSized(attachment)) { return ''; } // Since images are never scaled up once uploaded, we only need to // include a single image size that's larger than the original image // (if such an image size exists) to cover as many bases as possible let includedOriginalWidth = false; const sources = self.apos.attachment.imageSizes.filter(function (imageSize) { if (imageSize.width < attachment.width) { return true; } else if (!includedOriginalWidth) { includedOriginalWidth = true; return true; } else { return false; } }).map(function (imageSize) { const src = self.apos.attachment.url(attachment, { size: imageSize.name, crop: cropFields }); const width = Math.min(imageSize.width, attachment.width); return src + ' ' + width + 'w'; }); return sources.join(', '); }, isCroppable(image) { return image && self.apos.attachment.isCroppable(image.attachment); }, // Make the minimum size, if any, accessible to the templates afterList(req, results) { if (req.body.minSize) { results.minSize = [ self.apos.launder.integer(req.body.minSize[0]), self.apos.launder.integer(req.body.minSize[1]) ]; } }, addManagerModal() { self.apos.modal.add( `${self.__meta.name}:manager`, self.getComponentName('managerModal', 'AposMediaManager'), { moduleName: self.__meta.name } ); }, getLargestSize() { return self.apos.attachment.imageSizes.reduce((a, size) => { return size.width > a.width ? size : a; }, { name: 'dummy', width: 0 }).name; }, // Given an image piece and a aspect ratio array, calculate the crop // that would be applied to the image when autocropping it to the // given aspect ratio. calculateAutocrop(image, aspecRatio) { let crop; const configuredRatio = aspecRatio[0] / aspecRatio[1]; const nativeRatio = image.attachment.width / image.attachment.height; if (configuredRatio >= nativeRatio) { const height = image.attachment.width / configuredRatio; crop = { top: Math.floor((image.attachment.height - height) / 2), left: 0, width: image.attachment.width, height: Math.floor(height) }; } else { const width = image.attachment.height * configuredRatio; crop = { top: 0, left: Math.floor((image.attachment.width - width) / 2), width: Math.floor(width), height: image.attachment.height }; } return crop; }, async updateImageCropRelationships(req, piece) { if (!piece.relatedReverseIds?.length) { return; } if ( !piece._prevAttachmentId || !piece.attachment || piece._prevAttachmentId === piece.attachment._id ) { return; } const croppedIndex = {}; for (const docId of piece.relatedReverseIds) { await self.updateImageCropsForRelationship(req, docId, piece, croppedIndex); } }, // This handler operates on all documents of a given aposDocId. The // `piece` argument is the image piece that has been updated. - Auto // re-crop the image, using the same width/height ratio if the image has // been cropped. - Remove any existing focal point data. // // `croppedIndex` is used to avoid re-cropping the same image when // updating multiple documents. It's internally mutated by the handler. async updateImageCropsForRelationship(req, aposDocId, piece, croppedIndex = {}) { const dbDocs = await self.apos.doc.db.find({ aposDocId }).toArray(); const changeSets = dbDocs.flatMap(doc => getDocRelations(doc, piece)); for (const changeSet of changeSets) { try { const cropFields = await autocrop( changeSet.image, changeSet.cropFields, croppedIndex ); const $set = { [changeSet.docDotPath]: cropFields }; self.logDebug(req, 'replace-autocrop', { docId: changeSet.docId, docTitle: changeSet.doc.title, imageId: piece._id, imageTitle: piece.title, $set }); await self.apos.doc.db.updateOne({ _id: changeSet.docId }, { $set }); } catch (e) { self.apos.util.error(e); await self.apos.notify( req, req.t( 'apostrophe:imageOnReplaceAutocropError', { title: changeSet.doc.title } ), { type: 'danger', dismiss: true } ); } } if (changeSets.length) { return self.apos.notify( req, req.t( 'apostrophe:imageOnReplaceAutocropMessage', { title: changeSets[0].doc.title } ), { type: 'success', dismiss: true } ); } function getDocRelations(doc, imagePiece) { const results = []; self.apos.doc.walk(doc, function (o, key, value, dotPath, ancestors) { if (!value || typeof value !== 'object' || !Array.isArray(value.imageIds)) { return; } if (!value.imageIds.includes(imagePiece.aposDocId)) { return; } if (!value.imageFields?.[imagePiece.aposDocId]) { return; } const cropFields = value.imageFields[imagePiece.aposDocId]; // We check for crop OR focal point data (because // focal point has to be reset when the image is replaced). if (!cropFields.width && typeof cropFields.x !== 'number') { return; } results.push({ docId: doc._id, docDotPath: `${dotPath}.imageFields.${imagePiece.aposDocId}`, doc, cropFields, image: imagePiece, value }); }); return results; } async function autocrop(image, oldFields, croppedIndex) { let crop = { ...oldFields }; if (crop.width) { crop = self.calculateAutocrop(image, [ crop.width, crop.height ]); } const hash = cropHash(image, crop); if (crop.width && !croppedIndex[hash]) { await self.apos.attachment.crop(req, image.attachment._id, crop); croppedIndex[hash] = true; } return { ...crop, x: null, y: null }; } function cropHash(image, crop) { return `${image.attachment._id}-${crop.top}-${crop.left}-${crop.width}-${crop.height}`; } } }; }, extendMethods(self) { return { getRelationshipQueryBuilderChoicesProjection(_super, query) { const projection = _super(query); return { ...projection, attachment: 1 }; }, getBrowserData(_super, req) { const data = _super(req); data.components.managerModal = 'AposMediaManager'; return data; } }; }, helpers(self) { return { first(within, options) { if (!within) { return null; } return self.first(within, options); }, all(within, options) { if (!within) { return []; } return self.all(within, options); }, srcset(attachment, cropFields) { if (!attachment) { return ''; } return self.srcset(attachment, cropFields); }, isCroppable(image) { if (!image) { return false; } return self.isCroppable(image); } }; }, queries(self, query) { return { builders: { aspectRatio: { launder(a) { if (!Array.isArray(a)) { return undefined; } if (a.length !== 2) { return undefined; } return [ self.apos.launder.integer(a[0]), self.apos.launder.integer(a[1]) ]; } }, minSize: { finalize() { const minSize = query.get('minSize'); const aspectRatio = query.get('aspectRatio'); if (!minSize) { return; } const { minWidth, minHeight } = computeMinSizes(minSize, aspectRatio); const $nin = Object .keys(self.apos.attachment.sized) .filter(key => self.apos.attachment.sized[key]); const criteria = { $or: [ { 'attachment.extension': { $nin } }, { 'attachment.width': { $gte: minWidth }, 'attachment.height': { $gte: minHeight } } ] }; query.and(criteria); }, launder(a) { if (!Array.isArray(a)) { return undefined; } if (a.length !== 2) { return undefined; } return [ self.apos.launder.integer(a[0]), self.apos.launder.integer(a[1]) ]; } }, prevAttachment: { after(results) { for (const result of results) { if (result.attachment) { result._prevAttachmentId = result.attachment._id; } } } } } }; } };