apostrophe
Version:
The Apostrophe Content Management System.
823 lines (800 loc) • 25.8 kB
JavaScript
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;
}
}
}
}
}
};
}
};