diff --git a/blocks/CloudImageEditor/CloudImageEditor.js b/blocks/CloudImageEditor/CloudImageEditor.js index 9b39fdd0f..2c490725d 100644 --- a/blocks/CloudImageEditor/CloudImageEditor.js +++ b/blocks/CloudImageEditor/CloudImageEditor.js @@ -7,7 +7,7 @@ export class CloudImageEditor extends UploaderBlock { init$ = { ...this.ctxInit, - uuid: null, + cdnUrl: null, }; initCallback() { @@ -25,9 +25,9 @@ export class CloudImageEditor extends UploaderBlock { } this.entry = entry; - this.entry.subscribe('uuid', (uuid) => { - if (uuid) { - this.$.uuid = uuid; + this.entry.subscribe('cdnUrl', (cdnUrl) => { + if (cdnUrl) { + this.$.cdnUrl = cdnUrl; } }); }); @@ -50,8 +50,8 @@ export class CloudImageEditor extends UploaderBlock { mountEditor() { let instance = new CloudEditor(); instance.classList.add('lr-cldtr-common'); - let uuid = this.$.uuid; - instance.setAttribute('uuid', uuid); + let cdnUrl = this.$.cdnUrl; + instance.setAttribute('cdn-url', cdnUrl); instance.addEventListener('apply', (result) => this.handleApply(result)); instance.addEventListener('cancel', () => this.handleCancel()); diff --git a/blocks/CloudImageEditor/ref.htm b/blocks/CloudImageEditor/ref.htm index 2e37d29f6..768f11b34 100644 --- a/blocks/CloudImageEditor/ref.htm +++ b/blocks/CloudImageEditor/ref.htm @@ -1,9 +1,21 @@

Cloud image editor

+

Load image by UUID

+ - \ No newline at end of file + + +

Load image by CDN URL

+ + + + + + + + diff --git a/blocks/CloudImageEditor/src/CloudEditor.js b/blocks/CloudImageEditor/src/CloudEditor.js index 7a6d68c83..600336e94 100644 --- a/blocks/CloudImageEditor/src/CloudEditor.js +++ b/blocks/CloudImageEditor/src/CloudEditor.js @@ -1,13 +1,18 @@ import { Block } from '../../../abstract/Block.js'; -import { createCdnUrl, createCdnUrlModifiers, createOriginalUrl } from '../../../utils/cdn-utils.js'; +import { + createCdnUrl, + createCdnUrlModifiers, + createOriginalUrl, + extractOperations, + extractUuid, +} from '../../../utils/cdn-utils.js'; +import { TRANSPARENT_PIXEL_SRC } from '../../../utils/transparentPixelSrc.js'; import { classNames } from './lib/classNames.js'; import { debounce } from './lib/debounce.js'; -import { preloadImage } from './lib/preloadImage.js'; -import { TRANSPARENT_PIXEL_SRC } from '../../../utils/transparentPixelSrc.js'; +import { operationsToTransformations } from './lib/transformationUtils.js'; import { initState } from './state.js'; import { TEMPLATE } from './template.js'; import { TabId } from './toolbar-constants.js'; -import { viewerImageSrc } from './util.js'; export class CloudEditor extends Block { get ctxName() { @@ -26,69 +31,22 @@ export class CloudEditor extends Block { this.$.showLoader = show; } - _loadImageFromCdn() { - this._debouncedShowLoader(true); - let src = this._imageSrc(); - let { promise, cancel } = preloadImage(src); - promise - .then(() => { - this.$.src = src; - }) - .catch((err) => { - this.$['*networkProblems'] = true; - this._debouncedShowLoader(false); - this.$.src = src; - }); - this._cancelPreload && this._cancelPreload(); - this._cancelPreload = cancel; - } - - _imageSrc() { - let { width } = this.ref['img-container-el'].getBoundingClientRect(); - return this.proxyUrl(viewerImageSrc(this.$['*originalUrl'], width, {})); - } - - /** - * To proper work, we need non-zero size the element. So, we'll wait for it. - * - * @private - * @returns {Promise} - */ - _waitForSize() { - return new Promise((resolve, reject) => { - let timeout = 300; - let start = Date.now(); - - let callback = () => { - // there could be problem when element disconnected and connected again between ticks - if (!this.isConnected) { - clearInterval(interval); - reject(); - return; - } - if (Date.now() - start > timeout) { - clearInterval(interval); - reject(new Error('[cloud-image-editor] timout waiting for non-zero container size')); - return; - } - let { width, height } = this.getBoundingClientRect(); - - if (width > 0 && height > 0) { - clearInterval(interval); - resolve(); - } - }; - let interval = setInterval(callback, 50); - callback(); - }); - } - cssInit$ = { '--cfg-cdn-cname': 'https://ucarecdn.com', }; async initCallback() { - this.$['*originalUrl'] = createOriginalUrl(this.localCtx.read('--cfg-cdn-cname'), this.$.uuid); + if (this.$.cdnUrl) { + let uuid = extractUuid(this.$.cdnUrl); + this.$['*originalUrl'] = createOriginalUrl(this.$.cdnUrl, uuid); + let operations = extractOperations(this.$.cdnUrl); + let transformations = operationsToTransformations(operations); + this.$['*editorTransformations'] = transformations; + } else if (this.$.uuid) { + this.$['*originalUrl'] = createOriginalUrl(this.localCtx.read('--cfg-cdn-cname'), this.$.uuid); + } else { + throw new Error('No UUID nor CDN URL provided'); + } this.$['*faderEl'] = this.ref['fader-el']; this.$['*cropperEl'] = this.ref['cropper-el']; @@ -139,8 +97,6 @@ export class CloudEditor extends Block { .then(({ width, height }) => { this.$['*imageSize'] = { width, height }; }); - await this._waitForSize(); - this._loadImageFromCdn(); } catch (err) { if (err) { console.error('Failed to load image info', err); @@ -152,4 +108,5 @@ export class CloudEditor extends Block { CloudEditor.template = TEMPLATE; CloudEditor.bindAttributes({ uuid: 'uuid', + 'cdn-url': 'cdnUrl', }); diff --git a/blocks/CloudImageEditor/src/EditorFilterControl.js b/blocks/CloudImageEditor/src/EditorFilterControl.js index 2657cd258..7c3c0fed1 100644 --- a/blocks/CloudImageEditor/src/EditorFilterControl.js +++ b/blocks/CloudImageEditor/src/EditorFilterControl.js @@ -61,9 +61,7 @@ export class EditorFilterControl extends EditorButtonControl { }) .finally(() => { previewEl.style.backgroundImage = `url(${src})`; - setTimeout(() => { - previewEl.style.opacity = '1'; - }); + previewEl.setAttribute('loaded', ''); observer.unobserve(this); }); diff --git a/blocks/CloudImageEditor/src/EditorToolbar.js b/blocks/CloudImageEditor/src/EditorToolbar.js index 6193e0ab2..e2fae02a3 100644 --- a/blocks/CloudImageEditor/src/EditorToolbar.js +++ b/blocks/CloudImageEditor/src/EditorToolbar.js @@ -6,7 +6,14 @@ import { FAKE_ORIGINAL_FILTER } from './EditorSlider.js'; import { classNames } from './lib/classNames.js'; import { debounce } from './lib/debounce.js'; import { batchPreloadImages } from './lib/preloadImage.js'; -import { ALL_COLOR_OPERATIONS, ALL_CROP_OPERATIONS, ALL_FILTERS, TabId, TABS } from './toolbar-constants.js'; +import { + ALL_COLOR_OPERATIONS, + ALL_CROP_OPERATIONS, + ALL_FILTERS, + COLOR_OPERATIONS_CONFIG, + TabId, + TABS, +} from './toolbar-constants.js'; import { viewerImageSrc } from './util.js'; /** @param {String} id */ @@ -47,12 +54,10 @@ export class EditorToolbar extends Block { /** @type {import('./types.js').LoadingOperations} */ '*loadingOperations': new Map(), '*showSlider': false, - /** @type {import('./types.js').Transformations} */ - '*editorTransformations': {}, '*currentFilter': FAKE_ORIGINAL_FILTER, '*currentOperation': null, + '*tabId': TabId.CROP, showLoader: false, - tabId: TabId.CROP, filters: ALL_FILTERS, colorOperations: ALL_COLOR_OPERATIONS, cropOperations: ALL_CROP_OPERATIONS, @@ -104,18 +109,11 @@ export class EditorToolbar extends Block { this._debouncedShowLoader = debounce(this._showLoader.bind(this), 500); } - get tabId() { - return this.$.tabId; - } - /** @private */ _onSliderClose() { this.$['*showSlider'] = false; - if (this.$.tabId === TabId.SLIDERS) { - this.ref['tooltip-el'].className = classNames('filter-tooltip', { - 'filter-tooltip_visible': false, - 'filter-tooltip_hidden': true, - }); + if (this.$['*tabId'] === TabId.SLIDERS) { + this.ref['tooltip-el'].classList.toggle('info-tooltip_visible', false); } } @@ -193,7 +191,7 @@ export class EditorToolbar extends Block { * @param {{ fromViewer?: Boolean }} options */ _activateTab(id, { fromViewer }) { - this.$.tabId = id; + this.$['*tabId'] = id; if (id === TabId.CROP) { this.$['*faderEl'].deactivate(); @@ -232,7 +230,7 @@ export class EditorToolbar extends Block { /** @private */ _syncTabIndicator() { - let tabToggleEl = this.ref[`tab-toggle-${this.$.tabId}`]; + let tabToggleEl = this.ref[`tab-toggle-${this.$['*tabId']}`]; let indicatorEl = this.ref['tabs-indicator']; indicatorEl.style.transform = `translateX(${tabToggleEl.offsetLeft}px)`; } @@ -256,6 +254,31 @@ export class EditorToolbar extends Block { this.$.showLoader = show; } + _updateInfoTooltip = debounce(() => { + let transformations = this.$['*editorTransformations']; + let text = ''; + let visible = false; + + if (this.$['*tabId'] === TabId.FILTERS) { + visible = true; + if (this.$['*currentFilter'] && transformations?.filter?.name === this.$['*currentFilter']) { + let value = transformations?.filter?.amount || 100; + text = this.l10n(this.$['*currentFilter']) + ' ' + value; + } else { + text = this.l10n(FAKE_ORIGINAL_FILTER); + } + } else if (this.$['*tabId'] === TabId.SLIDERS && this.$['*currentOperation']) { + visible = true; + let value = + transformations?.[this.$['*currentOperation']] || COLOR_OPERATIONS_CONFIG[this.$['*currentOperation']].zero; + text = this.$['*currentOperation'] + ' ' + value; + } + if (visible) { + this.$['*operationTooltip'] = text; + } + this.ref['tooltip-el'].classList.toggle('info-tooltip_visible', visible); + }, 0); + initCallback() { super.initCallback(); @@ -264,38 +287,28 @@ export class EditorToolbar extends Block { this.sub('*imageSize', (imageSize) => { if (imageSize) { setTimeout(() => { - this._activateTab(this.$.tabId, { fromViewer: true }); + this._activateTab(this.$['*tabId'], { fromViewer: true }); }, 0); } }); - this.sub('*currentFilter', (currentFilter) => { - this.$['*operationTooltip'] = this.l10n(currentFilter || FAKE_ORIGINAL_FILTER); - this.ref['tooltip-el'].className = classNames('filter-tooltip', { - 'filter-tooltip_visible': currentFilter, - 'filter-tooltip_hidden': !currentFilter, - }); + this.sub('*editorTransformations', (editorTransformations) => { + let appliedFilter = editorTransformations?.filter?.name; + if (this.$['*currentFilter'] !== appliedFilter) { + this.$['*currentFilter'] = appliedFilter; + } }); - this.sub('*currentOperation', (currentOperation) => { - if (this.$.tabId !== TabId.SLIDERS) { - return; - } - this.$['*operationTooltip'] = currentOperation; - this.ref['tooltip-el'].className = classNames('filter-tooltip', { - 'filter-tooltip_visible': currentOperation, - 'filter-tooltip_hidden': !currentOperation, - }); + this.sub('*currentFilter', () => { + this._updateInfoTooltip(); }); - this.sub('*tabId', (tabId) => { - if (tabId === TabId.FILTERS) { - this.$['*operationTooltip'] = this.$['*currentFilter']; - } - this.ref['tooltip-el'].className = classNames('filter-tooltip', { - 'filter-tooltip_visible': tabId === TabId.FILTERS, - 'filter-tooltip_hidden': tabId !== TabId.FILTERS, - }); + this.sub('*currentOperation', () => { + this._updateInfoTooltip(); + }); + + this.sub('*tabId', () => { + this._updateInfoTooltip(); }); this.sub('*originalUrl', (originalUrl) => { @@ -329,14 +342,16 @@ export class EditorToolbar extends Block { this.$['presence.subToolbar'] = showSlider; this.$['presence.mainToolbar'] = !showSlider; }); + + this._updateInfoTooltip(); } } EditorToolbar.template = /* HTML */ ` -
-
-
{{*operationTooltip}}
+
+
+
{{*operationTooltip}}
diff --git a/blocks/CloudImageEditor/src/css/common.css b/blocks/CloudImageEditor/src/css/common.css index ae0eaca27..331cb23da 100644 --- a/blocks/CloudImageEditor/src/css/common.css +++ b/blocks/CloudImageEditor/src/css/common.css @@ -377,6 +377,14 @@ lr-editor-filter-control.active { --idle-color-rgb: var(--rgb-primary-accent); } +lr-editor-filter-control.not_active .preview[loaded] { + opacity: 1; +} + +lr-editor-filter-control.active .preview { + opacity: 0; +} + lr-editor-button-control.not_active, lr-editor-operation-control.not_active, lr-editor-crop-button-control.not_active, @@ -687,7 +695,7 @@ lr-editor-toolbar .controls-list_last-item { margin-right: var(--cldtr-gap-max); } -lr-editor-toolbar .filter-tooltip_container { +lr-editor-toolbar .info-tooltip_container { position: absolute; display: flex; align-items: flex-start; @@ -696,7 +704,7 @@ lr-editor-toolbar .filter-tooltip_container { height: 100%; } -lr-editor-toolbar .filter-tooltip_wrapper { +lr-editor-toolbar .info-tooltip_wrapper { position: absolute; top: calc(-100% - var(--cldtr-gap-mid-2)); display: flex; @@ -706,7 +714,7 @@ lr-editor-toolbar .filter-tooltip_wrapper { pointer-events: none; } -lr-editor-toolbar .filter-tooltip { +lr-editor-toolbar .info-tooltip { z-index: 3; padding-top: calc(var(--cldtr-gap-min) / 2); padding-right: var(--cldtr-gap-min); @@ -718,20 +726,16 @@ lr-editor-toolbar .filter-tooltip { text-transform: uppercase; background-color: var(--color-text-accent-contrast); border-radius: var(--border-radius-editor); + transform: translateY(100%); opacity: 0; transition: var(--transition-duration-3); } -lr-editor-toolbar .filter-tooltip_visible { +lr-editor-toolbar .info-tooltip_visible { transform: translateY(0px); opacity: 1; } -lr-editor-toolbar .filter-tooltip_hidden { - transform: translateY(100%); - opacity: 0; -} - lr-editor-toolbar .slider { padding-right: var(--l-slider-padding); padding-left: var(--l-slider-padding); diff --git a/blocks/CloudImageEditor/src/lib/transformationUtils.js b/blocks/CloudImageEditor/src/lib/transformationUtils.js index 44ea60b73..862498ea1 100644 --- a/blocks/CloudImageEditor/src/lib/transformationUtils.js +++ b/blocks/CloudImageEditor/src/lib/transformationUtils.js @@ -46,7 +46,8 @@ function transformationToStr(operation, options) { return ''; } -const ORDER = [ +// TODO: refactor all the operations constants +const SUPPORTED_OPERATIONS_ORDERED = [ 'enhance', 'brightness', 'exposure', @@ -68,7 +69,7 @@ const ORDER = [ */ export function transformationsToOperations(transformations) { return joinCdnOperations( - ...ORDER.filter( + ...SUPPORTED_OPERATIONS_ORDERED.filter( (operation) => typeof transformations[operation] !== 'undefined' && transformations[operation] !== null ) .map((operation) => { @@ -80,3 +81,53 @@ export function transformationsToOperations(transformations) { } export const COMMON_OPERATIONS = joinCdnOperations('format/auto', 'progressive/yes'); + +const asNumber = ([value]) => (typeof value !== 'undefined' ? Number(value) : undefined); +const asBoolean = () => true; +const asFilter = ([name, amount]) => ({ + name, + amount: Number(amount), +}); + +// 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: dimensions.split('x').map(Number), coords: coords.split(',').map(Number) }; +}; + +const OPERATION_PROCESSORS = { + enhance: asNumber, + brightness: asNumber, + exposure: asNumber, + gamma: asNumber, + contrast: asNumber, + saturation: asNumber, + vibrance: asNumber, + warmth: asNumber, + filter: asFilter, + mirror: asBoolean, + 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('/'); + if (!SUPPORTED_OPERATIONS_ORDERED.includes(name)) { + continue; + } + const processor = OPERATION_PROCESSORS[name]; + const value = processor(args); + transformations[name] = value; + } + return transformations; +} diff --git a/blocks/CloudImageEditor/src/state.js b/blocks/CloudImageEditor/src/state.js index 4a4bd85ca..c1a61c4c1 100644 --- a/blocks/CloudImageEditor/src/state.js +++ b/blocks/CloudImageEditor/src/state.js @@ -6,13 +6,14 @@ import { TRANSPARENT_PIXEL_SRC } from '../../../utils/transparentPixelSrc.js'; export function initState(fnCtx) { return { '*originalUrl': null, - '*tabId': null, '*faderEl': null, '*cropperEl': null, '*imgEl': null, '*imgContainerEl': null, '*networkProblems': false, '*imageSize': null, + /** @type {import('./types.js').Transformations} */ + '*editorTransformations': {}, entry: null, extension: null, @@ -24,6 +25,7 @@ export function initState(fnCtx) { fileType: '', showLoader: false, uuid: null, + cdnUrl: null, 'presence.networkProblems': false, 'presence.modalCaption': true, diff --git a/utils/cdn-utils.js b/utils/cdn-utils.js index 7ba796816..c234adc6b 100644 --- a/utils/cdn-utils.js +++ b/utils/cdn-utils.js @@ -68,6 +68,41 @@ export function extractFilename(cdnUrl) { return filename; } +/** + * Extract UUID from CDN URL + * + * @param {string} cdnUrl + * @returns {string} + */ +export function extractUuid(cdnUrl) { + let url = new URL(cdnUrl); + let { pathname } = url; + const slashIndex = pathname.indexOf('/'); + const secondSlashIndex = pathname.indexOf('/', slashIndex + 1); + return pathname.substring(slashIndex + 1, secondSlashIndex); +} + +/** + * Extract UUID from CDN URL + * + * @param {string} cdnUrl + * @returns {string[]} + */ +export function extractOperations(cdnUrl) { + let withoutFilename = trimFilename(cdnUrl); + let url = new URL(withoutFilename); + let operationsMarker = url.pathname.indexOf('/-/'); + if (operationsMarker === -1) { + return []; + } + let operationsStr = url.pathname.substring(operationsMarker); + + return operationsStr + .split('/-/') + .filter(Boolean) + .map((operation) => normalizeCdnOperation(operation)); +} + /** * Trim filename or file URL * diff --git a/utils/cdn-utils.test.js b/utils/cdn-utils.test.js index 23ec899d2..11583cee9 100644 --- a/utils/cdn-utils.test.js +++ b/utils/cdn-utils.test.js @@ -7,6 +7,8 @@ import { createOriginalUrl, extractFilename, trimFilename, + extractUuid, + extractOperations, } from './cdn-utils.js'; const falsyValues = ['', undefined, null, false, true, 0, 10]; @@ -183,3 +185,29 @@ describe('cdn-utils/trimFilename', () => { ); }); }); + +describe('cdn-utils/extractUuid', () => { + it('should extract uuid from cdn url', () => { + expect(extractUuid('https://ucarecdn.com/:uuid/image.jpeg')).to.eq(':uuid'); + expect(extractUuid('https://ucarecdn.com/:uuid/-/resize/100x/image.jpeg')).to.eq(':uuid'); + + expect(extractUuid('https://ucarecdn.com/c2499162-eb07-4b93-b31e-94a89a47e858/image.jpeg')).to.eq( + 'c2499162-eb07-4b93-b31e-94a89a47e858' + ); + expect(extractUuid('https://ucarecdn.com/c2499162-eb07-4b93-b31e-94a89a47e858/-/resize/100x/image.jpeg')).to.eq( + 'c2499162-eb07-4b93-b31e-94a89a47e858' + ); + }); +}); + +describe('cdn-utils/extractOperations', () => { + it('should extract operations from cdn url', () => { + expect(extractOperations('https://ucarecdn.com/:uuid/image.jpeg')).to.eql([]); + expect( + extractOperations('https://ucarecdn.com/c2499162-eb07-4b93-b31e-94a89a47e858/-/resize/100x/image.jpeg') + ).to.eql(['resize/100x']); + expect(extractOperations('https://domain.ucr.io:8080/-/resize/100x/https://domain.com/image.jpg?q=1#hash')).to.eql([ + 'resize/100x', + ]); + }); +});