diff --git a/blocks/CloudImageEditor/src/lib/transformationUtils.js b/blocks/CloudImageEditor/src/lib/transformationUtils.js index 4806aec32..d9e436828 100644 --- a/blocks/CloudImageEditor/src/lib/transformationUtils.js +++ b/blocks/CloudImageEditor/src/lib/transformationUtils.js @@ -1,7 +1,8 @@ +// @ts-check import { joinCdnOperations } from '../../../../utils/cdn-utils.js'; import { stringToArray } from '../../../../utils/stringToArray.js'; -export const OPERATIONS_ZEROS = { +export const OPERATIONS_ZEROS = Object.freeze({ brightness: 0, exposure: 0, gamma: 100, @@ -12,61 +13,62 @@ export const OPERATIONS_ZEROS = { enhance: 0, filter: 0, rotate: 0, -}; + mirror: false, +}); + +const SUPPORTED_OPERATIONS_ORDERED = /** @type {const} */ ([ + 'enhance', + 'brightness', + 'exposure', + 'gamma', + 'contrast', + 'saturation', + 'vibrance', + 'warmth', + 'filter', + 'mirror', + 'flip', + 'rotate', + 'crop', +]); /** - * @param {String} operation - * @param {Number | String | object} options - * @returns {String} + * @template {keyof import('../types').Transformations} T + * @param {T} operation + * @param {import('../types').Transformations[T]} options */ function transformationToStr(operation, options) { if (typeof options === 'number') { - return OPERATIONS_ZEROS[operation] !== options ? `${operation}/${options}` : ''; + const value = options; + return OPERATIONS_ZEROS[/** @type {keyof typeof OPERATIONS_ZEROS} */ (operation)] !== value + ? `${operation}/${value}` + : ''; } if (typeof options === 'boolean') { - return options && OPERATIONS_ZEROS[operation] !== options ? `${operation}` : ''; + const value = options; + return OPERATIONS_ZEROS[/** @type {keyof typeof OPERATIONS_ZEROS} */ (operation)] !== value ? `${operation}` : ''; } - if (operation === 'filter') { - if (!options || OPERATIONS_ZEROS[operation] === options.amount) { + if (operation === 'filter' && options) { + const { name, amount } = /** @type {NonNullable} */ (options); + if (OPERATIONS_ZEROS.filter === amount) { return ''; } - let { name, amount } = options; return `${operation}/${name}/${amount}`; } - if (operation === 'crop') { - if (!options) { - return ''; - } - let { dimensions, coords } = options; + if (operation === 'crop' && options) { + let { dimensions, coords } = /** @type {NonNullable} */ (options); return `${operation}/${dimensions.join('x')}/${coords.join(',')}`; } return ''; } -// TODO: refactor all the operations constants -const SUPPORTED_OPERATIONS_ORDERED = [ - 'enhance', - 'brightness', - 'exposure', - 'gamma', - 'contrast', - 'saturation', - 'vibrance', - 'warmth', - 'filter', - 'mirror', - 'flip', - 'rotate', - 'crop', -]; - /** * @param {import('../types').Transformations} transformations - * @returns {String} + * @returns {string} */ export function transformationsToOperations(transformations) { return joinCdnOperations( @@ -83,22 +85,39 @@ export function transformationsToOperations(transformations) { export const COMMON_OPERATIONS = joinCdnOperations('format/auto', 'progressive/yes'); +/** @param {[unknown]} arg */ const asNumber = ([value]) => (typeof value !== 'undefined' ? Number(value) : undefined); const asBoolean = () => true; +/** @param {[string, unknown]} arg */ const asFilter = ([name, amount]) => ({ name, amount: typeof amount !== 'undefined' ? Number(amount) : 100, }); -// Docs: https://uploadcare.com/docs/transformations/image/resize-crop/#operation-crop -// We don't support percentages and aligment presets, -// Because it's unclear how to handle them in the Editor UI -// TODO: add support for percentages and aligment presets -const asCrop = ([dimensions, coords]) => { - return { dimensions: stringToArray(dimensions, 'x').map(Number), coords: stringToArray(coords).map(Number) }; +/** + * Docs: https://uploadcare.com/docs/transformations/image/resize-crop/#operation-crop We don't support percentages and + * alignment presets, Because it's unclear how to handle them in the Editor UI TODO: add support for percentages and + * alignment presets + * + * @param {[string, string]} arg + */ +const asCrop = ([dimensions, alignment]) => { + if (!/\d+x\d+/.test(dimensions) || !/\d+,\d+/.test(alignment)) { + throw new Error('Crop by aspect ratio, percentage or alignment shortcuts is not supported.'); + } + + return /** @type {{ dimensions: [number, number]; coords: [number, number] }} */ ({ + dimensions: stringToArray(dimensions, 'x').map(Number), + coords: stringToArray(alignment).map(Number), + }); }; -const OPERATION_PROCESSORS = { +/** + * @type {{ + * [K in keyof Required]: (args: any) => import('../types').Transformations[K]; + * }} + */ +const OPERATION_PROCESSORS = Object.freeze({ enhance: asNumber, brightness: asNumber, exposure: asNumber, @@ -112,23 +131,35 @@ const OPERATION_PROCESSORS = { flip: asBoolean, rotate: asNumber, crop: asCrop, -}; +}); /** * @param {string[]} operations * @returns {import('../types.js').Transformations} */ export function operationsToTransformations(operations) { - /** @type {import('../types.js').Transformations} */ - let transformations = {}; - for (let operation of operations) { - let [name, ...args] = operation.split('/'); + /** @type {Record} */ + const transformations = {}; + for (const operation of operations) { + const [name, ...args] = operation.split('/'); if (!SUPPORTED_OPERATIONS_ORDERED.includes(name)) { continue; } - const processor = OPERATION_PROCESSORS[name]; - const value = processor(args); - transformations[name] = value; + const operationName = /** @type {(typeof SUPPORTED_OPERATIONS_ORDERED)[number]} */ (name); + const processor = OPERATION_PROCESSORS[operationName]; + try { + const value = processor(args); + transformations[operationName] = value; + } catch (err) { + console.warn( + [ + `Failed to parse URL operation "${operation}". It will be ignored.`, + err instanceof Error ? `Error message: "${err.message}"` : err, + 'If you need this functionality, please feel free to open an issue at https://github.com/uploadcare/blocks/issues/new', + ].join('\n') + ); + } } - return transformations; + + return /** @type {import('../types.js').Transformations} */ (transformations); } diff --git a/blocks/CloudImageEditor/src/types.js b/blocks/CloudImageEditor/src/types.js index 6ed699650..9e247d406 100644 --- a/blocks/CloudImageEditor/src/types.js +++ b/blocks/CloudImageEditor/src/types.js @@ -19,20 +19,21 @@ */ /** - * @typedef {Object} Transformations - * @property {number} [enhance] - * @property {number} [brightness] - * @property {number} [exposure] - * @property {number} [gamma] - * @property {number} [contrast] - * @property {number} [saturation] - * @property {number} [vibrance] - * @property {number} [warmth] - * @property {number} [rotate] - * @property {boolean} [mirror] - * @property {boolean} [flip] - * @property {{ name: string; amount: number }} [filter] - * @property {{ dimensions: [number, number]; coords: [number, number] }} [crop] + * @typedef {{ + * enhance?: number; + * brightness?: number; + * exposure?: number; + * gamma?: number; + * contrast?: number; + * saturation?: number; + * vibrance?: number; + * warmth?: number; + * rotate?: number; + * mirror?: boolean; + * flip?: boolean; + * filter?: { name: string; amount: number }; + * crop?: { dimensions: [number, number]; coords: [number, number] }; + * }} Transformations */ /**