From 20ed62fa2daec3f7e86ff0d56350a6780c3d5ea0 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 20 Sep 2023 17:37:53 +0300 Subject: [PATCH 1/7] chore: update typescript --- package-lock.json | 30 +++++++++++++++--------------- package.json | 4 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index c97837ed0..1fec86e4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@esm-bundle/chai": "^4.3.4-fix.0", "@happy-dom/global-registrator": "^9.8.4", "@jam-do/jam-tools": "^0.0.4", - "@total-typescript/ts-reset": "^0.4.2", + "@total-typescript/ts-reset": "^0.5.1", "@types/chai": "^4.3.4", "@types/mocha": "^10.0.1", "@types/node": "^18.15.11", @@ -44,7 +44,7 @@ "stylelint-config-standard": "^32.0.0", "stylelint-declaration-block-no-ignored-properties": "^2.7.0", "stylelint-order": "^6.0.3", - "typescript": "^5.0.4" + "typescript": "^5.2.2" } }, "node_modules/@75lb/deep-merge": { @@ -2674,9 +2674,9 @@ "integrity": "sha512-fUOJwzuldeApJ533YeTdrfnpp4nsA+ss1eiNBodX7RHf4LnhPB2Z9HP4fF3m2YhKYnxK0whjXaKA+wrxTRP5qA==" }, "node_modules/@total-typescript/ts-reset": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.4.2.tgz", - "integrity": "sha512-vqd7ZUDSrXFVT1n8b2kc3LnklncDQFPvR58yUS1kEP23/nHPAO9l1lMjUfnPrXYYk4Hj54rrLKMW5ipwk7k09A==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.5.1.tgz", + "integrity": "sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==", "dev": true }, "node_modules/@types/accepts": { @@ -12225,16 +12225,16 @@ } }, "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=14.17" } }, "node_modules/typical": { @@ -14599,9 +14599,9 @@ "integrity": "sha512-fUOJwzuldeApJ533YeTdrfnpp4nsA+ss1eiNBodX7RHf4LnhPB2Z9HP4fF3m2YhKYnxK0whjXaKA+wrxTRP5qA==" }, "@total-typescript/ts-reset": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.4.2.tgz", - "integrity": "sha512-vqd7ZUDSrXFVT1n8b2kc3LnklncDQFPvR58yUS1kEP23/nHPAO9l1lMjUfnPrXYYk4Hj54rrLKMW5ipwk7k09A==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.5.1.tgz", + "integrity": "sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==", "dev": true }, "@types/accepts": { @@ -21711,9 +21711,9 @@ } }, "typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true }, "typical": { diff --git a/package.json b/package.json index 8b7b53ed2..c0b539d8c 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ "@esm-bundle/chai": "^4.3.4-fix.0", "@happy-dom/global-registrator": "^9.8.4", "@jam-do/jam-tools": "^0.0.4", - "@total-typescript/ts-reset": "^0.4.2", + "@total-typescript/ts-reset": "^0.5.1", "@types/chai": "^4.3.4", "@types/mocha": "^10.0.1", "@types/node": "^18.15.11", @@ -124,7 +124,7 @@ "stylelint-config-standard": "^32.0.0", "stylelint-declaration-block-no-ignored-properties": "^2.7.0", "stylelint-order": "^6.0.3", - "typescript": "^5.0.4" + "typescript": "^5.2.2" }, "author": "Uploadcare Inc.", "license": "MIT", From fe00c37090cd7dcf2ad3263ec4ab3d35ead20c91 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 20 Sep 2023 17:38:32 +0300 Subject: [PATCH 2/7] chore: fix ts errors --- blocks/Config/Config.js | 24 +++--- blocks/ExternalSource/ExternalSource.js | 33 ++++---- blocks/FileItem/FileItem.js | 101 ++++++++++++------------ blocks/Modal/Modal.js | 18 +++-- blocks/SimpleBtn/SimpleBtn.js | 19 +++-- blocks/UploadList/UploadList.js | 73 +++++++++-------- 6 files changed, 143 insertions(+), 125 deletions(-) diff --git a/blocks/Config/Config.js b/blocks/Config/Config.js index 680adac21..0368efbf9 100644 --- a/blocks/Config/Config.js +++ b/blocks/Config/Config.js @@ -41,16 +41,20 @@ const attrStateMapping = /** @type {Record [ - sharedConfigKey(/** @type {keyof import('../../types').ConfigType} */ (key)), - value, - ]) - ), - }; + constructor() { + super(); + + /** @type {Block['init$'] & import('../../types').ConfigType} */ + this.init$ = { + ...this.init$, + ...Object.fromEntries( + Object.entries(initialConfig).map(([key, value]) => [ + sharedConfigKey(/** @type {keyof import('../../types').ConfigType} */ (key)), + value, + ]) + ), + }; + } initCallback() { super.initCallback(); diff --git a/blocks/ExternalSource/ExternalSource.js b/blocks/ExternalSource/ExternalSource.js index ad2c6cfc7..31956b8d1 100644 --- a/blocks/ExternalSource/ExternalSource.js +++ b/blocks/ExternalSource/ExternalSource.js @@ -33,19 +33,22 @@ import { queryString } from './query-string.js'; export class ExternalSource extends UploaderBlock { activityType = ActivityBlock.activities.EXTERNAL; - // @ts-ignore TODO: fix this - init$ = { - ...this.init$, - activityIcon: '', - activityCaption: '', - counter: 0, - onDone: () => { - this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; - }, - onCancel: () => { - this.historyBack(); - }, - }; + constructor() { + super(); + + this.init$ = { + ...this.init$, + activityIcon: '', + activityCaption: '', + counter: 0, + onDone: () => { + this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; + }, + onCancel: () => { + this.historyBack(); + }, + }; + } /** * @private @@ -116,14 +119,14 @@ export class ExternalSource extends UploaderBlock { this.applyStyles(); } - /** @private */ - _inheritedUpdateCssData = this.updateCssData; updateCssData = () => { if (this.isActivityActive) { this._inheritedUpdateCssData(); this.applyStyles(); } }; + /** @private */ + _inheritedUpdateCssData = this.updateCssData; /** * @private diff --git a/blocks/FileItem/FileItem.js b/blocks/FileItem/FileItem.js index 2f4aeafc5..9ab4541f9 100644 --- a/blocks/FileItem/FileItem.js +++ b/blocks/FileItem/FileItem.js @@ -36,56 +36,59 @@ export class FileItem extends UploaderBlock { /** @private */ _renderedOnce = false; - // @ts-ignore TODO: fix this - init$ = { - ...this.init$, - uid: '', - itemName: '', - errorText: '', - thumbUrl: '', - progressValue: 0, - progressVisible: false, - progressUnknown: false, - badgeIcon: '', - isFinished: false, - isFailed: false, - isUploading: false, - isFocused: false, - isEditable: false, - isLimitOverflow: false, - state: FileItemState.IDLE, - '*uploadTrigger': null, - - onEdit: () => { - this.set$({ - '*focusedEntry': this._entry, - }); - if (this.hasBlockInCtx((b) => b.activityType === ActivityBlock.activities.DETAILS)) { - this.$['*currentActivity'] = ActivityBlock.activities.DETAILS; - } else { - this.$['*currentActivity'] = ActivityBlock.activities.CLOUD_IMG_EDIT; - } - }, - onRemove: () => { - let entryUuid = this._entry.getValue('uuid'); - if (entryUuid) { - let data = this.getOutputData((dataItem) => { - return dataItem.getValue('uuid') === entryUuid; + constructor() { + super(); + + this.init$ = { + ...this.init$, + uid: '', + itemName: '', + errorText: '', + thumbUrl: '', + progressValue: 0, + progressVisible: false, + progressUnknown: false, + badgeIcon: '', + isFinished: false, + isFailed: false, + isUploading: false, + isFocused: false, + isEditable: false, + isLimitOverflow: false, + state: FileItemState.IDLE, + '*uploadTrigger': null, + + onEdit: () => { + this.set$({ + '*focusedEntry': this._entry, }); - EventManager.emit( - new EventData({ - type: EVENT_TYPES.REMOVE, - ctx: this.ctxName, - data, - }) - ); - } - this.uploadCollection.remove(this.$.uid); - }, - onUpload: () => { - this.upload(); - }, - }; + if (this.hasBlockInCtx((b) => b.activityType === ActivityBlock.activities.DETAILS)) { + this.$['*currentActivity'] = ActivityBlock.activities.DETAILS; + } else { + this.$['*currentActivity'] = ActivityBlock.activities.CLOUD_IMG_EDIT; + } + }, + onRemove: () => { + let entryUuid = this._entry.getValue('uuid'); + if (entryUuid) { + let data = this.getOutputData((dataItem) => { + return dataItem.getValue('uuid') === entryUuid; + }); + EventManager.emit( + new EventData({ + type: EVENT_TYPES.REMOVE, + ctx: this.ctxName, + data, + }) + ); + } + this.uploadCollection.remove(this.$.uid); + }, + onUpload: () => { + this.upload(); + }, + }; + } _reset() { for (let sub of this._entrySubs) { diff --git a/blocks/Modal/Modal.js b/blocks/Modal/Modal.js index 5432eb1c2..ac37248d3 100644 --- a/blocks/Modal/Modal.js +++ b/blocks/Modal/Modal.js @@ -4,6 +4,16 @@ import { Block } from '../../abstract/Block.js'; export class Modal extends Block { static StateConsumerScope = 'modal'; + constructor() { + super(); + this.init$ = { + ...this.init$, + '*modalActive': false, + isOpen: false, + closeClicked: this._handleDialogClose, + }; + } + _handleBackdropClick = () => { this._closeDialog(); }; @@ -23,14 +33,6 @@ export class Modal extends Block { } }; - // @ts-ignore TODO: fix this - init$ = { - ...this.init$, - '*modalActive': false, - isOpen: false, - closeClicked: this._handleDialogClose, - }; - show() { if (this.ref.dialog.showModal) { this.ref.dialog.showModal(); diff --git a/blocks/SimpleBtn/SimpleBtn.js b/blocks/SimpleBtn/SimpleBtn.js index b65ccaff3..570162d16 100644 --- a/blocks/SimpleBtn/SimpleBtn.js +++ b/blocks/SimpleBtn/SimpleBtn.js @@ -2,14 +2,17 @@ import { UploaderBlock } from '../../abstract/UploaderBlock.js'; export class SimpleBtn extends UploaderBlock { - // @ts-ignore TODO: fix this - init$ = { - ...this.init$, - '*simpleButtonText': '', - onClick: () => { - this.initFlow(); - }, - }; + constructor() { + super(); + + this.init$ = { + ...this.init$, + '*simpleButtonText': '', + onClick: () => { + this.initFlow(); + }, + }; + } initCallback() { super.initCallback(); diff --git a/blocks/UploadList/UploadList.js b/blocks/UploadList/UploadList.js index 8c56c2a58..1a9680f3a 100644 --- a/blocks/UploadList/UploadList.js +++ b/blocks/UploadList/UploadList.js @@ -20,41 +20,44 @@ export class UploadList extends UploaderBlock { historyTracked = true; activityType = ActivityBlock.activities.UPLOAD_LIST; - // @ts-ignore TODO: fix this - init$ = { - ...this.init$, - doneBtnVisible: false, - doneBtnEnabled: false, - uploadBtnVisible: false, - addMoreBtnVisible: false, - addMoreBtnEnabled: false, - headerText: '', - - hasFiles: false, - onAdd: () => { - this.initFlow(true); - }, - onUpload: () => { - this.uploadAll(); - this._updateUploadsState(); - }, - onDone: () => { - this.doneFlow(); - }, - onCancel: () => { - let data = this.getOutputData((dataItem) => { - return !!dataItem.getValue('fileInfo'); - }); - EventManager.emit( - new EventData({ - type: EVENT_TYPES.REMOVE, - ctx: this.ctxName, - data, - }) - ); - this.uploadCollection.clearAll(); - }, - }; + constructor() { + super(); + + this.init$ = { + ...this.init$, + doneBtnVisible: false, + doneBtnEnabled: false, + uploadBtnVisible: false, + addMoreBtnVisible: false, + addMoreBtnEnabled: false, + headerText: '', + + hasFiles: false, + onAdd: () => { + this.initFlow(true); + }, + onUpload: () => { + this.uploadAll(); + this._updateUploadsState(); + }, + onDone: () => { + this.doneFlow(); + }, + onCancel: () => { + let data = this.getOutputData((dataItem) => { + return !!dataItem.getValue('fileInfo'); + }); + EventManager.emit( + new EventData({ + type: EVENT_TYPES.REMOVE, + ctx: this.ctxName, + data, + }) + ); + this.uploadCollection.clearAll(); + }, + }; + } _debouncedHandleCollectionUpdate = debounce(() => { if (!this.isConnected) { From 923f4ca975c6829fc5ca02271c053b2c427ba72c Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 20 Sep 2023 17:39:22 +0300 Subject: [PATCH 3/7] feat(cloud-image-editor): add crop preset setting --- .../src/CloudImageEditorBlock.js | 99 ++- blocks/CloudImageEditor/src/CropFrame.js | 117 +++- .../src/EditorImageCropper.js | 145 +++-- blocks/CloudImageEditor/src/crop-utils.js | 567 ++++++++++++++++-- .../CloudImageEditor/src/cropper-constants.js | 2 +- blocks/CloudImageEditor/src/state.js | 8 + blocks/CloudImageEditor/src/types.js | 6 + .../CloudImageEditorActivity.js | 10 +- blocks/Config/initialConfig.js | 1 + blocks/Config/normalizeConfigValue.js | 1 + blocks/test/cloud-image-editor.htm | 31 + blocks/test/raw-regular.htm | 2 +- types/exported.d.ts | 1 + 13 files changed, 806 insertions(+), 184 deletions(-) create mode 100644 blocks/test/cloud-image-editor.htm diff --git a/blocks/CloudImageEditor/src/CloudImageEditorBlock.js b/blocks/CloudImageEditor/src/CloudImageEditorBlock.js index e49a27573..87f051f06 100644 --- a/blocks/CloudImageEditor/src/CloudImageEditorBlock.js +++ b/blocks/CloudImageEditor/src/CloudImageEditorBlock.js @@ -18,12 +18,14 @@ import { TabId } from './toolbar-constants.js'; export class CloudImageEditorBlock extends CloudImageEditorBase { static className = 'cloud-image-editor'; - // @ts-ignore TODO: fix this - init$ = { - ...this.init$, - // @ts-ignore TODO: fix this - ...initState(this), - }; + constructor() { + super(); + + this.init$ = { + ...this.init$, + ...initState(this), + }; + } /** Force cloud editor to always use own context */ get ctxName() { @@ -74,6 +76,49 @@ export class CloudImageEditorBlock extends CloudImageEditorBase { this.initEditor(); } + async updateImage() { + await this._waitForSize(); + + if (this.$['*tabId'] === TabId.CROP) { + this.$['*cropperEl'].deactivate({ reset: true }); + } else { + this.$['*faderEl'].deactivate(); + } + + this.$['*editorTransformations'] = {}; + + 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.cfg.cdnCname, this.$.uuid); + } else { + throw new Error('No UUID nor CDN URL provided'); + } + + try { + fetch(createCdnUrl(this.$['*originalUrl'], createCdnUrlModifiers('json'))) + .then((response) => response.json()) + .then((json) => { + const { width, height } = /** @type {{ width: number; height: number }} */ (json); + this.$['*imageSize'] = { width, height }; + + if (this.$['*tabId'] === TabId.CROP) { + this.$['*cropperEl'].activate(this.$['*imageSize']); + } else { + this.$['*faderEl'].activate({ url: this.$['*originalUrl'] }); + } + }); + } catch (err) { + if (err) { + console.error('Failed to load image info', err); + } + } + } + async initEditor() { try { await this._waitForSize(); @@ -109,6 +154,18 @@ export class CloudImageEditorBlock extends CloudImageEditorBase { } }); + this.sub('cropPreset', (val) => { + if (!val) return; + const [w, h] = val.split(':').map(Number); + if (!Number.isFinite(w) || !Number.isFinite(h)) { + console.error(`Invalid crop preset: ${val}`); + return; + } + /** @type {import('./types.js').CropAspectRatio} */ + const aspectRatio = { type: 'aspect-ratio', width: w, height: h }; + this.$['*cropPresetList'] = [aspectRatio]; + }); + this.sub('*tabId', (tabId) => { this.ref['img-el'].className = classNames('image', { image_hidden_to_cropper: tabId === TabId.CROP, @@ -116,18 +173,6 @@ export class CloudImageEditorBlock extends CloudImageEditorBase { }); }); - 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.cfg.cdnCname, this.$.uuid); - } else { - throw new Error('No UUID nor CDN URL provided'); - } - this.classList.add('editor_ON'); this.sub('*networkProblems', (networkProblems) => { @@ -138,6 +183,9 @@ export class CloudImageEditorBlock extends CloudImageEditorBase { this.sub( '*editorTransformations', (transformations) => { + if (Object.keys(transformations).length === 0) { + return; + } let originalUrl = this.$['*originalUrl']; let cdnUrlModifiers = createCdnUrlModifiers(transformationsToOperations(transformations)); let cdnUrl = createCdnUrl(originalUrl, createCdnUrlModifiers(cdnUrlModifiers, 'preview')); @@ -160,18 +208,8 @@ export class CloudImageEditorBlock extends CloudImageEditorBase { false ); - try { - fetch(createCdnUrl(this.$['*originalUrl'], createCdnUrlModifiers('json'))) - .then((response) => response.json()) - .then((json) => { - const { width, height } = /** @type {{ width: number; height: number }} */ (json); - this.$['*imageSize'] = { width, height }; - }); - } catch (err) { - if (err) { - console.error('Failed to load image info', err); - } - } + this.sub('uuid', (val) => val && this.updateImage()); + this.sub('cdnUrl', (val) => val && this.updateImage()); } } @@ -179,4 +217,5 @@ CloudImageEditorBlock.template = TEMPLATE; CloudImageEditorBlock.bindAttributes({ uuid: 'uuid', 'cdn-url': 'cdnUrl', + 'crop-preset': 'cropPreset', }); diff --git a/blocks/CloudImageEditor/src/CropFrame.js b/blocks/CloudImageEditor/src/CropFrame.js index 4b8f8d62c..3b2b6092c 100644 --- a/blocks/CloudImageEditor/src/CropFrame.js +++ b/blocks/CloudImageEditor/src/CropFrame.js @@ -1,11 +1,9 @@ +// @ts-check import { CloudImageEditorBase } from './CloudImageEditorBase.js'; import { - constraintRect, cornerPath, createSvgNode, - expandRect, - intersectionRect, - minRectSize, + resizeRect, moveRect, rectContainsPoint, setSvgNodeAttrs, @@ -23,14 +21,14 @@ import { import { classNames } from './lib/classNames.js'; export class CropFrame extends CloudImageEditorBase { - init$ = { - ...this.init$, - dragging: false, - }; - constructor() { super(); + this.init$ = { + ...this.init$, + dragging: false, + }; + /** @private */ this._handlePointerUp = this._handlePointerUp_.bind(this); @@ -41,6 +39,10 @@ export class CropFrame extends CloudImageEditorBase { this._handleSvgPointerMove = this._handleSvgPointerMove_.bind(this); } + /** + * @private + * @param {import('./types.js').Direction} direction + */ _shouldThumbBeDisabled(direction) { let imageBox = this.$['*imageBox']; if (!imageBox) { @@ -56,6 +58,7 @@ export class CropFrame extends CloudImageEditorBase { return tooHigh || tooWide; } + /** @private */ _createBackdrop() { /** @type {import('./types.js').Rectangle} */ let cropBox = this.$['*cropBox']; @@ -99,17 +102,23 @@ export class CropFrame extends CloudImageEditorBase { this._backdropMaskInner = maskRectInner; } - /** Super tricky workaround for the chromium bug See https://bugs.chromium.org/p/chromium/issues/detail?id=330815 */ + /** + * @private Super Tricky workaround for the chromium bug See + * https://bugs.chromium.org/p/chromium/issues/detail?id=330815 + */ _resizeBackdrop() { if (!this._backdropMask) { return; } this._backdropMask.style.display = 'none'; window.requestAnimationFrame(() => { - this._backdropMask.style.display = 'block'; + if (this._backdropMask) { + this._backdropMask.style.display = 'block'; + } }); } + /** @private */ _updateBackdrop() { /** @type {import('./types.js').Rectangle} */ let cropBox = this.$['*cropBox']; @@ -118,13 +127,15 @@ export class CropFrame extends CloudImageEditorBase { } let { x, y, width, height } = cropBox; - setSvgNodeAttrs(this._backdropMaskInner, { x, y, width, height }); + this._backdropMaskInner && setSvgNodeAttrs(this._backdropMaskInner, { x, y, width, height }); } + /** @private */ _updateFrame() { /** @type {import('./types.js').Rectangle} */ let cropBox = this.$['*cropBox']; - if (!cropBox) { + + if (!cropBox || !this._frameGuides || !this._frameThumbs) { return; } for (let thumb of Object.values(this._frameThumbs)) { @@ -141,7 +152,12 @@ export class CropFrame extends CloudImageEditorBase { cy: center[1], }); } else { - let { d, center } = isCorner ? cornerPath(cropBox, direction) : sidePath(cropBox, direction); + let { d, center } = isCorner + ? cornerPath(cropBox, direction) + : sidePath( + cropBox, + /** @type {Extract} */ (direction) + ); setSvgNodeAttrs(interactionNode, { cx: center[0], cy: center[1] }); setSvgNodeAttrs(pathNode, { d }); } @@ -156,8 +172,7 @@ export class CropFrame extends CloudImageEditorBase { ); } - let frameGuides = this._frameGuides; - setSvgNodeAttrs(frameGuides, { + setSvgNodeAttrs(this._frameGuides, { x: cropBox.x - GUIDE_STROKE_WIDTH * 0.5, y: cropBox.y - GUIDE_STROKE_WIDTH * 0.5, width: cropBox.width + GUIDE_STROKE_WIDTH, @@ -165,12 +180,23 @@ export class CropFrame extends CloudImageEditorBase { }); } + /** @private */ _createThumbs() { - let frameThumbs = {}; + /** + * @type {Partial<{ + * [K in import('./types.js').Direction]: { + * direction: import('./types.js').Direction; + * pathNode: SVGElement; + * interactionNode: SVGElement; + * groupNode: SVGElement; + * }; + * }>} + */ + const frameThumbs = {}; for (let i = 0; i < 3; i++) { for (let j = 0; j < 3; j++) { - let direction = `${['n', '', 's'][i]}${['w', '', 'e'][j]}`; + let direction = /** @type {import('./types.js').Direction} */ (`${['n', '', 's'][i]}${['w', '', 'e'][j]}`); let groupNode = createSvgNode('g'); groupNode.classList.add('thumb'); groupNode.setAttribute('with-effects', ''); @@ -199,6 +225,7 @@ export class CropFrame extends CloudImageEditorBase { return frameThumbs; } + /** @private */ _createGuides() { let svg = createSvgNode('svg'); @@ -245,6 +272,7 @@ export class CropFrame extends CloudImageEditorBase { return svg; } + /** @private */ _createFrame() { let svg = this.ref['svg-el']; let fr = document.createDocumentFragment(); @@ -262,7 +290,13 @@ export class CropFrame extends CloudImageEditorBase { this._frameGuides = frameGuides; } + /** + * @private + * @param {import('./types.js').Direction} direction + * @param {PointerEvent} e + */ _handlePointerDown(direction, e) { + if (!this._frameThumbs) return; let thumb = this._frameThumbs[direction]; if (this._shouldThumbBeDisabled(direction)) { return; @@ -280,6 +314,10 @@ export class CropFrame extends CloudImageEditorBase { this._dragStartCrop = { ...cropBox }; } + /** + * @private + * @param {PointerEvent} e + */ _handlePointerUp_(e) { this._updateCursor(); @@ -292,8 +330,12 @@ export class CropFrame extends CloudImageEditorBase { this.$.dragging = false; } + /** + * @private + * @param {PointerEvent} e + */ _handlePointerMove_(e) { - if (!this.$.dragging) { + if (!this.$.dragging || !this._dragStartPoint || !this._draggingThumb) { return; } e.stopPropagation(); @@ -307,20 +349,28 @@ export class CropFrame extends CloudImageEditorBase { let dy = y - this._dragStartPoint[1]; let { direction } = this._draggingThumb; + this.$['*cropBox'] = this._calcCropBox(direction, [dx, dy]); + } + + /** + * @private + * @param {import('./types.js').Direction} direction + * @param {[Number, Number]} delta + */ + _calcCropBox(direction, delta) { + const [dx, dy] = delta; /** @type {import('./types.js').Rectangle} */ let imageBox = this.$['*imageBox']; - let rect = this._dragStartCrop; + let rect = /** @type {import('./types.js').Rectangle} */ (this._dragStartCrop) ?? this.$['*cropBox']; + /** @type {import('./types.js').CropPresetList[0]} */ + const cropPreset = this.$['*cropPresetList']?.[0]; + const aspectRatio = cropPreset ? cropPreset.width / cropPreset.height : undefined; if (direction === '') { - rect = moveRect(rect, [dx, dy]); - rect = constraintRect(rect, imageBox); + rect = moveRect({ rect, delta: [dx, dy], imageBox }); } else { - rect = expandRect(rect, [dx, dy], direction); - rect = intersectionRect(rect, imageBox); + rect = resizeRect({ rect, delta: [dx, dy], direction, aspectRatio, imageBox }); } - /** @type {[Number, Number]} */ - let minCropRect = [Math.min(imageBox.width, MIN_CROP_SIZE), Math.min(imageBox.height, MIN_CROP_SIZE)]; - rect = minRectSize(rect, minCropRect, direction); if (!Object.values(rect).every((number) => Number.isFinite(number) && number >= 0)) { console.error('CropFrame is trying to create invalid rectangle', { @@ -328,10 +378,16 @@ export class CropFrame extends CloudImageEditorBase { }); return; } - this.$['*cropBox'] = rect; + return rect; } + /** + * @private + * @param {PointerEvent} e + */ _handleSvgPointerMove_(e) { + if (!this._frameThumbs) return; + let hoverThumb = Object.values(this._frameThumbs).find((thumb) => { if (this._shouldThumbBeDisabled(thumb.direction)) { return false; @@ -352,17 +408,21 @@ export class CropFrame extends CloudImageEditorBase { this._updateCursor(); } + /** @private */ _updateCursor() { let hoverThumb = this._hoverThumb; this.ref['svg-el'].style.cursor = hoverThumb ? thumbCursor(hoverThumb.direction) : 'initial'; } + /** @private */ _render() { this._updateBackdrop(); this._updateFrame(); } + /** @param {boolean} visible */ toggleThumbs(visible) { + if (!this._frameThumbs) return; Object.values(this._frameThumbs) .map(({ groupNode }) => groupNode) .forEach((groupNode) => { @@ -400,6 +460,7 @@ export class CropFrame extends CloudImageEditorBase { }); this.sub('dragging', (dragging) => { + if (!this._frameGuides) return; this._frameGuides.setAttribute( 'class', classNames({ diff --git a/blocks/CloudImageEditor/src/EditorImageCropper.js b/blocks/CloudImageEditor/src/EditorImageCropper.js index f75ab81f6..bacc8eadf 100644 --- a/blocks/CloudImageEditor/src/EditorImageCropper.js +++ b/blocks/CloudImageEditor/src/EditorImageCropper.js @@ -1,6 +1,8 @@ +// @ts-check + import { CloudImageEditorBase } from './CloudImageEditorBase.js'; -import { constraintRect, minRectSize } from './crop-utils.js'; -import { CROP_PADDING, MIN_CROP_SIZE } from './cropper-constants.js'; +import { isRectInsideRect, rotateSize } from './crop-utils.js'; +import { CROP_PADDING } from './cropper-constants.js'; import { classNames } from './lib/classNames.js'; import { debounce } from './lib/debounce.js'; import { pick } from './lib/pick.js'; @@ -24,16 +26,6 @@ function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } -/** - * @param {import('./types.js').ImageSize} imageSize - * @param {Number} angle - * @returns {import('./types.js').ImageSize} - */ -function rotateSize({ width, height }, angle) { - let swap = (angle / 90) % 2 !== 0; - return { width: swap ? height : width, height: swap ? width : height }; -} - /** * @param {import('./types.js').Transformations['crop']} crop * @returns {boolean} @@ -42,7 +34,7 @@ function validateCrop(crop) { if (!crop) { return true; } - /** @type {((arg: typeof crop) => boolean)[]} */ + /** @type {((arg: NonNullable) => boolean)[]} */ let shouldMatch = [ ({ dimensions, coords }) => [...dimensions, ...coords].every((number) => Number.isInteger(number) && Number.isFinite(number)), @@ -52,40 +44,42 @@ function validateCrop(crop) { } export class EditorImageCropper extends CloudImageEditorBase { - init$ = { - ...this.init$, - image: null, - '*padding': CROP_PADDING, - /** @type {Operations} */ - '*operations': { - rotate: 0, - mirror: false, - flip: false, - }, - /** @type {import('./types.js').Rectangle} */ - '*imageBox': { - x: 0, - y: 0, - width: 0, - height: 0, - }, - /** @type {import('./types.js').Rectangle} */ - '*cropBox': { - x: 0, - y: 0, - width: 0, - height: 0, - }, - }; - constructor() { super(); + this.init$ = { + ...this.init$, + image: null, + '*padding': CROP_PADDING, + /** @type {Operations} */ + '*operations': { + rotate: 0, + mirror: false, + flip: false, + }, + /** @type {import('./types.js').Rectangle} */ + '*imageBox': { + x: 0, + y: 0, + width: 0, + height: 0, + }, + /** @type {import('./types.js').Rectangle} */ + '*cropBox': { + x: 0, + y: 0, + width: 0, + height: 0, + }, + }; + /** @private */ this._commitDebounced = debounce(this._commit.bind(this), 300); /** @private */ this._handleResizeDebounced = debounce(this._handleResize.bind(this), 10); + + this._imageSize = { width: 0, height: 0 }; } /** @private */ @@ -118,7 +112,7 @@ export class EditorImageCropper extends CloudImageEditorBase { canvas.style.height = `${height}px`; canvas.width = width * dpr; canvas.height = height * dpr; - ctx.scale(dpr, dpr); + ctx?.scale(dpr, dpr); this._canvas = canvas; this._ctx = ctx; @@ -169,14 +163,14 @@ export class EditorImageCropper extends CloudImageEditorBase { let imageBox = this.$['*imageBox']; let operations = this.$['*operations']; let { rotate } = operations; - let transformation = this.$['*editorTransformations']['crop']; + let cropTransformation = this.$['*editorTransformations']['crop']; + let { width: previewWidth, x: previewX, y: previewY } = this.$['*imageBox']; - if (transformation) { + if (cropTransformation) { let { dimensions: [width, height], coords: [x, y], - } = transformation; - let { width: previewWidth, x: previewX, y: previewY } = this.$['*imageBox']; + } = cropTransformation; let { width: sourceWidth } = rotateSize(this._imageSize, rotate); let ratio = previewWidth / sourceWidth; cropBox = { @@ -185,29 +179,41 @@ export class EditorImageCropper extends CloudImageEditorBase { width: width * ratio, height: height * ratio, }; - } else { + } + + if (!cropTransformation || !isRectInsideRect(cropBox, imageBox)) { + /** @type {import('./types.js').CropPresetList[0]} */ + const cropPreset = this.$['*cropPresetList']?.[0]; + const cropRatio = cropPreset ? cropPreset.width / cropPreset.height : undefined; + const ratio = imageBox.width / imageBox.height; + let width = imageBox.width; + let height = imageBox.height; + if (cropRatio) { + if (ratio > cropRatio) { + width = Math.min(imageBox.height * cropRatio, imageBox.width); + } else { + height = Math.min(imageBox.width / cropRatio, imageBox.height); + } + } cropBox = { - x: imageBox.x, - y: imageBox.y, - width: imageBox.width, - height: imageBox.height, + x: imageBox.x + imageBox.width / 2 - width / 2, + y: imageBox.y + imageBox.height / 2 - height / 2, + width, + height, }; } - /** @type {[Number, Number]} */ - let minCropRect = [Math.min(imageBox.width, MIN_CROP_SIZE), Math.min(imageBox.height, MIN_CROP_SIZE)]; - cropBox = minRectSize(cropBox, minCropRect, 'se'); - cropBox = constraintRect(cropBox, imageBox); this.$['*cropBox'] = cropBox; } /** @private */ _drawImage() { + let ctx = this._ctx; + if (!ctx) return; let image = this.$.image; let imageBox = this.$['*imageBox']; let operations = this.$['*operations']; let { mirror, flip, rotate } = operations; - let ctx = this._ctx; let rotated = rotateSize({ width: imageBox.width, height: imageBox.height }, rotate); ctx.save(); ctx.translate(imageBox.x + imageBox.width / 2, imageBox.y + imageBox.height / 2); @@ -219,7 +225,7 @@ export class EditorImageCropper extends CloudImageEditorBase { /** @private */ _draw() { - if (!this._isActive || !this.$.image) { + if (!this._isActive || !this.$.image || !this._canvas || !this._ctx) { return; } let canvas = this._canvas; @@ -230,7 +236,10 @@ export class EditorImageCropper extends CloudImageEditorBase { this._drawImage(); } - /** @private */ + /** + * @private + * @param {{ fromViewer?: boolean }} options + */ _animateIn({ fromViewer }) { if (this.$.image) { this.ref['frame-el'].toggleThumbs(true); @@ -247,9 +256,9 @@ export class EditorImageCropper extends CloudImageEditorBase { /** * @private - * @returns {import('./types.js').Transformations['crop']['dimensions']} + * @returns {NonNullable['dimensions']} */ - _calculateDimensions() { + _getCropDimensions() { let cropBox = this.$['*cropBox']; let imageBox = this.$['*imageBox']; let operations = this.$['*operations']; @@ -273,7 +282,7 @@ export class EditorImageCropper extends CloudImageEditorBase { * @private * @returns {import('./types.js').Transformations['crop']} */ - _calculateCrop() { + _getCropTransformation() { let cropBox = this.$['*cropBox']; let imageBox = this.$['*imageBox']; let operations = this.$['*operations']; @@ -284,7 +293,7 @@ export class EditorImageCropper extends CloudImageEditorBase { let ratioW = previewWidth / sourceWidth; let ratioH = previewHeight / sourceHeight; - let dimensions = this._calculateDimensions(); + let dimensions = this._getCropDimensions(); let crop = { dimensions, coords: /** @type {[Number, Number]} */ ([ @@ -312,7 +321,7 @@ export class EditorImageCropper extends CloudImageEditorBase { } let operations = this.$['*operations']; let { rotate, mirror, flip } = operations; - let crop = this._calculateCrop(); + let crop = this._getCropTransformation(); /** @type {import('./types.js').Transformations} */ let editorTransformations = this.$['*editorTransformations']; let transformations = { @@ -359,7 +368,7 @@ export class EditorImageCropper extends CloudImageEditorBase { * @param {import('./types.js').ImageSize} imageSize * @param {{ fromViewer?: boolean }} options */ - async activate(imageSize, { fromViewer }) { + async activate(imageSize, { fromViewer } = {}) { if (this._isActive) { return; } @@ -379,11 +388,11 @@ export class EditorImageCropper extends CloudImageEditorBase { console.error('Failed to activate cropper', { error: err }); } } - deactivate() { + deactivate({ reset = false } = {}) { if (!this._isActive) { return; } - this._commit(); + !reset && this._commit(); this._isActive = false; this._transitionToCrop(); @@ -400,7 +409,7 @@ export class EditorImageCropper extends CloudImageEditorBase { /** @private */ _transitionToCrop() { - let dimensions = this._calculateDimensions(); + let dimensions = this._getCropDimensions(); let scaleX = Math.min(this.offsetWidth, dimensions[0]) / this.$['*cropBox'].width; let scaleY = Math.min(this.offsetHeight, dimensions[1]) / this.$['*cropBox'].height; let scale = Math.min(scaleX, scaleY); @@ -503,12 +512,16 @@ export class EditorImageCropper extends CloudImageEditorBase { this._draw(); }); - this.sub('*cropBox', (cropBox) => { + this.sub('*cropBox', () => { if (this.$.image) { this._commitDebounced(); } }); + this.sub('*cropPresetList', () => { + this._alignCrop(); + }); + setTimeout(() => { this.sub('*networkProblems', (networkProblems) => { if (!networkProblems) { diff --git a/blocks/CloudImageEditor/src/crop-utils.js b/blocks/CloudImageEditor/src/crop-utils.js index c8a569a2c..fd51f1be4 100644 --- a/blocks/CloudImageEditor/src/crop-utils.js +++ b/blocks/CloudImageEditor/src/crop-utils.js @@ -1,4 +1,5 @@ -import { THUMB_CORNER_SIZE, THUMB_OFFSET, THUMB_SIDE_SIZE } from './cropper-constants.js'; +// @ts-check +import { MIN_CROP_SIZE, THUMB_CORNER_SIZE, THUMB_OFFSET, THUMB_SIDE_SIZE } from './cropper-constants.js'; /** * @param {SVGElement} node @@ -21,7 +22,7 @@ export function createSvgNode(name, attrs = {}) { /** * @param {import('./types.js').Rectangle} rect - * @param {String} direction + * @param {import('./types.js').Direction} direction */ export function cornerPath(rect, direction) { let { x, y, width, height } = rect; @@ -52,13 +53,17 @@ export function cornerPath(rect, direction) { /** * @param {import('./types.js').Rectangle} rect - * @param {String} direction + * @param {Extract} direction */ export function sidePath(rect, direction) { let { x, y, width, height } = rect; - let wMul = ['n', 's'].includes(direction) ? 0.5 : { w: 0, e: 1 }[direction]; - let hMul = ['w', 'e'].includes(direction) ? 0.5 : { n: 0, s: 1 }[direction]; + let wMul = ['n', 's'].includes(direction) + ? 0.5 + : { w: 0, e: 1 }[/** @type {Extract} */ (direction)]; + let hMul = ['w', 'e'].includes(direction) + ? 0.5 + : { n: 0, s: 1 }[/** @type {Extract} */ (direction)]; let xSide = [-1, 1][wMul]; let ySide = [-1, 1][hMul]; @@ -76,7 +81,7 @@ export function sidePath(rect, direction) { return { d: path, center }; } -/** @param {String} direction */ +/** @param {import('./types.js').Direction} direction */ export function thumbCursor(direction) { if (direction === '') { return 'move'; @@ -94,15 +99,21 @@ export function thumbCursor(direction) { } /** - * @param {import('./types.js').Rectangle} rect - * @param {[Number, Number]} delta + * @param {{ + * rect: import('./types.js').Rectangle; + * delta: [Number, Number]; + * imageBox: import('./types.js').Rectangle; + * }} options */ -export function moveRect(rect, [dx, dy]) { - return { - ...rect, - x: rect.x + dx, - y: rect.y + dy, - }; +export function moveRect({ rect, delta: [dx, dy], imageBox }) { + return constraintRect( + { + ...rect, + x: rect.x + dx, + y: rect.y + dy, + }, + imageBox + ); } /** @@ -131,76 +142,497 @@ export function constraintRect(rect1, rect2) { } /** - * @param {import('./types.js').Rectangle} rect - * @param {[Number, Number]} delta - * @param {String} direction + * @param {{ + * rect: import('./types.js').Rectangle; + * delta: [Number, Number]; + * aspectRatio?: number; + * imageBox: import('./types.js').Rectangle; + * }} options + */ +function resizeNorth({ rect, delta, aspectRatio, imageBox }) { + const [, dy] = delta; + let { x, y, width, height } = rect; + + y += dy; + height -= dy; + if (aspectRatio) { + width = height * aspectRatio; + } + x = rect.x + rect.width / 2 - width / 2; + if (y <= imageBox.y) { + y = imageBox.y; + height = rect.y + rect.height - y; + if (aspectRatio) { + width = height * aspectRatio; + x = rect.x + rect.width / 2 - width / 2; + } + } + if (x <= imageBox.x) { + x = imageBox.x; + y = rect.y + rect.height - height; + } + if (x + width >= imageBox.x + imageBox.width) { + x = Math.max(imageBox.x, imageBox.x + imageBox.width - width); + width = imageBox.x + imageBox.width - x; + if (aspectRatio) { + height = width / aspectRatio; + } + y = rect.y + rect.height - height; + } + if (height < MIN_CROP_SIZE) { + height = MIN_CROP_SIZE; + if (aspectRatio) { + width = height * aspectRatio; + x = rect.x + rect.width / 2 - width / 2; + } + y = rect.y + rect.height - height; + } + if (width < MIN_CROP_SIZE) { + width = MIN_CROP_SIZE; + if (aspectRatio) { + height = width / aspectRatio; + x = rect.x + rect.width / 2 - width / 2; + } + y = rect.y + rect.height - height; + } + + return { x, y, width, height }; +} + +/** + * @param {{ + * rect: import('./types.js').Rectangle; + * delta: [Number, Number]; + * aspectRatio?: number; + * imageBox: import('./types.js').Rectangle; + * }} options + */ +function resizeWest({ rect, delta, aspectRatio, imageBox }) { + const [dx] = delta; + let { x, y, width, height } = rect; + + x += dx; + width -= dx; + if (aspectRatio) { + height = width / aspectRatio; + } + y = rect.y + rect.height / 2 - height / 2; + if (x <= imageBox.x) { + x = imageBox.x; + width = rect.x + rect.width - x; + if (aspectRatio) { + height = width / aspectRatio; + y = rect.y + rect.height / 2 - height / 2; + } + } + if (y <= imageBox.y) { + y = imageBox.y; + x = rect.x + rect.width - width; + } + if (y + height >= imageBox.y + imageBox.height) { + y = Math.max(imageBox.y, imageBox.y + imageBox.height - height); + height = imageBox.y + imageBox.height - y; + if (aspectRatio) { + width = height * aspectRatio; + } + x = rect.x + rect.width - width; + } + if (height < MIN_CROP_SIZE) { + height = MIN_CROP_SIZE; + if (aspectRatio) { + width = height * aspectRatio; + } + y = rect.y + rect.height / 2 - height / 2; + x = rect.x + rect.width - width; + } + if (width < MIN_CROP_SIZE) { + width = MIN_CROP_SIZE; + if (aspectRatio) { + height = width / aspectRatio; + } + y = rect.y + rect.height / 2 - height / 2; + x = rect.x + rect.width - width; + } + + return { x, y, width, height }; +} + +/** + * @param {{ + * rect: import('./types.js').Rectangle; + * delta: [Number, Number]; + * aspectRatio?: number; + * imageBox: import('./types.js').Rectangle; + * }} options + */ +function resizeSouth({ rect, delta, aspectRatio, imageBox }) { + const [, dy] = delta; + let { x, y, width, height } = rect; + + height += dy; + if (aspectRatio) { + width = height * aspectRatio; + } + x = rect.x + rect.width / 2 - width / 2; + if (y + height >= imageBox.y + imageBox.height) { + height = imageBox.y + imageBox.height - y; + if (aspectRatio) { + width = height * aspectRatio; + } + x = rect.x + rect.width / 2 - width / 2; + } + if (x <= imageBox.x) { + x = imageBox.x; + y = rect.y; + } + if (x + width >= imageBox.x + imageBox.width) { + x = Math.max(imageBox.x, imageBox.x + imageBox.width - width); + width = imageBox.x + imageBox.width - x; + if (aspectRatio) { + height = width / aspectRatio; + } + y = rect.y; + } + if (height < MIN_CROP_SIZE) { + height = MIN_CROP_SIZE; + if (aspectRatio) { + width = height * aspectRatio; + } + x = rect.x + rect.width / 2 - width / 2; + } + if (width < MIN_CROP_SIZE) { + width = MIN_CROP_SIZE; + if (aspectRatio) { + height = width / aspectRatio; + } + x = rect.x + rect.width / 2 - width / 2; + } + + return { x, y, width, height }; +} + +/** + * @param {{ + * rect: import('./types.js').Rectangle; + * delta: [Number, Number]; + * aspectRatio?: number; + * imageBox: import('./types.js').Rectangle; + * }} options + */ +function resizeEast({ rect, delta, aspectRatio, imageBox }) { + const [dx] = delta; + let { x, y, width, height } = rect; + + width += dx; + if (aspectRatio) { + height = width / aspectRatio; + } + y = rect.y + rect.height / 2 - height / 2; + if (x + width >= imageBox.x + imageBox.width) { + width = imageBox.x + imageBox.width - x; + if (aspectRatio) { + height = width / aspectRatio; + } + y = rect.y + rect.height / 2 - height / 2; + } + if (y <= imageBox.y) { + y = imageBox.y; + x = rect.x; + } + if (y + height >= imageBox.y + imageBox.height) { + y = Math.max(imageBox.y, imageBox.y + imageBox.height - height); + height = imageBox.y + imageBox.height - y; + if (aspectRatio) { + width = height * aspectRatio; + } + x = rect.x; + } + if (height < MIN_CROP_SIZE) { + height = MIN_CROP_SIZE; + if (aspectRatio) { + width = height * aspectRatio; + } + y = rect.y + rect.height / 2 - height / 2; + } + if (width < MIN_CROP_SIZE) { + width = MIN_CROP_SIZE; + if (aspectRatio) { + height = width / aspectRatio; + } + y = rect.y + rect.height / 2 - height / 2; + } + + return { x, y, width, height }; +} + +/** + * @param {{ + * rect: import('./types.js').Rectangle; + * delta: [Number, Number]; + * aspectRatio?: number; + * imageBox: import('./types.js').Rectangle; + * }} options */ -export function expandRect(rect, [dx, dy], direction) { +function resizeNorthWest({ rect, delta, aspectRatio, imageBox }) { + let [dx, dy] = delta; let { x, y, width, height } = rect; - if (direction.includes('n')) { - y += dy; - height -= dy; + if (x + dx < imageBox.x) { + dx = imageBox.x - x; } - if (direction.includes('s')) { + if (y + dy < imageBox.y) { + dy = imageBox.y - y; + } + x += dx; + width -= dx; + y += dy; + height -= dy; + if (aspectRatio && Math.abs(width / height) > aspectRatio) { + dy = width / aspectRatio - height; height += dy; + y -= dy; + if (y <= imageBox.y) { + height = height - (imageBox.y - y); + width = height * aspectRatio; + x = rect.x + rect.width - width; + y = imageBox.y; + } + } else if (aspectRatio) { + dx = height * aspectRatio - width; + width = width + dx; + x -= dx; + if (x <= imageBox.x) { + width = width - (imageBox.x - x); + height = width / aspectRatio; + x = imageBox.x; + y = rect.y + rect.height - height; + } + } + if (height < MIN_CROP_SIZE) { + height = MIN_CROP_SIZE; + if (aspectRatio) { + width = height * aspectRatio; + } + x = rect.x + rect.width - width; + y = rect.y + rect.height - height; + } + if (width < MIN_CROP_SIZE) { + width = MIN_CROP_SIZE; + if (aspectRatio) { + height = width / aspectRatio; + } + x = rect.x + rect.width - width; + y = rect.y + rect.height - height; + } + + return { x, y, width, height }; +} + +/** + * @param {{ + * rect: import('./types.js').Rectangle; + * delta: [Number, Number]; + * aspectRatio?: number; + * imageBox: import('./types.js').Rectangle; + * }} options + */ +function resizeNorthEast({ rect, delta, aspectRatio, imageBox }) { + let [dx, dy] = delta; + let { x, y, width, height } = rect; + + if (x + width + dx > imageBox.x + imageBox.width) { + dx = imageBox.x + imageBox.width - x - width; } - if (direction.includes('w')) { - x += dx; - width -= dx; + if (y + dy < imageBox.y) { + dy = imageBox.y - y; } - if (direction.includes('e')) { + width += dx; + y += dy; + height -= dy; + if (aspectRatio && Math.abs(width / height) > aspectRatio) { + dy = width / aspectRatio - height; + height += dy; + y -= dy; + if (y <= imageBox.y) { + height = height - (imageBox.y - y); + width = height * aspectRatio; + x = rect.x; + y = imageBox.y; + } + } else if (aspectRatio) { + dx = height * aspectRatio - width; width += dx; + if (x + width >= imageBox.x + imageBox.width) { + width = imageBox.x + imageBox.width - x; + height = width / aspectRatio; + x = imageBox.x + imageBox.width - width; + y = rect.y + rect.height - height; + } } - return { - x, - y, - width, - height, - }; + if (height < MIN_CROP_SIZE) { + height = MIN_CROP_SIZE; + if (aspectRatio) { + width = height * aspectRatio; + } + y = rect.y + rect.height - height; + } + if (width < MIN_CROP_SIZE) { + width = MIN_CROP_SIZE; + if (aspectRatio) { + height = width / aspectRatio; + } + y = rect.y + rect.height - height; + } + + return { x, y, width, height }; } /** - * @param {import('./types.js').Rectangle} rect1 - * @param {import('./types.js').Rectangle} rect2 + * @param {{ + * rect: import('./types.js').Rectangle; + * delta: [Number, Number]; + * aspectRatio?: number; + * imageBox: import('./types.js').Rectangle; + * }} options */ -export function intersectionRect(rect1, rect2) { - let leftX = Math.max(rect1.x, rect2.x); - let rightX = Math.min(rect1.x + rect1.width, rect2.x + rect2.width); - let topY = Math.max(rect1.y, rect2.y); - let bottomY = Math.min(rect1.y + rect1.height, rect2.y + rect2.height); +function resizeSouthWest({ rect, delta, aspectRatio, imageBox }) { + let [dx, dy] = delta; + let { x, y, width, height } = rect; - return { x: leftX, y: topY, width: rightX - leftX, height: bottomY - topY }; + if (x + dx < imageBox.x) { + dx = imageBox.x - x; + } + if (y + height + dy > imageBox.y + imageBox.height) { + dy = imageBox.y + imageBox.height - y - height; + } + x += dx; + width -= dx; + height += dy; + if (aspectRatio && Math.abs(width / height) > aspectRatio) { + dy = width / aspectRatio - height; + height += dy; + if (y + height >= imageBox.y + imageBox.height) { + height = imageBox.y + imageBox.height - y; + width = height * aspectRatio; + x = rect.x + rect.width - width; + y = imageBox.y + imageBox.height - height; + } + } else if (aspectRatio) { + dx = height * aspectRatio - width; + width += dx; + x -= dx; + if (x <= imageBox.x) { + width = width - (imageBox.x - x); + height = width / aspectRatio; + x = imageBox.x; + y = rect.y; + } + } + if (height < MIN_CROP_SIZE) { + height = MIN_CROP_SIZE; + if (aspectRatio) { + width = height * aspectRatio; + } + x = rect.x + rect.width - width; + } + if (width < MIN_CROP_SIZE) { + width = MIN_CROP_SIZE; + if (aspectRatio) { + height = width / aspectRatio; + } + x = rect.x + rect.width - width; + } + + return { x, y, width, height }; } /** - * @param {import('./types.js').Rectangle} rect - * @param {[Number, Number]} minSize - * @param {String} direction + * @param {{ + * rect: import('./types.js').Rectangle; + * delta: [Number, Number]; + * aspectRatio?: number; + * imageBox: import('./types.js').Rectangle; + * }} options */ -export function minRectSize(rect, [minWidth, minHeight], direction) { +function resizeSouthEast({ rect, delta, aspectRatio, imageBox }) { + let [dx, dy] = delta; let { x, y, width, height } = rect; - if (direction.includes('n')) { - let prevHeight = height; - height = Math.max(minHeight, height); - y = y + prevHeight - height; + if (x + width + dx > imageBox.x + imageBox.width) { + dx = imageBox.x + imageBox.width - x - width; } - if (direction.includes('s')) { - height = Math.max(minHeight, height); + if (y + height + dy > imageBox.y + imageBox.height) { + dy = imageBox.y + imageBox.height - y - height; } - if (direction.includes('w')) { - let prevWidth = width; - width = Math.max(minWidth, width); - x = x + prevWidth - width; + width += dx; + height += dy; + if (aspectRatio && Math.abs(width / height) > aspectRatio) { + dy = width / aspectRatio - height; + height += dy; + if (y + height >= imageBox.y + imageBox.height) { + height = imageBox.y + imageBox.height - y; + width = height * aspectRatio; + x = rect.x; + y = imageBox.y + imageBox.height - height; + } + } else if (aspectRatio) { + dx = height * aspectRatio - width; + width += dx; + if (x + width >= imageBox.x + imageBox.width) { + width = imageBox.x + imageBox.width - x; + height = width / aspectRatio; + x = imageBox.x + imageBox.width - width; + y = rect.y; + } + } + if (height < MIN_CROP_SIZE) { + height = MIN_CROP_SIZE; + if (aspectRatio) { + width = height * aspectRatio; + } } - if (direction.includes('e')) { - width = Math.max(minWidth, width); + if (width < MIN_CROP_SIZE) { + width = MIN_CROP_SIZE; + if (aspectRatio) { + height = width / aspectRatio; + } } return { x, y, width, height }; } +/** + * @param {{ + * rect: import('./types.js').Rectangle; + * delta: [Number, Number]; + * direction: import('./types.js').Direction; + * aspectRatio?: number; + * imageBox: import('./types.js').Rectangle; + * }} options + */ +export function resizeRect({ direction, ...rest }) { + switch (direction) { + case 'n': + return resizeNorth(rest); + case 'w': + return resizeWest(rest); + case 's': + return resizeSouth(rest); + case 'e': + return resizeEast(rest); + case 'nw': + return resizeNorthWest(rest); + case 'ne': + return resizeNorthEast(rest); + case 'sw': + return resizeSouthWest(rest); + case 'se': + return resizeSouthEast(rest); + default: + return rest.rect; + } +} + /** * @param {import('./types.js').Rectangle} rect * @param {[Number, Number]} point @@ -208,3 +640,26 @@ export function minRectSize(rect, [minWidth, minHeight], direction) { export function rectContainsPoint(rect, [x, y]) { return rect.x <= x && x <= rect.x + rect.width && rect.y <= y && y <= rect.y + rect.height; } + +/** + * @param {import('./types.js').Rectangle} rect1 + * @param {import('./types.js').Rectangle} rect2 + */ +export function isRectInsideRect(rect1, rect2) { + return ( + rect1.x >= rect2.x && + rect1.y >= rect2.y && + rect1.x + rect1.width <= rect2.x + rect2.width && + rect1.y + rect1.height <= rect2.y + rect2.height + ); +} + +/** + * @param {import('./types.js').ImageSize} imageSize + * @param {Number} angle + * @returns {import('./types.js').ImageSize} + */ +export function rotateSize({ width, height }, angle) { + let swap = (angle / 90) % 2 !== 0; + return { width: swap ? height : width, height: swap ? width : height }; +} diff --git a/blocks/CloudImageEditor/src/cropper-constants.js b/blocks/CloudImageEditor/src/cropper-constants.js index ff3ac7b97..c02feb6fe 100644 --- a/blocks/CloudImageEditor/src/cropper-constants.js +++ b/blocks/CloudImageEditor/src/cropper-constants.js @@ -6,4 +6,4 @@ export const THUMB_OFFSET = THUMB_STROKE_WIDTH / 2; export const GUIDE_STROKE_WIDTH = 1; export const GUIDE_THIRD = 100 / 3; -export const MIN_CROP_SIZE = THUMB_CORNER_SIZE * 2 + THUMB_SIDE_SIZE; +export const MIN_CROP_SIZE = 1; diff --git a/blocks/CloudImageEditor/src/state.js b/blocks/CloudImageEditor/src/state.js index cd9e38b1c..1a029ab55 100644 --- a/blocks/CloudImageEditor/src/state.js +++ b/blocks/CloudImageEditor/src/state.js @@ -1,3 +1,5 @@ +// @ts-check + import { createCdnUrl, createCdnUrlModifiers } from '../../../utils/cdn-utils.js'; import { TRANSPARENT_PIXEL_SRC } from '../../../utils/transparentPixelSrc.js'; import { transformationsToOperations } from './lib/transformationUtils.js'; @@ -14,6 +16,8 @@ export function initState(fnCtx) { '*imageSize': null, /** @type {import('./types.js').Transformations} */ '*editorTransformations': {}, + /** @type {import('./types.js').CropPresetList} */ + '*cropPresetList': [], entry: null, extension: null, @@ -24,8 +28,11 @@ export function initState(fnCtx) { src: TRANSPARENT_PIXEL_SRC, fileType: '', showLoader: false, + + // options uuid: null, cdnUrl: null, + cropPreset: '', 'presence.networkProblems': false, 'presence.modalCaption': true, @@ -41,6 +48,7 @@ export function initState(fnCtx) { } fnCtx.$['*networkProblems'] = false; }, + /** @param {import('./types.js').Transformations} transformations */ '*on.apply': (transformations) => { if (!transformations) { return; diff --git a/blocks/CloudImageEditor/src/types.js b/blocks/CloudImageEditor/src/types.js index e2715e766..6ed699650 100644 --- a/blocks/CloudImageEditor/src/types.js +++ b/blocks/CloudImageEditor/src/types.js @@ -51,4 +51,10 @@ * @property {Transformations} transformations */ +/** @typedef {{ type: 'aspect-ratio'; width: number; height: number }} CropAspectRatio */ + +/** @typedef {CropAspectRatio[]} CropPresetList */ + +/** @typedef {'' | 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'} Direction */ + export {}; diff --git a/blocks/CloudImageEditorActivity/CloudImageEditorActivity.js b/blocks/CloudImageEditorActivity/CloudImageEditorActivity.js index 6ef259ef7..16bc8c518 100644 --- a/blocks/CloudImageEditorActivity/CloudImageEditorActivity.js +++ b/blocks/CloudImageEditorActivity/CloudImageEditorActivity.js @@ -47,11 +47,17 @@ export class CloudImageEditorActivity extends UploaderBlock { } mountEditor() { - let instance = new CloudImageEditorBlock(); - let cdnUrl = this.$.cdnUrl; + const instance = new CloudImageEditorBlock(); + const cdnUrl = this.$.cdnUrl; + const cropPreset = this.cfg.cropPreset; + instance.setAttribute('ctx-name', this.ctxName); instance.setAttribute('cdn-url', cdnUrl); + if (cropPreset) { + instance.setAttribute('crop-preset', cropPreset); + } + instance.addEventListener('apply', (result) => this.handleApply(result)); instance.addEventListener('cancel', () => this.handleCancel()); diff --git a/blocks/Config/initialConfig.js b/blocks/Config/initialConfig.js index 817f4aa98..3ccb80dee 100644 --- a/blocks/Config/initialConfig.js +++ b/blocks/Config/initialConfig.js @@ -24,6 +24,7 @@ export const initialConfig = Object.freeze({ useLocalImageEditor: false, useCloudImageEditor: true, removeCopyright: false, + cropPreset: '', modalScrollLock: true, modalBackdropStrokes: false, diff --git a/blocks/Config/normalizeConfigValue.js b/blocks/Config/normalizeConfigValue.js index b8f317a58..c84a9bcc5 100644 --- a/blocks/Config/normalizeConfigValue.js +++ b/blocks/Config/normalizeConfigValue.js @@ -42,6 +42,7 @@ const mapping = { useLocalImageEditor: asBoolean, useCloudImageEditor: asBoolean, removeCopyright: asBoolean, + cropPreset: asString, modalScrollLock: asBoolean, modalBackdropStrokes: asBoolean, diff --git a/blocks/test/cloud-image-editor.htm b/blocks/test/cloud-image-editor.htm new file mode 100644 index 000000000..3abbb24f7 --- /dev/null +++ b/blocks/test/cloud-image-editor.htm @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/blocks/test/raw-regular.htm b/blocks/test/raw-regular.htm index 6bb0abfc0..24f2ddb6f 100644 --- a/blocks/test/raw-regular.htm +++ b/blocks/test/raw-regular.htm @@ -30,4 +30,4 @@ - + diff --git a/types/exported.d.ts b/types/exported.d.ts index cb9c3422e..64107e4a6 100644 --- a/types/exported.d.ts +++ b/types/exported.d.ts @@ -18,6 +18,7 @@ export type ConfigType = { useLocalImageEditor: boolean; useCloudImageEditor: boolean; removeCopyright: boolean; + cropPreset: string; modalScrollLock: boolean; modalBackdropStrokes: boolean; sourceListWrap: boolean; From 26eda66729e230e2197a8c6a2d879cc9220e850b Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 26 Sep 2023 14:56:23 +0300 Subject: [PATCH 4/7] feat(uploader): force defined aspect ration for the output images --- abstract/UploaderBlock.js | 134 ++++++++++++------ .../src/CloudImageEditorBlock.js | 13 +- blocks/CloudImageEditor/src/CropFrame.js | 57 +++++--- .../src/EditorImageCropper.js | 73 +++++----- .../CloudImageEditor/src/EditorImageFader.js | 2 +- blocks/CloudImageEditor/src/EditorToolbar.js | 2 +- blocks/CloudImageEditor/src/crop-utils.js | 71 ++++++++-- .../CloudImageEditor/src/cropper-constants.js | 2 + blocks/CloudImageEditor/src/lib/debounce.js | 16 --- .../src/lib/parseCropPreset.js | 14 ++ blocks/FileItem/FileItem.js | 2 +- blocks/Modal/Modal.js | 6 +- blocks/test/cloud-image-editor.htm | 2 +- blocks/test/raw-regular.htm | 3 +- blocks/utils/debounce.js | 20 +-- blocks/utils/throttle.js | 30 ++++ .../minimal/FileUploaderMinimal.js | 11 +- 17 files changed, 303 insertions(+), 155 deletions(-) delete mode 100644 blocks/CloudImageEditor/src/lib/debounce.js create mode 100644 blocks/CloudImageEditor/src/lib/parseCropPreset.js create mode 100644 blocks/utils/throttle.js diff --git a/abstract/UploaderBlock.js b/abstract/UploaderBlock.js index 8e0ab5a7a..3fd7a7079 100644 --- a/abstract/UploaderBlock.js +++ b/abstract/UploaderBlock.js @@ -2,18 +2,21 @@ import { ActivityBlock } from './ActivityBlock.js'; import { Data } from '@symbiotejs/symbiote'; -import { IMAGE_ACCEPT_LIST, mergeFileTypes, fileIsImage, matchMimeType, matchExtension } from '../utils/fileTypes.js'; -import { uploadEntrySchema } from './uploadEntrySchema.js'; -import { customUserAgent } from '../blocks/utils/userAgent.js'; -import { TypedCollection } from './TypedCollection.js'; -import { uploaderBlockCtx } from './CTX.js'; -import { EVENT_TYPES, EventData, EventManager } from './EventManager.js'; +import { calculateMaxCenteredCropFrame } from '../blocks/CloudImageEditor/src/crop-utils.js'; +import { parseCropPreset } from '../blocks/CloudImageEditor/src/lib/parseCropPreset.js'; import { Modal } from '../blocks/Modal/Modal.js'; -import { stringToArray } from '../utils/stringToArray.js'; -import { warnOnce } from '../utils/warnOnce.js'; import { UploadSource } from '../blocks/utils/UploadSource.js'; -import { prettyBytes } from '../utils/prettyBytes.js'; import { debounce } from '../blocks/utils/debounce.js'; +import { customUserAgent } from '../blocks/utils/userAgent.js'; +import { createCdnUrl, createCdnUrlModifiers } from '../utils/cdn-utils.js'; +import { IMAGE_ACCEPT_LIST, fileIsImage, matchExtension, matchMimeType, mergeFileTypes } from '../utils/fileTypes.js'; +import { prettyBytes } from '../utils/prettyBytes.js'; +import { stringToArray } from '../utils/stringToArray.js'; +import { warnOnce } from '../utils/warnOnce.js'; +import { uploaderBlockCtx } from './CTX.js'; +import { EVENT_TYPES, EventData, EventManager } from './EventManager.js'; +import { TypedCollection } from './TypedCollection.js'; +import { uploadEntrySchema } from './uploadEntrySchema.js'; export class UploaderBlock extends ActivityBlock { couldBeUploadCollectionOwner = false; @@ -457,6 +460,41 @@ export class UploaderBlock extends ActivityBlock { ); } if (changeMap.fileInfo) { + if (this.cfg.cropPreset) { + const cropPreset = parseCropPreset(this.cfg.cropPreset); + if (cropPreset) { + const [aspectRatioPreset] = cropPreset; + + const entries = this.uploadCollection + .findItems( + (entry) => + entry.getValue('fileInfo') && + entry.getValue('isImage') && + !entry.getValue('cdnUrlModifiers')?.includes('/crop/') + ) + .map((id) => this.uploadCollection.read(id)); + + for (const entry of entries) { + const fileInfo = entry.getValue('fileInfo'); + const { width, height } = fileInfo.imageInfo; + const expectedAspectRatio = aspectRatioPreset.width / aspectRatioPreset.height; + const crop = calculateMaxCenteredCropFrame(width, height, expectedAspectRatio); + const cdnUrlModifiers = createCdnUrlModifiers(`crop/${crop.width}x${crop.height}/${crop.x},${crop.y}`); + entry.setMultipleValues({ + cdnUrlModifiers, + cdnUrl: createCdnUrl(entry.getValue('cdnUrl'), cdnUrlModifiers), + }); + if ( + uploadCollection.size === 1 && + this.cfg.useCloudImageEditor && + this.hasBlockInCtx((block) => block.activityType === ActivityBlock.activities.CLOUD_IMG_EDIT) + ) { + this.$['*focusedEntry'] = entry; + this.$['*currentActivity'] = ActivityBlock.activities.CLOUD_IMG_EDIT; + } + } + } + } let loadedItems = uploadCollection.findItems((entry) => { return !!entry.getValue('fileInfo'); }); @@ -614,41 +652,47 @@ UploaderBlock.sourceTypes = Object.freeze({ }); Object.values(EVENT_TYPES).forEach((eType) => { - let eName = EventManager.eName(eType); - window.addEventListener(eName, (e) => { - let outputTypes = [EVENT_TYPES.UPLOAD_FINISH, EVENT_TYPES.REMOVE, EVENT_TYPES.CDN_MODIFICATION]; - // @ts-ignore TODO: fix this - if (outputTypes.includes(e.detail.type)) { + const eName = EventManager.eName(eType); + const cb = debounce( + /** @param {CustomEvent} e */ + (e) => { + let outputTypes = [EVENT_TYPES.UPLOAD_FINISH, EVENT_TYPES.REMOVE, EVENT_TYPES.CDN_MODIFICATION]; // @ts-ignore TODO: fix this - let dataCtx = Data.getCtx(e.detail.ctx); - /** @type {TypedCollection} */ - let uploadCollection = dataCtx.read('uploadCollection'); - // @ts-ignore TODO: fix this - let data = []; - uploadCollection.items().forEach((id) => { - let uploadEntryData = Data.getCtx(id).store; - /** @type {import('@uploadcare/upload-client').UploadcareFile} */ - let fileInfo = uploadEntryData.fileInfo; - if (fileInfo) { - let outputItem = { - ...fileInfo, - cdnUrlModifiers: uploadEntryData.cdnUrlModifiers, - cdnUrl: uploadEntryData.cdnUrl || fileInfo.cdnUrl, - }; - data.push(outputItem); - } - }); - EventManager.emit( - new EventData({ - type: EVENT_TYPES.DATA_OUTPUT, - // @ts-ignore TODO: fix this - ctx: e.detail.ctx, - // @ts-ignore TODO: fix this - data, - }) - ); - // @ts-ignore TODO: fix this - dataCtx.pub('outputData', data); - } - }); + if (outputTypes.includes(e.detail.type)) { + // @ts-ignore TODO: fix this + let dataCtx = Data.getCtx(e.detail.ctx); + /** @type {TypedCollection} */ + let uploadCollection = dataCtx.read('uploadCollection'); + // @ts-ignore TODO: fix this + let data = []; + uploadCollection.items().forEach((id) => { + let uploadEntryData = Data.getCtx(id).store; + /** @type {import('@uploadcare/upload-client').UploadcareFile} */ + let fileInfo = uploadEntryData.fileInfo; + if (fileInfo) { + let outputItem = { + ...fileInfo, + cdnUrlModifiers: uploadEntryData.cdnUrlModifiers, + cdnUrl: uploadEntryData.cdnUrl || fileInfo.cdnUrl, + }; + data.push(outputItem); + } + }); + EventManager.emit( + new EventData({ + type: EVENT_TYPES.DATA_OUTPUT, + // @ts-ignore TODO: fix this + ctx: e.detail.ctx, + // @ts-ignore TODO: fix this + data, + }) + ); + // @ts-ignore TODO: fix this + dataCtx.pub('outputData', data); + } + }, + 0 + ); + // @ts-ignore TODO: fix this + window.addEventListener(eName, cb); }); diff --git a/blocks/CloudImageEditor/src/CloudImageEditorBlock.js b/blocks/CloudImageEditor/src/CloudImageEditorBlock.js index 87f051f06..60247e2df 100644 --- a/blocks/CloudImageEditor/src/CloudImageEditorBlock.js +++ b/blocks/CloudImageEditor/src/CloudImageEditorBlock.js @@ -7,9 +7,10 @@ import { extractUuid, } from '../../../utils/cdn-utils.js'; import { TRANSPARENT_PIXEL_SRC } from '../../../utils/transparentPixelSrc.js'; +import { debounce } from '../../utils/debounce.js'; import { CloudImageEditorBase } from './CloudImageEditorBase.js'; import { classNames } from './lib/classNames.js'; -import { debounce } from './lib/debounce.js'; +import { parseCropPreset } from './lib/parseCropPreset.js'; import { operationsToTransformations, transformationsToOperations } from './lib/transformationUtils.js'; import { initState } from './state.js'; import { TEMPLATE } from './template.js'; @@ -155,15 +156,7 @@ export class CloudImageEditorBlock extends CloudImageEditorBase { }); this.sub('cropPreset', (val) => { - if (!val) return; - const [w, h] = val.split(':').map(Number); - if (!Number.isFinite(w) || !Number.isFinite(h)) { - console.error(`Invalid crop preset: ${val}`); - return; - } - /** @type {import('./types.js').CropAspectRatio} */ - const aspectRatio = { type: 'aspect-ratio', width: w, height: h }; - this.$['*cropPresetList'] = [aspectRatio]; + this.$['*cropPresetList'] = parseCropPreset(val); }); this.sub('*tabId', (tabId) => { diff --git a/blocks/CloudImageEditor/src/CropFrame.js b/blocks/CloudImageEditor/src/CropFrame.js index 3b2b6092c..b8b34d3fd 100644 --- a/blocks/CloudImageEditor/src/CropFrame.js +++ b/blocks/CloudImageEditor/src/CropFrame.js @@ -1,11 +1,14 @@ // @ts-check import { CloudImageEditorBase } from './CloudImageEditorBase.js'; import { + clamp, + constraintRect, cornerPath, createSvgNode, - resizeRect, moveRect, rectContainsPoint, + resizeRect, + roundRect, setSvgNodeAttrs, sidePath, thumbCursor, @@ -13,9 +16,12 @@ import { import { GUIDE_STROKE_WIDTH, GUIDE_THIRD, + MAX_INTERACTION_SIZE, MIN_CROP_SIZE, + MIN_INTERACTION_SIZE, THUMB_CORNER_SIZE, THUMB_OFFSET, + THUMB_SIDE_SIZE, THUMB_STROKE_WIDTH, } from './cropper-constants.js'; import { classNames } from './lib/classNames.js'; @@ -142,23 +148,40 @@ export class CropFrame extends CloudImageEditorBase { let { direction, pathNode, interactionNode, groupNode } = thumb; let isCenter = direction === ''; let isCorner = direction.length === 2; + let { x, y, width, height } = cropBox; if (isCenter) { - let { x, y, width, height } = cropBox; - let center = [x + width / 2, y + height / 2]; - setSvgNodeAttrs(interactionNode, { - r: Math.min(width, height) / 3, - cx: center[0], - cy: center[1], - }); + const moveThumbRect = { + x: x + width / 3, + y: y + height / 3, + width: width / 3, + height: height / 3, + }; + setSvgNodeAttrs(interactionNode, moveThumbRect); } else { + const thumbSizeMultiplier = clamp( + Math.min(width, height) / (THUMB_CORNER_SIZE * 2 + THUMB_SIDE_SIZE) / 2, + 0, + 1 + ); + let { d, center } = isCorner - ? cornerPath(cropBox, direction) + ? cornerPath(cropBox, direction, thumbSizeMultiplier) : sidePath( cropBox, - /** @type {Extract} */ (direction) + /** @type {Extract} */ (direction), + thumbSizeMultiplier ); - setSvgNodeAttrs(interactionNode, { cx: center[0], cy: center[1] }); + const size = Math.max( + MAX_INTERACTION_SIZE * clamp(Math.min(width, height) / MAX_INTERACTION_SIZE / 3, 0, 1), + MIN_INTERACTION_SIZE + ); + setSvgNodeAttrs(interactionNode, { + x: center[0] - size, + y: center[1] - size, + width: size * 2, + height: size * 2, + }); setSvgNodeAttrs(pathNode, { d }); } @@ -200,8 +223,7 @@ export class CropFrame extends CloudImageEditorBase { let groupNode = createSvgNode('g'); groupNode.classList.add('thumb'); groupNode.setAttribute('with-effects', ''); - let interactionNode = createSvgNode('circle', { - r: THUMB_CORNER_SIZE + THUMB_OFFSET, + let interactionNode = createSvgNode('rect', { fill: 'transparent', }); let pathNode = createSvgNode('path', { @@ -349,7 +371,10 @@ export class CropFrame extends CloudImageEditorBase { let dy = y - this._dragStartPoint[1]; let { direction } = this._draggingThumb; - this.$['*cropBox'] = this._calcCropBox(direction, [dx, dy]); + const movedCropBox = this._calcCropBox(direction, [dx, dy]); + if (movedCropBox) { + this.$['*cropBox'] = movedCropBox; + } } /** @@ -378,7 +403,7 @@ export class CropFrame extends CloudImageEditorBase { }); return; } - return rect; + return constraintRect(roundRect(rect), this.$['*imageBox']); } /** @@ -392,7 +417,7 @@ export class CropFrame extends CloudImageEditorBase { if (this._shouldThumbBeDisabled(thumb.direction)) { return false; } - let node = thumb.groupNode; + let node = thumb.interactionNode; let bounds = node.getBoundingClientRect(); let rect = { x: bounds.x, diff --git a/blocks/CloudImageEditor/src/EditorImageCropper.js b/blocks/CloudImageEditor/src/EditorImageCropper.js index bacc8eadf..afc978577 100644 --- a/blocks/CloudImageEditor/src/EditorImageCropper.js +++ b/blocks/CloudImageEditor/src/EditorImageCropper.js @@ -1,10 +1,11 @@ // @ts-check +import { debounce } from '../../utils/debounce.js'; +import { throttle } from '../../utils/throttle.js'; import { CloudImageEditorBase } from './CloudImageEditorBase.js'; -import { isRectInsideRect, rotateSize } from './crop-utils.js'; +import { clamp, constraintRect, isRectInsideRect, rotateSize, roundRect } from './crop-utils.js'; import { CROP_PADDING } from './cropper-constants.js'; import { classNames } from './lib/classNames.js'; -import { debounce } from './lib/debounce.js'; import { pick } from './lib/pick.js'; import { preloadImage } from './lib/preloadImage.js'; import { viewerImageSrc } from './util.js'; @@ -16,16 +17,6 @@ import { viewerImageSrc } from './util.js'; * @property {Number} rotate */ -/** - * @param {Number} value - * @param {Number} min - * @param {Number} max - * @returns {Number} - */ -function clamp(value, min, max) { - return Math.min(Math.max(value, min), max); -} - /** * @param {import('./types.js').Transformations['crop']} crop * @returns {boolean} @@ -77,18 +68,21 @@ export class EditorImageCropper extends CloudImageEditorBase { this._commitDebounced = debounce(this._commit.bind(this), 300); /** @private */ - this._handleResizeDebounced = debounce(this._handleResize.bind(this), 10); + this._handleResizeThrottled = throttle(this._handleResize.bind(this), 100); this._imageSize = { width: 0, height: 0 }; } /** @private */ _handleResize() { - if (!this.isConnected) { + if (!this.isConnected || !this._isActive) { return; } - this.deactivate(); - this.activate(this._imageSize, { fromViewer: false }); + this._initCanvas(); + this._syncTransformations(); + this._alignImage(); + this._alignCrop(); + this._draw(); } /** @private */ @@ -131,6 +125,7 @@ export class EditorImageCropper extends CloudImageEditorBase { let bounds = { width: this.offsetWidth, height: this.offsetHeight }; let naturalSize = rotateSize({ width: image.naturalWidth, height: image.naturalHeight }, rotate); + let imageBox; if (naturalSize.width > bounds.width - padding * 2 || naturalSize.height > bounds.height - padding * 2) { let imageAspectRatio = naturalSize.width / naturalSize.height; @@ -141,20 +136,22 @@ export class EditorImageCropper extends CloudImageEditorBase { let height = width / imageAspectRatio; let x = 0 + padding; let y = padding + (bounds.height - padding * 2) / 2 - height / 2; - this.$['*imageBox'] = { x, y, width, height }; + imageBox = { x, y, width, height }; } else { let height = bounds.height - padding * 2; let width = height * imageAspectRatio; let x = padding + (bounds.width - padding * 2) / 2 - width / 2; let y = 0 + padding; - this.$['*imageBox'] = { x, y, width, height }; + imageBox = { x, y, width, height }; } } else { let { width, height } = naturalSize; let x = padding + (bounds.width - padding * 2) / 2 - width / 2; let y = padding + (bounds.height - padding * 2) / 2 - height / 2; - this.$['*imageBox'] = { x, y, width, height }; + imageBox = { x, y, width, height }; } + + this.$['*imageBox'] = roundRect(imageBox); } /** @private */ @@ -173,12 +170,15 @@ export class EditorImageCropper extends CloudImageEditorBase { } = cropTransformation; let { width: sourceWidth } = rotateSize(this._imageSize, rotate); let ratio = previewWidth / sourceWidth; - cropBox = { - x: previewX + x * ratio, - y: previewY + y * ratio, - width: width * ratio, - height: height * ratio, - }; + cropBox = constraintRect( + roundRect({ + x: previewX + x * ratio, + y: previewY + y * ratio, + width: width * ratio, + height: height * ratio, + }), + this.$['*imageBox'] + ); } if (!cropTransformation || !isRectInsideRect(cropBox, imageBox)) { @@ -203,7 +203,7 @@ export class EditorImageCropper extends CloudImageEditorBase { }; } - this.$['*cropBox'] = cropBox; + this.$['*cropBox'] = constraintRect(roundRect(cropBox), this.$['*imageBox']); } /** @private */ @@ -375,18 +375,22 @@ export class EditorImageCropper extends CloudImageEditorBase { this._isActive = true; this._imageSize = imageSize; this.removeEventListener('transitionend', this._reset); - this._initCanvas(); try { this.$.image = await this._waitForImage(this.$['*originalUrl'], this.$['*editorTransformations']); this._syncTransformations(); - this._alignImage(); - this._alignCrop(); - this._draw(); this._animateIn({ fromViewer }); } catch (err) { console.error('Failed to activate cropper', { error: err }); } + + this._observer = new ResizeObserver(([entry]) => { + const nonZeroSize = entry.contentRect.width > 0 && entry.contentRect.height > 0; + if (nonZeroSize && this._isActive && this.$.image) { + this._handleResizeThrottled(); + } + }); + this._observer.observe(this); } deactivate({ reset = false } = {}) { if (!this._isActive) { @@ -405,6 +409,7 @@ export class EditorImageCropper extends CloudImageEditorBase { this.ref['frame-el'].toggleThumbs(false); this.addEventListener('transitionend', this._reset, { once: true }); + this._observer?.disconnect(); } /** @private */ @@ -500,14 +505,6 @@ export class EditorImageCropper extends CloudImageEditorBase { initCallback() { super.initCallback(); - this._observer = new ResizeObserver(([entry]) => { - const nonZeroSize = entry.contentRect.width > 0 && entry.contentRect.height > 0; - if (nonZeroSize && this._isActive && this.$.image) { - this._handleResizeDebounced(); - } - }); - this._observer.observe(this); - this.sub('*imageBox', () => { this._draw(); }); diff --git a/blocks/CloudImageEditor/src/EditorImageFader.js b/blocks/CloudImageEditor/src/EditorImageFader.js index ee6ab0e85..97d90ecb3 100644 --- a/blocks/CloudImageEditor/src/EditorImageFader.js +++ b/blocks/CloudImageEditor/src/EditorImageFader.js @@ -1,6 +1,6 @@ +import { debounce } from '../../utils/debounce.js'; import { CloudImageEditorBase } from './CloudImageEditorBase.js'; import { classNames } from './lib/classNames.js'; -import { debounce } from './lib/debounce.js'; import { linspace } from './lib/linspace.js'; import { batchPreloadImages } from './lib/preloadImage.js'; import { COLOR_OPERATIONS_CONFIG } from './toolbar-constants.js'; diff --git a/blocks/CloudImageEditor/src/EditorToolbar.js b/blocks/CloudImageEditor/src/EditorToolbar.js index d6266b6ab..b060eb205 100644 --- a/blocks/CloudImageEditor/src/EditorToolbar.js +++ b/blocks/CloudImageEditor/src/EditorToolbar.js @@ -1,9 +1,9 @@ +import { debounce } from '../../utils/debounce.js'; import { CloudImageEditorBase } from './CloudImageEditorBase.js'; import { EditorCropButtonControl } from './EditorCropButtonControl.js'; import { EditorFilterControl } from './EditorFilterControl.js'; import { EditorOperationControl } from './EditorOperationControl.js'; import { FAKE_ORIGINAL_FILTER } from './EditorSlider.js'; -import { debounce } from './lib/debounce.js'; import { batchPreloadImages } from './lib/preloadImage.js'; import { ALL_COLOR_OPERATIONS, diff --git a/blocks/CloudImageEditor/src/crop-utils.js b/blocks/CloudImageEditor/src/crop-utils.js index fd51f1be4..470d8df5a 100644 --- a/blocks/CloudImageEditor/src/crop-utils.js +++ b/blocks/CloudImageEditor/src/crop-utils.js @@ -23,8 +23,9 @@ export function createSvgNode(name, attrs = {}) { /** * @param {import('./types.js').Rectangle} rect * @param {import('./types.js').Direction} direction + * @param {number} sizeMultiplier */ -export function cornerPath(rect, direction) { +export function cornerPath(rect, direction, sizeMultiplier) { let { x, y, width, height } = rect; let wMul = direction.includes('w') ? 0 : 1; @@ -34,11 +35,11 @@ export function cornerPath(rect, direction) { let p1 = [ x + wMul * width + THUMB_OFFSET * xSide, - y + hMul * height + THUMB_OFFSET * ySide - THUMB_CORNER_SIZE * ySide, + y + hMul * height + THUMB_OFFSET * ySide - THUMB_CORNER_SIZE * sizeMultiplier * ySide, ]; let p2 = [x + wMul * width + THUMB_OFFSET * xSide, y + hMul * height + THUMB_OFFSET * ySide]; let p3 = [ - x + wMul * width - THUMB_CORNER_SIZE * xSide + THUMB_OFFSET * xSide, + x + wMul * width - THUMB_CORNER_SIZE * sizeMultiplier * xSide + THUMB_OFFSET * xSide, y + hMul * height + THUMB_OFFSET * ySide, ]; @@ -54,8 +55,9 @@ export function cornerPath(rect, direction) { /** * @param {import('./types.js').Rectangle} rect * @param {Extract} direction + * @param {number} sizeMultiplier */ -export function sidePath(rect, direction) { +export function sidePath(rect, direction, sizeMultiplier) { let { x, y, width, height } = rect; let wMul = ['n', 's'].includes(direction) @@ -69,11 +71,11 @@ export function sidePath(rect, direction) { let p1, p2; if (['n', 's'].includes(direction)) { - p1 = [x + wMul * width - THUMB_SIDE_SIZE / 2, y + hMul * height + THUMB_OFFSET * ySide]; - p2 = [x + wMul * width + THUMB_SIDE_SIZE / 2, y + hMul * height + THUMB_OFFSET * ySide]; + p1 = [x + wMul * width - (THUMB_SIDE_SIZE * sizeMultiplier) / 2, y + hMul * height + THUMB_OFFSET * ySide]; + p2 = [x + wMul * width + (THUMB_SIDE_SIZE * sizeMultiplier) / 2, y + hMul * height + THUMB_OFFSET * ySide]; } else { - p1 = [x + wMul * width + THUMB_OFFSET * xSide, y + hMul * height - THUMB_SIDE_SIZE / 2]; - p2 = [x + wMul * width + THUMB_OFFSET * xSide, y + hMul * height + THUMB_SIDE_SIZE / 2]; + p1 = [x + wMul * width + THUMB_OFFSET * xSide, y + hMul * height - (THUMB_SIDE_SIZE * sizeMultiplier) / 2]; + p2 = [x + wMul * width + THUMB_OFFSET * xSide, y + hMul * height + (THUMB_SIDE_SIZE * sizeMultiplier) / 2]; } let path = `M ${p1[0]} ${p1[1]} L ${p2[0]} ${p2[1]}`; let center = [p2[0] - (p2[0] - p1[0]) / 2, p2[1] - (p2[1] - p1[1]) / 2]; @@ -663,3 +665,56 @@ export function rotateSize({ width, height }, angle) { let swap = (angle / 90) % 2 !== 0; return { width: swap ? height : width, height: swap ? width : height }; } + +/** + * @param {number} width + * @param {number} height + * @param {number} aspectRatio + */ +export function calculateMaxCenteredCropFrame(width, height, aspectRatio) { + const imageAspectRatio = width / height; + let cropWidth, cropHeight; + + if (imageAspectRatio > aspectRatio) { + cropWidth = Math.round(height * aspectRatio); + cropHeight = height; + } else { + cropWidth = width; + cropHeight = Math.round(width / aspectRatio); + } + + const cropX = Math.round((width - cropWidth) / 2); + const cropY = Math.round((height - cropHeight) / 2); + + if (cropX + cropWidth > width) { + cropWidth = width - cropX; + } + if (cropY + cropHeight > height) { + cropHeight = height - cropY; + } + + return { x: cropX, y: cropY, width: cropWidth, height: cropHeight }; +} + +/** + * @param {import('./types.js').Rectangle} rect + * @returns {import('./types.js').Rectangle} + */ +export function roundRect(rect) { + return { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; +} + +/** + * @param {Number} value + * @param {Number} min + * @param {Number} max + * @returns {Number} + */ +export function clamp(value, min, max) { + return Math.min(Math.max(value, min), max); +} diff --git a/blocks/CloudImageEditor/src/cropper-constants.js b/blocks/CloudImageEditor/src/cropper-constants.js index c02feb6fe..5245b8f3d 100644 --- a/blocks/CloudImageEditor/src/cropper-constants.js +++ b/blocks/CloudImageEditor/src/cropper-constants.js @@ -7,3 +7,5 @@ export const THUMB_OFFSET = THUMB_STROKE_WIDTH / 2; export const GUIDE_STROKE_WIDTH = 1; export const GUIDE_THIRD = 100 / 3; export const MIN_CROP_SIZE = 1; +export const MAX_INTERACTION_SIZE = 24; +export const MIN_INTERACTION_SIZE = 6; diff --git a/blocks/CloudImageEditor/src/lib/debounce.js b/blocks/CloudImageEditor/src/lib/debounce.js deleted file mode 100644 index 88008ae93..000000000 --- a/blocks/CloudImageEditor/src/lib/debounce.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @param {function} callback - * @param {Number} wait - * @returns {function} - */ -export function debounce(callback, wait) { - let timer; - let debounced = (...args) => { - clearTimeout(timer); - timer = setTimeout(() => callback(...args), wait); - }; - debounced.cancel = () => { - clearTimeout(timer); - }; - return debounced; -} diff --git a/blocks/CloudImageEditor/src/lib/parseCropPreset.js b/blocks/CloudImageEditor/src/lib/parseCropPreset.js new file mode 100644 index 000000000..025ea40e4 --- /dev/null +++ b/blocks/CloudImageEditor/src/lib/parseCropPreset.js @@ -0,0 +1,14 @@ +// @ts-check + +/** @param {import('../../../../types/exported.d.ts').ConfigType['cropPreset']} cropPreset */ +export const parseCropPreset = (cropPreset) => { + if (!cropPreset) return []; + const [w, h] = cropPreset.split(':').map(Number); + if (!Number.isFinite(w) || !Number.isFinite(h)) { + console.error(`Invalid crop preset: ${cropPreset}`); + return; + } + /** @type {import('../types.js').CropAspectRatio} */ + const aspectRatio = { type: 'aspect-ratio', width: w, height: h }; + return [aspectRatio]; +}; diff --git a/blocks/FileItem/FileItem.js b/blocks/FileItem/FileItem.js index 9ab4541f9..b9061cfc2 100644 --- a/blocks/FileItem/FileItem.js +++ b/blocks/FileItem/FileItem.js @@ -299,7 +299,7 @@ export class FileItem extends UploaderBlock { isUploading: state === FileItemState.UPLOADING, isFinished: state === FileItemState.FINISHED, progressVisible: state === FileItemState.UPLOADING, - isEditable: this.cfg.useCloudImageEditor && state === FileItemState.FINISHED && this._entry?.getValue('isImage'), + isEditable: this.cfg.useCloudImageEditor && this._entry?.getValue('isImage') && this._entry?.getValue('cdnUrl'), errorText: this._entry.getValue('uploadError')?.message || this._entry.getValue('validationErrorMsg') || diff --git a/blocks/Modal/Modal.js b/blocks/Modal/Modal.js index ac37248d3..81d7f173e 100644 --- a/blocks/Modal/Modal.js +++ b/blocks/Modal/Modal.js @@ -27,7 +27,7 @@ export class Modal extends Block { }; /** @param {Event} e */ - _handleDialogClick = (e) => { + _handleDialogPointerUp = (e) => { if (e.target === this.ref.dialog) { this._closeDialog(); } @@ -53,7 +53,7 @@ export class Modal extends Block { super.initCallback(); if (typeof HTMLDialogElement === 'function') { this.ref.dialog.addEventListener('close', this._handleDialogClose); - this.ref.dialog.addEventListener('click', this._handleDialogClick); + this.ref.dialog.addEventListener('pointerup', this._handleDialogPointerUp); } else { this.setAttribute('dialog-fallback', ''); let backdrop = document.createElement('div'); @@ -95,7 +95,7 @@ export class Modal extends Block { super.destroyCallback(); document.body.style.overflow = ''; this.ref.dialog.removeEventListener('close', this._handleDialogClose); - this.ref.dialog.removeEventListener('click', this._handleDialogClick); + this.ref.dialog.removeEventListener('click', this._handleDialogPointerUp); } } diff --git a/blocks/test/cloud-image-editor.htm b/blocks/test/cloud-image-editor.htm index 3abbb24f7..99200bc0a 100644 --- a/blocks/test/cloud-image-editor.htm +++ b/blocks/test/cloud-image-editor.htm @@ -27,5 +27,5 @@ - + diff --git a/blocks/test/raw-regular.htm b/blocks/test/raw-regular.htm index 24f2ddb6f..dd3dd5d90 100644 --- a/blocks/test/raw-regular.htm +++ b/blocks/test/raw-regular.htm @@ -30,4 +30,5 @@ - + + \ No newline at end of file diff --git a/blocks/utils/debounce.js b/blocks/utils/debounce.js index 454ab449b..09c1c5c76 100644 --- a/blocks/utils/debounce.js +++ b/blocks/utils/debounce.js @@ -1,18 +1,22 @@ +// @ts-check + /** - * @template {Function} T + * @template {{ (...args: any[]): any }} T * @param {T} callback * @param {number} wait - * @returns {T & { cancel: function }} + * @returns {T & { cancel: () => void }} } */ export function debounce(callback, wait) { + /** @type {NodeJS.Timeout} */ let timer; - /** @type {any} */ - let debounced = (...args) => { - clearTimeout(timer); - timer = setTimeout(() => callback(...args), wait); - }; + const debounced = + /** @param {...any} args */ + (...args) => { + clearTimeout(timer); + timer = setTimeout(() => callback(...args), wait); + }; debounced.cancel = () => { clearTimeout(timer); }; - return debounced; + return /** @type {T & { cancel: () => void }} } */ (debounced); } diff --git a/blocks/utils/throttle.js b/blocks/utils/throttle.js new file mode 100644 index 000000000..edfed5da3 --- /dev/null +++ b/blocks/utils/throttle.js @@ -0,0 +1,30 @@ +// @ts-check + +/** + * @param {Function} fn + * @param {number} wait + */ +export const throttle = (fn, wait) => { + /** @type {boolean} */ + let inThrottle; + /** @type {ReturnType} */ + let lastFn; + /** @type {number} */ + let lastTime; + /** @param {...any} args */ + return (...args) => { + if (!inThrottle) { + fn(...args); + lastTime = Date.now(); + inThrottle = true; + } else { + clearTimeout(lastFn); + lastFn = setTimeout(() => { + if (Date.now() - lastTime >= wait) { + fn(...args); + lastTime = Date.now(); + } + }, Math.max(wait - (Date.now() - lastTime), 0)); + } + }; +}; diff --git a/solutions/file-uploader/minimal/FileUploaderMinimal.js b/solutions/file-uploader/minimal/FileUploaderMinimal.js index e7730405d..6ad79c5f2 100644 --- a/solutions/file-uploader/minimal/FileUploaderMinimal.js +++ b/solutions/file-uploader/minimal/FileUploaderMinimal.js @@ -1,6 +1,5 @@ -import { SolutionBlock } from '../../../abstract/SolutionBlock.js'; import { ActivityBlock } from '../../../abstract/ActivityBlock.js'; -import { sharedConfigKey } from '../../../abstract/sharedConfigKey.js'; +import { SolutionBlock } from '../../../abstract/SolutionBlock.js'; export class FileUploaderMinimal extends SolutionBlock { pauseRender = true; @@ -22,15 +21,15 @@ export class FileUploaderMinimal extends SolutionBlock { } }); - this.sub(sharedConfigKey('sourceList'), (sourceList) => { + this.subConfigValue('sourceList', (sourceList) => { if (sourceList !== 'local') { - this.$[sharedConfigKey('sourceList')] = 'local'; + this.cfg.sourceList = 'local'; } }); - this.sub(sharedConfigKey('confirmUpload'), (confirmUpload) => { + this.subConfigValue('confirmUpload', (confirmUpload) => { if (confirmUpload !== false) { - this.$[sharedConfigKey('confirmUpload')] = false; + this.cfg.confirmUpload = false; } }); } From 00893706332ab1a9de4d62e1fc9c51fba952ae08 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Wed, 27 Sep 2023 15:41:51 +0300 Subject: [PATCH 5/7] feat(external-sources): add files to the upload list after done button click --- blocks/ExternalSource/ExternalSource.js | 51 ++++++++++++++----------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/blocks/ExternalSource/ExternalSource.js b/blocks/ExternalSource/ExternalSource.js index 31956b8d1..33957e688 100644 --- a/blocks/ExternalSource/ExternalSource.js +++ b/blocks/ExternalSource/ExternalSource.js @@ -40,8 +40,30 @@ export class ExternalSource extends UploaderBlock { ...this.init$, activityIcon: '', activityCaption: '', + selectedList: [], counter: 0, onDone: () => { + for (const message of this.$.selectedList) { + const url = (() => { + if (message.alternatives) { + const preferredTypes = stringToArray(this.cfg.externalSourcesPreferredTypes); + for (const preferredType of preferredTypes) { + const regexp = wildcardRegexp(preferredType); + for (const [type, typeUrl] of Object.entries(message.alternatives)) { + if (regexp.test(type)) { + return typeUrl; + } + } + } + } + return message.url; + })(); + + const { filename } = message; + const { externalSourceType } = this.activityParams; + this.addFileFromUrl(url, { fileName: filename, source: externalSourceType }); + } + this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; }, onCancel: () => { @@ -67,7 +89,6 @@ export class ExternalSource extends UploaderBlock { activityIcon: externalSourceType, }); - this.$.counter = 0; this.mountIframe(); }, }); @@ -76,6 +97,9 @@ export class ExternalSource extends UploaderBlock { this.unmountIframe(); } }); + this.sub('selectedList', (list) => { + this.$.counter = list.length; + }); } /** @@ -91,27 +115,7 @@ export class ExternalSource extends UploaderBlock { * @param {SelectedFileMessage} message */ async handleFileSelected(message) { - console.log(message); - this.$.counter = this.$.counter + 1; - - const url = (() => { - if (message.alternatives) { - const preferredTypes = stringToArray(this.cfg.externalSourcesPreferredTypes); - for (const preferredType of preferredTypes) { - const regexp = wildcardRegexp(preferredType); - for (const [type, typeUrl] of Object.entries(message.alternatives)) { - if (regexp.test(type)) { - return typeUrl; - } - } - } - } - return message.url; - })(); - - let { filename } = message; - let { externalSourceType } = this.activityParams; - this.addFileFromUrl(url, { fileName: filename, source: externalSourceType }); + this.$.selectedList = [...this.$.selectedList, message]; } /** @private */ @@ -193,6 +197,7 @@ export class ExternalSource extends UploaderBlock { registerMessage('file-selected', iframe.contentWindow, this.handleFileSelected.bind(this)); this._iframe = iframe; + this.$.selectedList = []; } /** @private */ @@ -200,6 +205,8 @@ export class ExternalSource extends UploaderBlock { this._iframe && unregisterMessage('file-selected', this._iframe.contentWindow); this.ref.iframeWrapper.innerHTML = ''; this._iframe = null; + this.$.selectedList = []; + this.$.counter = 0; } } From c1b73676befcf43db21819c1f0b5593ec5160977 Mon Sep 17 00:00:00 2001 From: nd0ut Date: Thu, 28 Sep 2023 14:24:48 +0300 Subject: [PATCH 6/7] chore: fix lint errors --- blocks/CloudImageEditor/src/crop-utils.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/blocks/CloudImageEditor/src/crop-utils.js b/blocks/CloudImageEditor/src/crop-utils.js index 470d8df5a..200c87afc 100644 --- a/blocks/CloudImageEditor/src/crop-utils.js +++ b/blocks/CloudImageEditor/src/crop-utils.js @@ -153,14 +153,14 @@ export function constraintRect(rect1, rect2) { */ function resizeNorth({ rect, delta, aspectRatio, imageBox }) { const [, dy] = delta; - let { x, y, width, height } = rect; + let { y, width, height } = rect; y += dy; height -= dy; if (aspectRatio) { width = height * aspectRatio; } - x = rect.x + rect.width / 2 - width / 2; + let x = rect.x + rect.width / 2 - width / 2; if (y <= imageBox.y) { y = imageBox.y; height = rect.y + rect.height - y; @@ -211,14 +211,14 @@ function resizeNorth({ rect, delta, aspectRatio, imageBox }) { */ function resizeWest({ rect, delta, aspectRatio, imageBox }) { const [dx] = delta; - let { x, y, width, height } = rect; + let { x, width, height } = rect; x += dx; width -= dx; if (aspectRatio) { height = width / aspectRatio; } - y = rect.y + rect.height / 2 - height / 2; + let y = rect.y + rect.height / 2 - height / 2; if (x <= imageBox.x) { x = imageBox.x; width = rect.x + rect.width - x; @@ -269,13 +269,13 @@ function resizeWest({ rect, delta, aspectRatio, imageBox }) { */ function resizeSouth({ rect, delta, aspectRatio, imageBox }) { const [, dy] = delta; - let { x, y, width, height } = rect; + let { y, width, height } = rect; height += dy; if (aspectRatio) { width = height * aspectRatio; } - x = rect.x + rect.width / 2 - width / 2; + let x = rect.x + rect.width / 2 - width / 2; if (y + height >= imageBox.y + imageBox.height) { height = imageBox.y + imageBox.height - y; if (aspectRatio) { @@ -323,13 +323,13 @@ function resizeSouth({ rect, delta, aspectRatio, imageBox }) { */ function resizeEast({ rect, delta, aspectRatio, imageBox }) { const [dx] = delta; - let { x, y, width, height } = rect; + let { x, width, height } = rect; width += dx; if (aspectRatio) { height = width / aspectRatio; } - y = rect.y + rect.height / 2 - height / 2; + let y = rect.y + rect.height / 2 - height / 2; if (x + width >= imageBox.x + imageBox.width) { width = imageBox.x + imageBox.width - x; if (aspectRatio) { From 8f9d39701aaecb48cf0876dafb930a4bde93999b Mon Sep 17 00:00:00 2001 From: nd0ut Date: Tue, 3 Oct 2023 11:13:32 +0300 Subject: [PATCH 7/7] chore: refactor --- abstract/UploaderBlock.js | 71 ++++++++++--------- .../src/CloudImageEditorBlock.js | 23 +++--- blocks/ExternalSource/ExternalSource.js | 36 ++++++---- 3 files changed, 70 insertions(+), 60 deletions(-) diff --git a/abstract/UploaderBlock.js b/abstract/UploaderBlock.js index 3fd7a7079..619deaf36 100644 --- a/abstract/UploaderBlock.js +++ b/abstract/UploaderBlock.js @@ -461,39 +461,7 @@ export class UploaderBlock extends ActivityBlock { } if (changeMap.fileInfo) { if (this.cfg.cropPreset) { - const cropPreset = parseCropPreset(this.cfg.cropPreset); - if (cropPreset) { - const [aspectRatioPreset] = cropPreset; - - const entries = this.uploadCollection - .findItems( - (entry) => - entry.getValue('fileInfo') && - entry.getValue('isImage') && - !entry.getValue('cdnUrlModifiers')?.includes('/crop/') - ) - .map((id) => this.uploadCollection.read(id)); - - for (const entry of entries) { - const fileInfo = entry.getValue('fileInfo'); - const { width, height } = fileInfo.imageInfo; - const expectedAspectRatio = aspectRatioPreset.width / aspectRatioPreset.height; - const crop = calculateMaxCenteredCropFrame(width, height, expectedAspectRatio); - const cdnUrlModifiers = createCdnUrlModifiers(`crop/${crop.width}x${crop.height}/${crop.x},${crop.y}`); - entry.setMultipleValues({ - cdnUrlModifiers, - cdnUrl: createCdnUrl(entry.getValue('cdnUrl'), cdnUrlModifiers), - }); - if ( - uploadCollection.size === 1 && - this.cfg.useCloudImageEditor && - this.hasBlockInCtx((block) => block.activityType === ActivityBlock.activities.CLOUD_IMG_EDIT) - ) { - this.$['*focusedEntry'] = entry; - this.$['*currentActivity'] = ActivityBlock.activities.CLOUD_IMG_EDIT; - } - } - } + this.setInitialCrop(); } let loadedItems = uploadCollection.findItems((entry) => { return !!entry.getValue('fileInfo'); @@ -565,6 +533,43 @@ export class UploaderBlock extends ActivityBlock { } }; + /** @private */ + setInitialCrop() { + const cropPreset = parseCropPreset(this.cfg.cropPreset); + if (cropPreset) { + const [aspectRatioPreset] = cropPreset; + + const entries = this.uploadCollection + .findItems( + (entry) => + entry.getValue('fileInfo') && + entry.getValue('isImage') && + !entry.getValue('cdnUrlModifiers')?.includes('/crop/') + ) + .map((id) => this.uploadCollection.read(id)); + + for (const entry of entries) { + const fileInfo = entry.getValue('fileInfo'); + const { width, height } = fileInfo.imageInfo; + const expectedAspectRatio = aspectRatioPreset.width / aspectRatioPreset.height; + const crop = calculateMaxCenteredCropFrame(width, height, expectedAspectRatio); + const cdnUrlModifiers = createCdnUrlModifiers(`crop/${crop.width}x${crop.height}/${crop.x},${crop.y}`); + entry.setMultipleValues({ + cdnUrlModifiers, + cdnUrl: createCdnUrl(entry.getValue('cdnUrl'), cdnUrlModifiers), + }); + if ( + this.uploadCollection.size === 1 && + this.cfg.useCloudImageEditor && + this.hasBlockInCtx((block) => block.activityType === ActivityBlock.activities.CLOUD_IMG_EDIT) + ) { + this.$['*focusedEntry'] = entry; + this.$['*currentActivity'] = ActivityBlock.activities.CLOUD_IMG_EDIT; + } + } + } + } + /** @private */ async getMetadata() { const configValue = this.cfg.metadata ?? /** @type {import('../types').Metadata} */ (this.$['*uploadMetadata']); diff --git a/blocks/CloudImageEditor/src/CloudImageEditorBlock.js b/blocks/CloudImageEditor/src/CloudImageEditorBlock.js index 60247e2df..d72ee22d0 100644 --- a/blocks/CloudImageEditor/src/CloudImageEditorBlock.js +++ b/blocks/CloudImageEditor/src/CloudImageEditorBlock.js @@ -101,18 +101,17 @@ export class CloudImageEditorBlock extends CloudImageEditorBase { } try { - fetch(createCdnUrl(this.$['*originalUrl'], createCdnUrlModifiers('json'))) - .then((response) => response.json()) - .then((json) => { - const { width, height } = /** @type {{ width: number; height: number }} */ (json); - this.$['*imageSize'] = { width, height }; - - if (this.$['*tabId'] === TabId.CROP) { - this.$['*cropperEl'].activate(this.$['*imageSize']); - } else { - this.$['*faderEl'].activate({ url: this.$['*originalUrl'] }); - } - }); + const cdnUrl = createCdnUrl(this.$['*originalUrl'], createCdnUrlModifiers('json')); + const json = await fetch(cdnUrl).then((response) => response.json()); + + const { width, height } = /** @type {{ width: number; height: number }} */ (json); + this.$['*imageSize'] = { width, height }; + + if (this.$['*tabId'] === TabId.CROP) { + this.$['*cropperEl'].activate(this.$['*imageSize']); + } else { + this.$['*faderEl'].activate({ url: this.$['*originalUrl'] }); + } } catch (err) { if (err) { console.error('Failed to load image info', err); diff --git a/blocks/ExternalSource/ExternalSource.js b/blocks/ExternalSource/ExternalSource.js index 33957e688..56a55de9f 100644 --- a/blocks/ExternalSource/ExternalSource.js +++ b/blocks/ExternalSource/ExternalSource.js @@ -44,21 +44,7 @@ export class ExternalSource extends UploaderBlock { counter: 0, onDone: () => { for (const message of this.$.selectedList) { - const url = (() => { - if (message.alternatives) { - const preferredTypes = stringToArray(this.cfg.externalSourcesPreferredTypes); - for (const preferredType of preferredTypes) { - const regexp = wildcardRegexp(preferredType); - for (const [type, typeUrl] of Object.entries(message.alternatives)) { - if (regexp.test(type)) { - return typeUrl; - } - } - } - } - return message.url; - })(); - + const url = this.extractUrlFromMessage(message); const { filename } = message; const { externalSourceType } = this.activityParams; this.addFileFromUrl(url, { fileName: filename, source: externalSourceType }); @@ -102,6 +88,26 @@ export class ExternalSource extends UploaderBlock { }); } + /** + * @private + * @param {SelectedFileMessage} message + */ + extractUrlFromMessage(message) { + if (message.alternatives) { + const preferredTypes = stringToArray(this.cfg.externalSourcesPreferredTypes); + for (const preferredType of preferredTypes) { + const regexp = wildcardRegexp(preferredType); + for (const [type, typeUrl] of Object.entries(message.alternatives)) { + if (regexp.test(type)) { + return typeUrl; + } + } + } + } + + return message.url; + } + /** * @private * @param {Message} message