diff --git a/.prettierrc b/.prettierrc index f32423c1b..a00e51884 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,9 +1,16 @@ { - "parser": "babel", - "plugins": ["prettier-plugin-jsdoc"], "singleQuote": true, "tabWidth": 2, "semi": true, "arrowParens": "always", - "printWidth": 120 + "printWidth": 120, + "overrides": [ + { + "files": "*.js", + "options": { + "parser": "babel", + "plugins": ["prettier-plugin-jsdoc"] + } + } + ] } diff --git a/.stylelintrc.cjs b/.stylelintrc.cjs index ffcf656d9..5307ee1f6 100644 --- a/.stylelintrc.cjs +++ b/.stylelintrc.cjs @@ -20,6 +20,18 @@ module.exports = { 'color-function-notation': null, 'order/properties-order': null, 'rule-empty-line-before': null, + 'at-rule-no-unknown': [ + true, + { + ignoreAtRules: ['container'], + }, + ], + 'property-no-unknown': [ + true, + { + ignoreProperties: ['container-type', 'container-name'], + }, + ], }, overrides: [ { diff --git a/abstract/ActivityBlock.js b/abstract/ActivityBlock.js index fa7fa9827..f9abbe01b 100644 --- a/abstract/ActivityBlock.js +++ b/abstract/ActivityBlock.js @@ -1,3 +1,4 @@ +import { Modal } from '../blocks/Modal/Modal.js'; import { debounce } from '../blocks/utils/debounce.js'; import { Block } from './Block.js'; import { activityBlockCtx } from './CTX.js'; @@ -6,7 +7,8 @@ const ACTIVE_ATTR = 'active'; const ACTIVE_PROP = '___ACTIVITY_IS_ACTIVE___'; export class ActivityBlock extends Block { - ctxInit = activityBlockCtx(); + historyTracked = false; + ctxInit = activityBlockCtx(this); _debouncedHistoryFlush = debounce(this._historyFlush.bind(this), 10); @@ -33,10 +35,10 @@ export class ActivityBlock extends Block { actDesc?.deactivateCallback?.(); // console.log(`Activity "${this.activityType}" deactivated`); } else if (this.activityType === val && !this[ACTIVE_PROP]) { + this.$['*historyBack'] = this.historyBack.bind(this); /** @private */ this[ACTIVE_PROP] = true; this.setAttribute(ACTIVE_ATTR, ''); - this.setForCtxTarget('lr-modal', '*modalCloseCallback', actDesc?.modalCloseCallback); actDesc?.activateCallback?.(); // console.log(`Activity "${this.activityType}" activated`); @@ -56,7 +58,9 @@ export class ActivityBlock extends Block { if (history.length > 10) { history = history.slice(history.length - 11, history.length - 1); } - history.push(this.activityType); + if (this.historyTracked) { + history.push(this.activityType); + } this.$['*history'] = history; } } @@ -76,10 +80,9 @@ export class ActivityBlock extends Block { * @param {Object} [options] * @param {() => void} [options.onActivate] * @param {() => void} [options.onDeactivate] - * @param {() => void} [options.onClose] */ - registerActivity(name, options) { - const { onActivate, onDeactivate, onClose } = options; + registerActivity(name, options = {}) { + const { onActivate, onDeactivate } = options; if (!ActivityBlock._activityRegistry) { ActivityBlock._activityRegistry = Object.create(null); } @@ -87,7 +90,6 @@ export class ActivityBlock extends Block { ActivityBlock._activityRegistry[actKey] = { activateCallback: onActivate, deactivateCallback: onDeactivate, - modalCloseCallback: onClose, }; } @@ -109,12 +111,14 @@ export class ActivityBlock extends Block { /** @type {String[]} */ let history = this.$['*history']; if (history) { - history.pop(); - let prevActivity = history.pop(); - this.$['*currentActivity'] = prevActivity; + let nextActivity = history.pop(); + while (nextActivity === this.activityType) { + nextActivity = history.pop(); + } + this.$['*currentActivity'] = nextActivity; this.$['*history'] = history; - if (!prevActivity) { - this.setForCtxTarget('lr-modal', '*modalActive', false); + if (!nextActivity) { + this.setForCtxTarget(Modal.StateConsumerScope, '*modalActive', false); } } } diff --git a/abstract/Block.js b/abstract/Block.js index b65fdb801..0902389c0 100644 --- a/abstract/Block.js +++ b/abstract/Block.js @@ -1,5 +1,5 @@ import { BaseComponent, Data } from '@symbiotejs/symbiote'; -import { applyTemplateData } from '../utils/applyTemplateData.js'; +import { applyTemplateData, getPluralObjects } from '../utils/template-utils.js'; import { l10nProcessor } from './l10nProcessor.js'; import { blockCtx } from './CTX.js'; import { createWindowHeightTracker, getIsWindowHeightTracked } from '../utils/createWindowHeightTracker.js'; @@ -7,6 +7,7 @@ import { createWindowHeightTracker, getIsWindowHeightTracked } from '../utils/cr const TAG_PREFIX = 'lr-'; export class Block extends BaseComponent { + static StateConsumerScope = null; allowCustomTemplate = true; ctxInit = blockCtx(); @@ -18,11 +19,32 @@ export class Block extends BaseComponent { * @returns {String} */ l10n(str, variables = {}) { + if (!str) { + return ''; + } let template = this.getCssData('--l10n-' + str, true) || str; + let pluralObjects = getPluralObjects(template); + for (let pluralObject of pluralObjects) { + variables[pluralObject.variable] = this.pluralize( + pluralObject.pluralKey, + Number(variables[pluralObject.countVariable]) + ); + } let result = applyTemplateData(template, variables); return result; } + /** + * @param {string} key + * @param {number} count + * @returns {string} + */ + pluralize(key, count) { + const locale = this.l10n('locale-name') || 'en-US'; + const pluralForm = new Intl.PluralRules(locale).select(count); + return this.l10n(`${key}__${pluralForm}`); + } + constructor() { super(); /** @type {String} */ @@ -55,26 +77,40 @@ export class Block extends BaseComponent { } /** - * @param {String} targetTagName + * @param {(block: Block) => boolean} callback * @returns {Boolean} */ - checkCtxTarget(targetTagName) { + findBlockInCtx(callback) { /** @type {Set} */ - let registry = this.$['*ctxTargetsRegistry']; - return registry?.has(targetTagName); + let blocksRegistry = this.$['*blocksRegistry']; + for (let block of blocksRegistry) { + if (callback(block)) { + return true; + } + } + return false; } /** - * @param {String} targetTagName + * @param {String} consumerScope * @param {String} prop * @param {any} newVal */ - setForCtxTarget(targetTagName, prop, newVal) { - if (this.checkCtxTarget(targetTagName)) { + setForCtxTarget(consumerScope, prop, newVal) { + if (this.findBlockInCtx((b) => /** @type {typeof Block} */ (b.constructor).StateConsumerScope === consumerScope)) { this.$[prop] = newVal; } } + /** @param {String} activityType */ + setActivity(activityType) { + if (this.findBlockInCtx((b) => b.activityType === activityType)) { + this.$['*currentActivity'] = activityType; + return; + } + console.warn(`Activity type "${activityType}" not found in the context`); + } + connectedCallback() { if (!getIsWindowHeightTracked()) { this._destroyInnerHeightTracker = createWindowHeightTracker(); @@ -98,23 +134,13 @@ export class Block extends BaseComponent { } initCallback() { - let tagName = this.constructor['is']; - let registry = this.$['*ctxTargetsRegistry']; - let counter = registry.has(tagName) ? registry.get(tagName) + 1 : 1; - registry.set(tagName, counter); - this.$['*ctxTargetsRegistry'] = registry; + let blocksRegistry = this.$['*blocksRegistry']; + blocksRegistry.add(this); } destroyCallback() { - let tagName = this.constructor['is']; - let registry = this.$['*ctxTargetsRegistry']; - let newCount = registry.has(registry) ? registry.get(tagName) - 1 : 0; - if (newCount === 0) { - registry.delete(tagName); - } else { - registry.set(tagName, newCount); - } - this.$['*ctxTargetsRegistry'] = registry; + let blocksRegistry = this.$['*blocksRegistry']; + blocksRegistry.delete(this); } /** diff --git a/abstract/CTX.js b/abstract/CTX.js index 035d837ac..0dfd515ff 100644 --- a/abstract/CTX.js +++ b/abstract/CTX.js @@ -1,18 +1,24 @@ export const blockCtx = () => ({ - '*ctxTargetsRegistry': new Map(), + /** @type {Set} */ + '*blocksRegistry': new Set(), }); -export const activityBlockCtx = () => ({ +export const activityBlockCtx = (fnCtx) => ({ ...blockCtx(), '*currentActivity': '', '*currentActivityParams': {}, '*history': [], - '*activityCaption': '', - '*activityIcon': '', + '*historyBack': null, + '*closeModal': () => { + fnCtx.set$({ + '*modalActive': false, + '*currentActivity': '', + }); + }, }); -export const uploaderBlockCtx = () => ({ - ...activityBlockCtx(), +export const uploaderBlockCtx = (fnCtx) => ({ + ...activityBlockCtx(fnCtx), '*commonProgress': 0, '*uploadList': [], '*outputData': null, diff --git a/abstract/SolutionBlock.js b/abstract/SolutionBlock.js index 167787917..574817259 100644 --- a/abstract/SolutionBlock.js +++ b/abstract/SolutionBlock.js @@ -2,6 +2,15 @@ import { ShadowWrapper } from '../blocks/ShadowWrapper/ShadowWrapper.js'; import { uploaderBlockCtx } from './CTX.js'; export class SolutionBlock extends ShadowWrapper { - ctxInit = uploaderBlockCtx(); + ctxInit = uploaderBlockCtx(this); ctxOwner = true; + _template = null; + + static set template(value) { + this._template = value + /** HTML */ ``; + } + + static get template() { + return this._template; + } } diff --git a/abstract/UploaderBlock.js b/abstract/UploaderBlock.js index 168584841..8f6e72d21 100644 --- a/abstract/UploaderBlock.js +++ b/abstract/UploaderBlock.js @@ -7,9 +7,10 @@ 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 { Modal } from '../blocks/Modal/Modal.js'; export class UploaderBlock extends ActivityBlock { - ctxInit = uploaderBlockCtx(); + ctxInit = uploaderBlockCtx(this); /** @private */ __initialUploadMetadata = null; @@ -42,9 +43,10 @@ export class UploaderBlock extends ActivityBlock { destroyCallback() { super.destroyCallback(); - let registry = this.$['*ctxTargetsRegistry']; - if (registry?.size === 0) { + let blocksRegistry = this.$['*blocksRegistry']; + if (blocksRegistry.has(this)) { this.uploadCollection.unobserve(this._handleCollectionUpdate); + blocksRegistry.delete(this); } } @@ -61,11 +63,13 @@ export class UploaderBlock extends ActivityBlock { }); } - openSystemDialog() { + /** @param {{ captureCamera?: boolean }} options */ + openSystemDialog(options = {}) { let accept = mergeFileTypes([ this.getCssData('--cfg-accept'), ...(this.getCssData('--cfg-img-only') ? IMAGE_ACCEPT_LIST : []), ]).join(','); + if (this.getCssData('--cfg-accept') && !!this.getCssData('--cfg-img-only')) { console.warn( 'There could be a mistake.\n' + @@ -76,12 +80,18 @@ export class UploaderBlock extends ActivityBlock { this.fileInput = document.createElement('input'); this.fileInput.type = 'file'; this.fileInput.multiple = !!this.getCssData('--cfg-multiple'); - this.fileInput.accept = accept; + if (options.captureCamera) { + this.fileInput.capture = ''; + this.fileInput.accept = IMAGE_ACCEPT_LIST.join(','); + } else { + this.fileInput.accept = accept; + } this.fileInput.dispatchEvent(new MouseEvent('click')); this.fileInput.onchange = () => { this.addFiles([...this.fileInput['files']]); // To call uploadTrigger UploadList should draw file items first: this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; + this.setForCtxTarget(Modal.StateConsumerScope, '*modalActive', true); this.fileInput['value'] = ''; this.fileInput = null; }; @@ -106,7 +116,7 @@ export class UploaderBlock extends ActivityBlock { this.set$({ '*currentActivity': ActivityBlock.activities.UPLOAD_LIST, }); - this.setForCtxTarget('lr-modal', '*modalActive', true); + this.setForCtxTarget(Modal.StateConsumerScope, '*modalActive', true); } else { if (this.sourceList?.length === 1) { let srcKey = this.sourceList[0]; @@ -125,14 +135,14 @@ export class UploaderBlock extends ActivityBlock { } else { this.$['*currentActivity'] = srcKey; } - this.setForCtxTarget('lr-modal', '*modalActive', true); + this.setForCtxTarget(Modal.StateConsumerScope, '*modalActive', true); } } else { // Multiple sources case: this.set$({ '*currentActivity': ActivityBlock.activities.START_FROM, }); - this.setForCtxTarget('lr-modal', '*modalActive', true); + this.setForCtxTarget(Modal.StateConsumerScope, '*modalActive', true); } } } @@ -143,7 +153,7 @@ export class UploaderBlock extends ActivityBlock { '*history': this.doneActivity ? [this.doneActivity] : [], }); if (!this.$['*currentActivity']) { - this.setForCtxTarget('lr-modal', '*modalActive', false); + this.setForCtxTarget(Modal.StateConsumerScope, '*modalActive', false); } } diff --git a/assets/htm/blocks-demo.htm b/assets/htm/blocks-demo.htm index 2e037f563..446a717e2 100644 --- a/assets/htm/blocks-demo.htm +++ b/assets/htm/blocks-demo.htm @@ -14,6 +14,7 @@

🎬 Live Demo (regular case)

--cfg-pubkey: 'demopublickey'; --cfg-use-local-image-editor: 0; --cfg-use-cloud-image-editor: 1; + --darkmode: 1; } @@ -21,8 +22,6 @@

🎬 Live Demo (regular case)

- - @@ -31,7 +30,6 @@

🎬 Live Demo (regular case)

-
diff --git a/blocks/ActivityCaption/ActivityCaption.js b/blocks/ActivityCaption/ActivityCaption.js deleted file mode 100644 index c9e651ff0..000000000 --- a/blocks/ActivityCaption/ActivityCaption.js +++ /dev/null @@ -1,5 +0,0 @@ -import { ActivityBlock } from '../../abstract/ActivityBlock.js'; - -export class ActivityCaption extends ActivityBlock {} - -ActivityCaption.template = /* HTML */ `
{{*activityCaption}}
`; diff --git a/blocks/ActivityCaption/activity-caption.css b/blocks/ActivityCaption/activity-caption.css deleted file mode 100644 index ec58214bb..000000000 --- a/blocks/ActivityCaption/activity-caption.css +++ /dev/null @@ -1,4 +0,0 @@ -lr-activity-caption { - color: var(--clr-txt); - font-size: 1em; -} diff --git a/blocks/ActivityCaption/ref.htm b/blocks/ActivityCaption/ref.htm deleted file mode 100644 index 61c37f631..000000000 --- a/blocks/ActivityCaption/ref.htm +++ /dev/null @@ -1,6 +0,0 @@ -

ActivityCaption

- - - - - \ No newline at end of file diff --git a/blocks/ActivityCaption/test.js b/blocks/ActivityCaption/test.js deleted file mode 100644 index 55684f948..000000000 --- a/blocks/ActivityCaption/test.js +++ /dev/null @@ -1,10 +0,0 @@ -import { ifRef } from '../../utils/ifRef.js'; -import { ActivityCaption } from './ActivityCaption.js'; -import { registerBlocks } from '../../abstract/registerBlocks.js'; - -ifRef(() => { - registerBlocks({ ActivityCaption }); - /** @type {ActivityCaption} */ - let actCap = document.querySelector(ActivityCaption.is); - actCap.$['*activityCaption'] = 'TEST CAPTION'; -}); diff --git a/blocks/ActivityHeader/ActivityHeader.js b/blocks/ActivityHeader/ActivityHeader.js new file mode 100644 index 000000000..528e88818 --- /dev/null +++ b/blocks/ActivityHeader/ActivityHeader.js @@ -0,0 +1,3 @@ +import { ActivityBlock } from '../../abstract/ActivityBlock.js'; + +export class ActivityHeader extends ActivityBlock {} diff --git a/blocks/ActivityHeader/activity-header.css b/blocks/ActivityHeader/activity-header.css new file mode 100644 index 000000000..1003d50fd --- /dev/null +++ b/blocks/ActivityHeader/activity-header.css @@ -0,0 +1,23 @@ +lr-activity-header { + display: flex; + gap: var(--gap-mid); + justify-content: space-between; + padding: var(--gap-mid); + color: var(--clr-txt); + font-weight: 500; + font-size: 1em; + line-height: var(--ui-size); +} + +lr-activity-header lr-icon { + height: var(--ui-size); +} + +lr-activity-header button { + color: var(--clr-txt); +} + +lr-activity-header > * { + display: flex; + align-items: center; +} diff --git a/blocks/ActivityIcon/ActivityIcon.js b/blocks/ActivityIcon/ActivityIcon.js deleted file mode 100644 index de32f2954..000000000 --- a/blocks/ActivityIcon/ActivityIcon.js +++ /dev/null @@ -1,5 +0,0 @@ -import { ActivityBlock } from '../../abstract/ActivityBlock.js'; - -export class ActivityIcon extends ActivityBlock {} - -ActivityIcon.template = /* HTML */ ` `; diff --git a/blocks/ActivityIcon/activity-icon.css b/blocks/ActivityIcon/activity-icon.css deleted file mode 100644 index b1e1383e7..000000000 --- a/blocks/ActivityIcon/activity-icon.css +++ /dev/null @@ -1,3 +0,0 @@ -lr-activity-icon { - height: var(--ui-size); -} diff --git a/blocks/ActivityIcon/ref.htm b/blocks/ActivityIcon/ref.htm deleted file mode 100644 index 0a2785b0d..000000000 --- a/blocks/ActivityIcon/ref.htm +++ /dev/null @@ -1,8 +0,0 @@ -

ActivityIcon

- - - - - - - diff --git a/blocks/ActivityIcon/test.js b/blocks/ActivityIcon/test.js deleted file mode 100644 index d97353fdc..000000000 --- a/blocks/ActivityIcon/test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { ifRef } from '../../utils/ifRef.js'; -import { Icon } from '../Icon/Icon.js'; -import { ActivityIcon } from './ActivityIcon.js'; -import { registerBlocks } from '../../abstract/registerBlocks.js'; - -ifRef(() => { - registerBlocks({ Icon, ActivityIcon }); - /** @type {ActivityIcon} */ - let actIcon = document.querySelector(ActivityIcon.is); - actIcon.$['*activityIcon'] = 'file'; -}); diff --git a/blocks/CameraSource/CameraSource.js b/blocks/CameraSource/CameraSource.js index f9c839d9e..1aae83c9a 100644 --- a/blocks/CameraSource/CameraSource.js +++ b/blocks/CameraSource/CameraSource.js @@ -13,8 +13,8 @@ export class CameraSource extends UploaderBlock { ...this.ctxInit, video: null, videoTransformCss: null, - shotBtnDisabled: false, - videoHidden: false, + shotBtnDisabled: true, + videoHidden: true, messageHidden: true, requestBtnHidden: canUsePermissionsApi(), l10nMessage: null, @@ -44,11 +44,6 @@ export class CameraSource extends UploaderBlock { /** @private */ _onActivate = () => { - this.set$({ - '*activityCaption': this.l10n('caption-camera'), - '*activityIcon': 'camera', - }); - if (canUsePermissionsApi()) { this._subscribePermissions(); } @@ -64,11 +59,6 @@ export class CameraSource extends UploaderBlock { this._stopCapture(); }; - /** @private */ - _onClose = () => { - this.historyBack(); - }; - /** @private */ _handlePermissionsChange = () => { this._capture(); @@ -80,6 +70,7 @@ export class CameraSource extends UploaderBlock { */ _setPermissionsState = debounce((state) => { this.$.originalErrorMessage = null; + this.classList.toggle('initialized', state === 'granted'); if (state === 'granted') { this.set$({ @@ -201,32 +192,53 @@ export class CameraSource extends UploaderBlock { this.registerActivity(this.activityType, { onActivate: this._onActivate, onDeactivate: this._onDeactivate, - onClose: this._onClose, }); this.sub('--cfg-camera-mirror', (val) => { this.$.videoTransformCss = val ? 'scaleX(-1)' : null; }); - let deviceList = await navigator.mediaDevices.enumerateDevices(); - let cameraSelectOptions = deviceList - .filter((info) => { - return info.kind === 'videoinput'; - }) - .map((info, idx) => { - return { - text: info.label.trim() || `${this.l10n('caption-camera')} ${idx + 1}`, - value: info.deviceId, - }; - }); - if (cameraSelectOptions.length > 1) { - this.$.cameraSelectOptions = cameraSelectOptions; - this.$.cameraSelectHidden = false; + try { + let deviceList = await navigator.mediaDevices.enumerateDevices(); + let cameraSelectOptions = deviceList + .filter((info) => { + return info.kind === 'videoinput'; + }) + .map((info, idx) => { + return { + text: info.label.trim() || `${this.l10n('caption-camera')} ${idx + 1}`, + value: info.deviceId, + }; + }); + if (cameraSelectOptions.length > 1) { + this.$.cameraSelectOptions = cameraSelectOptions; + this.$.cameraSelectHidden = false; + } + } catch (err) { + // mediaDevices isn't available for HTTP + // TODO: handle this case } } } CameraSource.template = /* HTML */ ` + + +
+ + +
+ + + +
- - -
- - - - +
`; diff --git a/blocks/CameraSource/camera-source.css b/blocks/CameraSource/camera-source.css index 960c8ef9c..15353cd75 100644 --- a/blocks/CameraSource/camera-source.css +++ b/blocks/CameraSource/camera-source.css @@ -1,20 +1,40 @@ lr-camera-source { position: relative; display: flex; - align-items: center; - justify-content: center; - height: var(--modal-content-height-fill, 100%); + flex-direction: column; + width: 100%; + height: 100%; + max-height: 100%; + overflow: hidden; + background-color: var(--clr-background-light); + border-radius: var(--border-radius-element); +} + +lr-modal lr-camera-source { + width: min(calc(var(--modal-max-w) - var(--gap-mid) * 2), calc(100vw - var(--gap-mid) * 2)); + height: 100vh; max-height: var(--modal-max-content-height); - padding-bottom: calc(2 * var(--gap-mid) + var(--ui-size)); +} + +lr-camera-source.initialized { + height: max-content; +} + +@media only screen and (max-width: 430px) { + lr-camera-source { + width: calc(100vw - var(--gap-mid) * 2); + height: var(--modal-content-height-fill, 100%); + } } lr-camera-source video { display: block; - max-width: 100%; + width: 100%; max-height: 100%; object-fit: contain; object-position: center center; - background-color: var(--clr-background); + background-color: var(--clr-background-dark); + border-radius: var(--border-radius-element); } lr-camera-source .toolbar { @@ -24,13 +44,17 @@ lr-camera-source .toolbar { justify-content: space-between; width: 100%; padding: var(--gap-mid); - background-color: var(--clr-background); + background-color: var(--clr-background-light); } lr-camera-source .content { display: flex; + flex: 1; justify-content: center; - height: 100%; + width: 100%; + padding: var(--gap-mid); + padding-top: 0; + overflow: hidden; } lr-camera-source .message-box { @@ -42,9 +66,41 @@ lr-camera-source .message-box { align-items: center; justify-content: center; padding: var(--padding) var(--padding) 0 var(--padding); + color: var(--clr-txt); } lr-camera-source .message-box button { color: var(--clr-btn-txt-primary); background-color: var(--clr-btn-bgr-primary); } + +lr-camera-source .shot-btn { + position: absolute; + bottom: var(--gap-max); + width: calc(var(--ui-size) * 1.8); + height: calc(var(--ui-size) * 1.8); + color: var(--clr-background-light); + background-color: var(--clr-txt); + border-radius: 50%; + opacity: 0.85; + transition: var(--transition-duration) ease; +} + +lr-camera-source .shot-btn:hover { + transform: scale(1.05); + opacity: 1; +} + +lr-camera-source .shot-btn:active { + background-color: var(--clr-txt-mid); + opacity: 1; +} + +lr-camera-source .shot-btn[disabled] { + bottom: calc(var(--gap-max) * -1 - var(--gap-mid) - var(--ui-size) * 2); +} + +lr-camera-source .shot-btn lr-icon svg { + width: calc(var(--ui-size) / 1.5); + height: calc(var(--ui-size) / 1.5); +} diff --git a/blocks/CloudImageEditor/CloudImageEditor.js b/blocks/CloudImageEditor/CloudImageEditor.js index 99a625dc2..2c490725d 100644 --- a/blocks/CloudImageEditor/CloudImageEditor.js +++ b/blocks/CloudImageEditor/CloudImageEditor.js @@ -7,7 +7,7 @@ export class CloudImageEditor extends UploaderBlock { init$ = { ...this.ctxInit, - uuid: null, + cdnUrl: null, }; initCallback() { @@ -17,7 +17,6 @@ export class CloudImageEditor extends UploaderBlock { this.registerActivity(this.activityType, { onActivate: () => this.mountEditor(), onDeactivate: () => this.unmountEditor(), - onClose: () => this.historyBack(), }); this.sub('*focusedEntry', (/** @type {import('../../abstract/TypedData.js').TypedData} */ entry) => { @@ -26,9 +25,9 @@ export class CloudImageEditor extends UploaderBlock { } this.entry = entry; - this.entry.subscribe('uuid', (uuid) => { - if (uuid) { - this.$.uuid = uuid; + this.entry.subscribe('cdnUrl', (cdnUrl) => { + if (cdnUrl) { + this.$.cdnUrl = cdnUrl; } }); }); @@ -51,8 +50,8 @@ export class CloudImageEditor extends UploaderBlock { mountEditor() { let instance = new CloudEditor(); instance.classList.add('lr-cldtr-common'); - let uuid = this.$.uuid; - instance.setAttribute('uuid', uuid); + let cdnUrl = this.$.cdnUrl; + instance.setAttribute('cdn-url', cdnUrl); instance.addEventListener('apply', (result) => this.handleApply(result)); instance.addEventListener('cancel', () => this.handleCancel()); diff --git a/blocks/CloudImageEditor/index.css b/blocks/CloudImageEditor/index.css index 6a865a58e..bb5acf536 100644 --- a/blocks/CloudImageEditor/index.css +++ b/blocks/CloudImageEditor/index.css @@ -4,6 +4,12 @@ lr-cloud-image-editor { position: relative; display: flex; width: 100%; - height: var(--modal-content-height-fill, 100%); + height: 100%; overflow: hidden; + background-color: var(--clr-background-light); +} + +lr-modal lr-cloud-image-editor { + width: min(calc(var(--modal-max-w) - var(--gap-mid) * 2), calc(100vw - var(--gap-mid) * 2)); + height: var(--modal-content-height-fill, 100%); } diff --git a/blocks/CloudImageEditor/ref.htm b/blocks/CloudImageEditor/ref.htm index 2e37d29f6..768f11b34 100644 --- a/blocks/CloudImageEditor/ref.htm +++ b/blocks/CloudImageEditor/ref.htm @@ -1,9 +1,21 @@

Cloud image editor

+

Load image by UUID

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

Load image by CDN URL

+ + + + + + + + diff --git a/blocks/CloudImageEditor/src/CloudEditor.js b/blocks/CloudImageEditor/src/CloudEditor.js index 7a6d68c83..600336e94 100644 --- a/blocks/CloudImageEditor/src/CloudEditor.js +++ b/blocks/CloudImageEditor/src/CloudEditor.js @@ -1,13 +1,18 @@ import { Block } from '../../../abstract/Block.js'; -import { createCdnUrl, createCdnUrlModifiers, createOriginalUrl } from '../../../utils/cdn-utils.js'; +import { + createCdnUrl, + createCdnUrlModifiers, + createOriginalUrl, + extractOperations, + extractUuid, +} from '../../../utils/cdn-utils.js'; +import { TRANSPARENT_PIXEL_SRC } from '../../../utils/transparentPixelSrc.js'; import { classNames } from './lib/classNames.js'; import { debounce } from './lib/debounce.js'; -import { preloadImage } from './lib/preloadImage.js'; -import { TRANSPARENT_PIXEL_SRC } from '../../../utils/transparentPixelSrc.js'; +import { operationsToTransformations } from './lib/transformationUtils.js'; import { initState } from './state.js'; import { TEMPLATE } from './template.js'; import { TabId } from './toolbar-constants.js'; -import { viewerImageSrc } from './util.js'; export class CloudEditor extends Block { get ctxName() { @@ -26,69 +31,22 @@ export class CloudEditor extends Block { this.$.showLoader = show; } - _loadImageFromCdn() { - this._debouncedShowLoader(true); - let src = this._imageSrc(); - let { promise, cancel } = preloadImage(src); - promise - .then(() => { - this.$.src = src; - }) - .catch((err) => { - this.$['*networkProblems'] = true; - this._debouncedShowLoader(false); - this.$.src = src; - }); - this._cancelPreload && this._cancelPreload(); - this._cancelPreload = cancel; - } - - _imageSrc() { - let { width } = this.ref['img-container-el'].getBoundingClientRect(); - return this.proxyUrl(viewerImageSrc(this.$['*originalUrl'], width, {})); - } - - /** - * To proper work, we need non-zero size the element. So, we'll wait for it. - * - * @private - * @returns {Promise} - */ - _waitForSize() { - return new Promise((resolve, reject) => { - let timeout = 300; - let start = Date.now(); - - let callback = () => { - // there could be problem when element disconnected and connected again between ticks - if (!this.isConnected) { - clearInterval(interval); - reject(); - return; - } - if (Date.now() - start > timeout) { - clearInterval(interval); - reject(new Error('[cloud-image-editor] timout waiting for non-zero container size')); - return; - } - let { width, height } = this.getBoundingClientRect(); - - if (width > 0 && height > 0) { - clearInterval(interval); - resolve(); - } - }; - let interval = setInterval(callback, 50); - callback(); - }); - } - cssInit$ = { '--cfg-cdn-cname': 'https://ucarecdn.com', }; async initCallback() { - this.$['*originalUrl'] = createOriginalUrl(this.localCtx.read('--cfg-cdn-cname'), this.$.uuid); + if (this.$.cdnUrl) { + let uuid = extractUuid(this.$.cdnUrl); + this.$['*originalUrl'] = createOriginalUrl(this.$.cdnUrl, uuid); + let operations = extractOperations(this.$.cdnUrl); + let transformations = operationsToTransformations(operations); + this.$['*editorTransformations'] = transformations; + } else if (this.$.uuid) { + this.$['*originalUrl'] = createOriginalUrl(this.localCtx.read('--cfg-cdn-cname'), this.$.uuid); + } else { + throw new Error('No UUID nor CDN URL provided'); + } this.$['*faderEl'] = this.ref['fader-el']; this.$['*cropperEl'] = this.ref['cropper-el']; @@ -139,8 +97,6 @@ export class CloudEditor extends Block { .then(({ width, height }) => { this.$['*imageSize'] = { width, height }; }); - await this._waitForSize(); - this._loadImageFromCdn(); } catch (err) { if (err) { console.error('Failed to load image info', err); @@ -152,4 +108,5 @@ export class CloudEditor extends Block { CloudEditor.template = TEMPLATE; CloudEditor.bindAttributes({ uuid: 'uuid', + 'cdn-url': 'cdnUrl', }); diff --git a/blocks/CloudImageEditor/src/EditorFilterControl.js b/blocks/CloudImageEditor/src/EditorFilterControl.js index 2657cd258..7c3c0fed1 100644 --- a/blocks/CloudImageEditor/src/EditorFilterControl.js +++ b/blocks/CloudImageEditor/src/EditorFilterControl.js @@ -61,9 +61,7 @@ export class EditorFilterControl extends EditorButtonControl { }) .finally(() => { previewEl.style.backgroundImage = `url(${src})`; - setTimeout(() => { - previewEl.style.opacity = '1'; - }); + previewEl.setAttribute('loaded', ''); observer.unobserve(this); }); diff --git a/blocks/CloudImageEditor/src/EditorToolbar.js b/blocks/CloudImageEditor/src/EditorToolbar.js index 6193e0ab2..e2fae02a3 100644 --- a/blocks/CloudImageEditor/src/EditorToolbar.js +++ b/blocks/CloudImageEditor/src/EditorToolbar.js @@ -6,7 +6,14 @@ import { FAKE_ORIGINAL_FILTER } from './EditorSlider.js'; import { classNames } from './lib/classNames.js'; import { debounce } from './lib/debounce.js'; import { batchPreloadImages } from './lib/preloadImage.js'; -import { ALL_COLOR_OPERATIONS, ALL_CROP_OPERATIONS, ALL_FILTERS, TabId, TABS } from './toolbar-constants.js'; +import { + ALL_COLOR_OPERATIONS, + ALL_CROP_OPERATIONS, + ALL_FILTERS, + COLOR_OPERATIONS_CONFIG, + TabId, + TABS, +} from './toolbar-constants.js'; import { viewerImageSrc } from './util.js'; /** @param {String} id */ @@ -47,12 +54,10 @@ export class EditorToolbar extends Block { /** @type {import('./types.js').LoadingOperations} */ '*loadingOperations': new Map(), '*showSlider': false, - /** @type {import('./types.js').Transformations} */ - '*editorTransformations': {}, '*currentFilter': FAKE_ORIGINAL_FILTER, '*currentOperation': null, + '*tabId': TabId.CROP, showLoader: false, - tabId: TabId.CROP, filters: ALL_FILTERS, colorOperations: ALL_COLOR_OPERATIONS, cropOperations: ALL_CROP_OPERATIONS, @@ -104,18 +109,11 @@ export class EditorToolbar extends Block { this._debouncedShowLoader = debounce(this._showLoader.bind(this), 500); } - get tabId() { - return this.$.tabId; - } - /** @private */ _onSliderClose() { this.$['*showSlider'] = false; - if (this.$.tabId === TabId.SLIDERS) { - this.ref['tooltip-el'].className = classNames('filter-tooltip', { - 'filter-tooltip_visible': false, - 'filter-tooltip_hidden': true, - }); + if (this.$['*tabId'] === TabId.SLIDERS) { + this.ref['tooltip-el'].classList.toggle('info-tooltip_visible', false); } } @@ -193,7 +191,7 @@ export class EditorToolbar extends Block { * @param {{ fromViewer?: Boolean }} options */ _activateTab(id, { fromViewer }) { - this.$.tabId = id; + this.$['*tabId'] = id; if (id === TabId.CROP) { this.$['*faderEl'].deactivate(); @@ -232,7 +230,7 @@ export class EditorToolbar extends Block { /** @private */ _syncTabIndicator() { - let tabToggleEl = this.ref[`tab-toggle-${this.$.tabId}`]; + let tabToggleEl = this.ref[`tab-toggle-${this.$['*tabId']}`]; let indicatorEl = this.ref['tabs-indicator']; indicatorEl.style.transform = `translateX(${tabToggleEl.offsetLeft}px)`; } @@ -256,6 +254,31 @@ export class EditorToolbar extends Block { this.$.showLoader = show; } + _updateInfoTooltip = debounce(() => { + let transformations = this.$['*editorTransformations']; + let text = ''; + let visible = false; + + if (this.$['*tabId'] === TabId.FILTERS) { + visible = true; + if (this.$['*currentFilter'] && transformations?.filter?.name === this.$['*currentFilter']) { + let value = transformations?.filter?.amount || 100; + text = this.l10n(this.$['*currentFilter']) + ' ' + value; + } else { + text = this.l10n(FAKE_ORIGINAL_FILTER); + } + } else if (this.$['*tabId'] === TabId.SLIDERS && this.$['*currentOperation']) { + visible = true; + let value = + transformations?.[this.$['*currentOperation']] || COLOR_OPERATIONS_CONFIG[this.$['*currentOperation']].zero; + text = this.$['*currentOperation'] + ' ' + value; + } + if (visible) { + this.$['*operationTooltip'] = text; + } + this.ref['tooltip-el'].classList.toggle('info-tooltip_visible', visible); + }, 0); + initCallback() { super.initCallback(); @@ -264,38 +287,28 @@ export class EditorToolbar extends Block { this.sub('*imageSize', (imageSize) => { if (imageSize) { setTimeout(() => { - this._activateTab(this.$.tabId, { fromViewer: true }); + this._activateTab(this.$['*tabId'], { fromViewer: true }); }, 0); } }); - this.sub('*currentFilter', (currentFilter) => { - this.$['*operationTooltip'] = this.l10n(currentFilter || FAKE_ORIGINAL_FILTER); - this.ref['tooltip-el'].className = classNames('filter-tooltip', { - 'filter-tooltip_visible': currentFilter, - 'filter-tooltip_hidden': !currentFilter, - }); + this.sub('*editorTransformations', (editorTransformations) => { + let appliedFilter = editorTransformations?.filter?.name; + if (this.$['*currentFilter'] !== appliedFilter) { + this.$['*currentFilter'] = appliedFilter; + } }); - this.sub('*currentOperation', (currentOperation) => { - if (this.$.tabId !== TabId.SLIDERS) { - return; - } - this.$['*operationTooltip'] = currentOperation; - this.ref['tooltip-el'].className = classNames('filter-tooltip', { - 'filter-tooltip_visible': currentOperation, - 'filter-tooltip_hidden': !currentOperation, - }); + this.sub('*currentFilter', () => { + this._updateInfoTooltip(); }); - this.sub('*tabId', (tabId) => { - if (tabId === TabId.FILTERS) { - this.$['*operationTooltip'] = this.$['*currentFilter']; - } - this.ref['tooltip-el'].className = classNames('filter-tooltip', { - 'filter-tooltip_visible': tabId === TabId.FILTERS, - 'filter-tooltip_hidden': tabId !== TabId.FILTERS, - }); + this.sub('*currentOperation', () => { + this._updateInfoTooltip(); + }); + + this.sub('*tabId', () => { + this._updateInfoTooltip(); }); this.sub('*originalUrl', (originalUrl) => { @@ -329,14 +342,16 @@ export class EditorToolbar extends Block { this.$['presence.subToolbar'] = showSlider; this.$['presence.mainToolbar'] = !showSlider; }); + + this._updateInfoTooltip(); } } EditorToolbar.template = /* HTML */ ` -
-
-
{{*operationTooltip}}
+
+
+
{{*operationTooltip}}
diff --git a/blocks/CloudImageEditor/src/css/common.css b/blocks/CloudImageEditor/src/css/common.css index 239a2dbb9..331cb23da 100644 --- a/blocks/CloudImageEditor/src/css/common.css +++ b/blocks/CloudImageEditor/src/css/common.css @@ -1,3 +1,5 @@ +/* TODO: we shuoud use basic theme there */ + :where(.lr-cldtr-common), :host { /* Theme settings >>> */ @@ -44,10 +46,10 @@ --border-radius-ui: 5px; --border-radius-base: 6px; - --gap-min: 5px; - --gap-mid-1: 10px; - --gap-mid-2: 15px; - --gap-max: 20px; + --cldtr-gap-min: 5px; + --cldtr-gap-mid-1: 10px; + --cldtr-gap-mid-2: 15px; + --cldtr-gap-max: 20px; --opacity-min: var(--opacity-shade-mid); --opacity-mid: 0.1; @@ -136,13 +138,13 @@ lr-cloud-editor > .wrapper { --l-min-img-height: var(--modal-toolbar-height); --l-max-img-height: 100%; --l-edit-button-width: 120px; - --l-toolbar-horizontal-padding: var(--gap-mid-1); + --l-toolbar-horizontal-padding: var(--cldtr-gap-mid-1); } @media only screen and (max-width: 800px) { lr-cloud-editor > .wrapper { --l-edit-button-width: 70px; - --l-toolbar-horizontal-padding: var(--gap-min); + --l-toolbar-horizontal-padding: var(--cldtr-gap-min); } } @@ -277,7 +279,7 @@ lr-cloud-editor > .wrapper > .network_problems_splash > .network_problems_conten } lr-cloud-editor > .wrapper > .network_problems_splash > .network_problems_content > .network_problems_text { - margin-top: var(--gap-max); + margin-top: var(--cldtr-gap-max); font-size: var(--font-size-ui); } @@ -368,17 +370,25 @@ lr-editor-operation-control { transition: var(--l-width-transition); } -lr-editor-button-control > .active, -lr-editor-operation-control > .active, -lr-editor-crop-button-control > .active, -lr-editor-filter-control > .active { +lr-editor-button-control.active, +lr-editor-operation-control.active, +lr-editor-crop-button-control.active, +lr-editor-filter-control.active { --idle-color-rgb: var(--rgb-primary-accent); } -lr-editor-button-control > .not_active, -lr-editor-operation-control > .not_active, -lr-editor-crop-button-control > .not_active, -lr-editor-filter-control > .not_active { +lr-editor-filter-control.not_active .preview[loaded] { + opacity: 1; +} + +lr-editor-filter-control.active .preview { + opacity: 0; +} + +lr-editor-button-control.not_active, +lr-editor-operation-control.not_active, +lr-editor-crop-button-control.not_active, +lr-editor-filter-control.not_active { --idle-color-rgb: var(--rgb-text-base); } @@ -401,7 +411,7 @@ lr-editor-button-control > .title, lr-editor-operation-control > .title, lr-editor-crop-button-control > .title, lr-editor-filter-control > .title { - padding-right: var(--gap-mid-1); + padding-right: var(--cldtr-gap-mid-1); font-size: 0.7em; letter-spacing: 1.004px; text-transform: uppercase; @@ -552,17 +562,17 @@ lr-editor-toolbar { @media only screen and (max-width: 600px) { lr-editor-toolbar { - --l-tab-gap: var(--gap-mid-1); - --l-slider-padding: var(--gap-min); - --l-controls-padding: var(--gap-min); + --l-tab-gap: var(--cldtr-gap-mid-1); + --l-slider-padding: var(--cldtr-gap-min); + --l-controls-padding: var(--cldtr-gap-min); } } @media only screen and (min-width: 601px) { lr-editor-toolbar { - --l-tab-gap: calc(var(--gap-mid-1) + var(--gap-max)); - --l-slider-padding: var(--gap-mid-1); - --l-controls-padding: var(--gap-mid-1); + --l-tab-gap: calc(var(--cldtr-gap-mid-1) + var(--cldtr-gap-max)); + --l-slider-padding: var(--cldtr-gap-mid-1); + --l-controls-padding: var(--cldtr-gap-mid-1); } } @@ -662,14 +672,14 @@ lr-editor-toolbar > .toolbar-container > .sub-toolbar > .tab-content-row > .tab- grid-template-columns: 1fr auto 1fr; box-sizing: border-box; min-width: 100%; - padding-left: var(--gap-max); + padding-left: var(--cldtr-gap-max); } lr-editor-toolbar > .toolbar-container > .sub-toolbar > .tab-content-row > .tab-content .controls-list_inner { display: grid; grid-area: inner; grid-auto-flow: column; - grid-gap: calc((var(--gap-min) - 1px) * 3); + grid-gap: calc((var(--cldtr-gap-min) - 1px) * 3); } lr-editor-toolbar @@ -678,14 +688,14 @@ lr-editor-toolbar > .tab-content-row > .tab-content .controls-list_inner:last-child { - padding-right: var(--gap-max); + padding-right: var(--cldtr-gap-max); } lr-editor-toolbar .controls-list_last-item { - margin-right: var(--gap-max); + margin-right: var(--cldtr-gap-max); } -lr-editor-toolbar .filter-tooltip_container { +lr-editor-toolbar .info-tooltip_container { position: absolute; display: flex; align-items: flex-start; @@ -694,9 +704,9 @@ lr-editor-toolbar .filter-tooltip_container { height: 100%; } -lr-editor-toolbar .filter-tooltip_wrapper { +lr-editor-toolbar .info-tooltip_wrapper { position: absolute; - top: calc(-100% - var(--gap-mid-2)); + top: calc(-100% - var(--cldtr-gap-mid-2)); display: flex; flex-direction: column; justify-content: flex-end; @@ -704,32 +714,28 @@ lr-editor-toolbar .filter-tooltip_wrapper { pointer-events: none; } -lr-editor-toolbar .filter-tooltip { +lr-editor-toolbar .info-tooltip { z-index: 3; - padding-top: calc(var(--gap-min) / 2); - padding-right: var(--gap-min); - padding-bottom: calc(var(--gap-min) / 2); - padding-left: var(--gap-min); + padding-top: calc(var(--cldtr-gap-min) / 2); + padding-right: var(--cldtr-gap-min); + padding-bottom: calc(var(--cldtr-gap-min) / 2); + padding-left: var(--cldtr-gap-min); color: var(--color-text-base); font-size: 0.7em; letter-spacing: 1px; text-transform: uppercase; background-color: var(--color-text-accent-contrast); border-radius: var(--border-radius-editor); + transform: translateY(100%); opacity: 0; transition: var(--transition-duration-3); } -lr-editor-toolbar .filter-tooltip_visible { +lr-editor-toolbar .info-tooltip_visible { transform: translateY(0px); opacity: 1; } -lr-editor-toolbar .filter-tooltip_hidden { - transform: translateY(100%); - opacity: 0; -} - lr-editor-toolbar .slider { padding-right: var(--l-slider-padding); padding-left: var(--l-slider-padding); @@ -745,8 +751,8 @@ lr-btn-ui { align-items: center; box-sizing: var(--css-box-sizing, border-box); height: var(--css-height, var(--size-touch-area)); - padding-right: var(--css-padding-right, var(--gap-mid-1)); - padding-left: var(--css-padding-left, var(--gap-mid-1)); + padding-right: var(--css-padding-right, var(--cldtr-gap-mid-1)); + padding-left: var(--css-padding-left, var(--cldtr-gap-mid-1)); color: rgba(var(--color-effect), var(--opacity-effect)); outline: none; cursor: pointer; @@ -769,13 +775,13 @@ lr-btn-ui .icon { } lr-btn-ui .icon_left { - margin-right: var(--gap-mid-1); + margin-right: var(--cldtr-gap-mid-1); margin-left: 0px; } lr-btn-ui .icon_right { margin-right: 0px; - margin-left: var(--gap-mid-1); + margin-left: var(--cldtr-gap-mid-1); } lr-btn-ui .icon_single { diff --git a/blocks/CloudImageEditor/src/lib/transformationUtils.js b/blocks/CloudImageEditor/src/lib/transformationUtils.js index 44ea60b73..862498ea1 100644 --- a/blocks/CloudImageEditor/src/lib/transformationUtils.js +++ b/blocks/CloudImageEditor/src/lib/transformationUtils.js @@ -46,7 +46,8 @@ function transformationToStr(operation, options) { return ''; } -const ORDER = [ +// TODO: refactor all the operations constants +const SUPPORTED_OPERATIONS_ORDERED = [ 'enhance', 'brightness', 'exposure', @@ -68,7 +69,7 @@ const ORDER = [ */ export function transformationsToOperations(transformations) { return joinCdnOperations( - ...ORDER.filter( + ...SUPPORTED_OPERATIONS_ORDERED.filter( (operation) => typeof transformations[operation] !== 'undefined' && transformations[operation] !== null ) .map((operation) => { @@ -80,3 +81,53 @@ export function transformationsToOperations(transformations) { } export const COMMON_OPERATIONS = joinCdnOperations('format/auto', 'progressive/yes'); + +const asNumber = ([value]) => (typeof value !== 'undefined' ? Number(value) : undefined); +const asBoolean = () => true; +const asFilter = ([name, amount]) => ({ + name, + amount: Number(amount), +}); + +// Docs: https://uploadcare.com/docs/transformations/image/resize-crop/#operation-crop +// We don't support percentages and aligment presets, +// Because it's unclear how to handle them in the Editor UI +// TODO: add support for percentages and aligment presets +const asCrop = ([dimensions, coords]) => { + return { dimensions: dimensions.split('x').map(Number), coords: coords.split(',').map(Number) }; +}; + +const OPERATION_PROCESSORS = { + enhance: asNumber, + brightness: asNumber, + exposure: asNumber, + gamma: asNumber, + contrast: asNumber, + saturation: asNumber, + vibrance: asNumber, + warmth: asNumber, + filter: asFilter, + mirror: asBoolean, + flip: asBoolean, + rotate: asNumber, + crop: asCrop, +}; + +/** + * @param {string[]} operations + * @returns {import('../types.js').Transformations} + */ +export function operationsToTransformations(operations) { + /** @type {import('../types.js').Transformations} */ + let transformations = {}; + for (let operation of operations) { + let [name, ...args] = operation.split('/'); + if (!SUPPORTED_OPERATIONS_ORDERED.includes(name)) { + continue; + } + const processor = OPERATION_PROCESSORS[name]; + const value = processor(args); + transformations[name] = value; + } + return transformations; +} diff --git a/blocks/CloudImageEditor/src/state.js b/blocks/CloudImageEditor/src/state.js index 4a4bd85ca..c1a61c4c1 100644 --- a/blocks/CloudImageEditor/src/state.js +++ b/blocks/CloudImageEditor/src/state.js @@ -6,13 +6,14 @@ import { TRANSPARENT_PIXEL_SRC } from '../../../utils/transparentPixelSrc.js'; export function initState(fnCtx) { return { '*originalUrl': null, - '*tabId': null, '*faderEl': null, '*cropperEl': null, '*imgEl': null, '*imgContainerEl': null, '*networkProblems': false, '*imageSize': null, + /** @type {import('./types.js').Transformations} */ + '*editorTransformations': {}, entry: null, extension: null, @@ -24,6 +25,7 @@ export function initState(fnCtx) { fileType: '', showLoader: false, uuid: null, + cdnUrl: null, 'presence.networkProblems': false, 'presence.modalCaption': true, diff --git a/blocks/ConfirmationDialog/ConfirmationDialog.js b/blocks/ConfirmationDialog/ConfirmationDialog.js index b84cded47..dd2c6199c 100644 --- a/blocks/ConfirmationDialog/ConfirmationDialog.js +++ b/blocks/ConfirmationDialog/ConfirmationDialog.js @@ -21,6 +21,7 @@ export class ConfirmationDialog extends ActivityBlock { init$ = { ...this.ctxInit, + activityCaption: '', messageTxt: '', confirmBtnTxt: '', denyBtnTxt: '', @@ -41,18 +42,15 @@ export class ConfirmationDialog extends ActivityBlock { return; } this.set$({ - '*modalHeaderHidden': true, '*currentActivity': ActivityBlock.activities.CONFIRMATION, - '*activityCaption': this.l10n(cfn.captionL10nStr), + activityCaption: this.l10n(cfn.captionL10nStr), messageTxt: this.l10n(cfn.messageL10Str), confirmBtnTxt: this.l10n(cfn.confirmL10nStr), denyBtnTxt: this.l10n(cfn.denyL10nStr), onDeny: () => { - this.$['*modalHeaderHidden'] = false; cfn.denyAction(); }, onConfirm: () => { - this.$['*modalHeaderHidden'] = false; cfn.confirmAction(); }, }); @@ -61,6 +59,16 @@ export class ConfirmationDialog extends ActivityBlock { } ConfirmationDialog.template = /* HTML */ ` + + + {{activityCaption}} + + +
{{messageTxt}}
diff --git a/blocks/Copyright/Copyright.js b/blocks/Copyright/Copyright.js new file mode 100644 index 000000000..d3b6322a4 --- /dev/null +++ b/blocks/Copyright/Copyright.js @@ -0,0 +1,12 @@ +import { Block } from '../../abstract/Block.js'; + +export class Copyright extends Block { + static template = /* HTML */ ` + Powered by Uploadcare + `; +} diff --git a/blocks/Copyright/copyright.css b/blocks/Copyright/copyright.css new file mode 100644 index 000000000..b9f24d591 --- /dev/null +++ b/blocks/Copyright/copyright.css @@ -0,0 +1,12 @@ +lr-copyright .credits { + padding: 0 var(--gap-mid) var(--gap-mid) calc(var(--gap-mid) * 1.5); + color: var(--clr-txt-lightest); + font-weight: normal; + font-size: 0.85em; + opacity: 0.7; + transition: var(--transition-duration) ease; +} + +lr-copyright .credits:hover { + opacity: 1; +} diff --git a/blocks/DropArea/DropArea.js b/blocks/DropArea/DropArea.js index abc34f431..f418afc96 100644 --- a/blocks/DropArea/DropArea.js +++ b/blocks/DropArea/DropArea.js @@ -2,26 +2,76 @@ import { UploaderBlock } from '../../abstract/UploaderBlock.js'; import { ActivityBlock } from '../../abstract/ActivityBlock.js'; import { DropzoneState, addDropzone } from './addDropzone.js'; import { fileIsImage } from '../../utils/fileTypes.js'; +import { Modal } from '../Modal/Modal.js'; export class DropArea extends UploaderBlock { init$ = { ...this.ctxInit, state: DropzoneState.INACTIVE, + withIcon: false, + isClickable: false, + isFullscreen: false, + text: this.l10n('drop-files-here'), + 'lr-drop-area/targets': null, }; + isActive() { + const bounds = this.getBoundingClientRect(); + const hasSize = bounds.width > 0 && bounds.height > 0; + const isInViewport = + bounds.top >= 0 && + bounds.left >= 0 && + bounds.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + bounds.right <= (window.innerWidth || document.documentElement.clientWidth); + + const style = window.getComputedStyle(this); + const visible = style.visibility !== 'hidden' && style.display !== 'none'; + + return hasSize && visible && isInViewport; + } initCallback() { super.initCallback(); + if (!this.$['lr-drop-area/targets']) { + this.$['lr-drop-area/targets'] = new Set(); + } + this.$['lr-drop-area/targets'].add(this); + + this.defineAccessor('clickable', (value) => { + this.set$({ isClickable: typeof value === 'string' }); + }); + this.defineAccessor('with-icon', (value) => { + this.set$({ withIcon: typeof value === 'string' }); + }); + this.defineAccessor('fullscreen', (value) => { + this.set$({ isFullscreen: typeof value === 'string' }); + }); + + this.defineAccessor('text', (value) => { + if (value) { + this.set$({ text: this.l10n(value) || value }); + } else { + this.set$({ text: this.l10n('drop-files-here') }); + } + }); + /** @private */ this._destroyDropzone = addDropzone({ element: this, + shouldIgnore: () => this._isDisabled(), onChange: (state) => { this.$.state = state; }, + /** @param {(File | String)[]} items */ onItems: (items) => { if (!items.length) { return; } - if (!this.getCssData('--cfg-multiple')) { - items = [items[0]]; + let isMultiple = this.getCssData('--cfg-multiple'); + let multipleMax = this.getCssData('--cfg-multiple-max'); + let currentFilesCount = this.uploadCollection.size; + if (isMultiple && multipleMax) { + items = items.slice(0, multipleMax - currentFilesCount - 1); + } else if (!isMultiple) { + items = items.slice(0, currentFilesCount > 0 ? 0 : 1); } items.forEach((/** @type {File | String} */ item) => { if (typeof item === 'string') { @@ -43,12 +93,26 @@ export class DropArea extends UploaderBlock { this.set$({ '*currentActivity': ActivityBlock.activities.UPLOAD_LIST, }); - // @ts-ignore - this.setForCtxTarget('lr-modal', '*modalActive', true); + this.setForCtxTarget(Modal.StateConsumerScope, '*modalActive', true); } }, }); + let contentWrapperEl = this.ref['content-wrapper']; + if (contentWrapperEl) { + this._destroyContentWrapperDropzone = addDropzone({ + element: contentWrapperEl, + onChange: (state) => { + const stateText = Object.entries(DropzoneState) + .find(([, value]) => value === state)?.[0] + .toLowerCase(); + stateText && contentWrapperEl.setAttribute('drag-state', stateText); + }, + onItems: () => {}, + shouldIgnore: () => this._isDisabled(), + }); + } + this.sub('state', (state) => { const stateText = Object.entries(DropzoneState) .find(([, value]) => value === state)?.[0] @@ -58,23 +122,78 @@ export class DropArea extends UploaderBlock { } }); - if (this.hasAttribute('clickable')) { - let clickable = this.getAttribute('clickable'); - if (clickable === '' || clickable === 'true') { - // @private - this._onAreaClicked = () => { - this.openSystemDialog(); - }; - this.addEventListener('click', this._onAreaClicked); - } + if (this.$.isClickable) { + // @private + this._onAreaClicked = () => { + this.openSystemDialog(); + }; + this.addEventListener('click', this._onAreaClicked); } } + /** + * Ignore drop events if there are other visible drop areas on the page + * + * @returns {Boolean} + */ + _isDisabled() { + if (!this._couldHandleFiles()) { + return true; + } + if (!this.$.isFullscreen) { + return false; + } + const otherTargets = [...this.$['lr-drop-area/targets']].filter((el) => el !== this); + const activeTargets = otherTargets.filter((/** @type {typeof this} */ el) => { + return el.isActive(); + }); + return activeTargets.length > 0; + } + + _couldHandleFiles() { + let isMultiple = this.getCssData('--cfg-multiple'); + let multipleMax = this.getCssData('--cfg-multiple-max'); + let currentFilesCount = this.uploadCollection.size; + + if (isMultiple && multipleMax && currentFilesCount >= multipleMax) { + return false; + } + + if (!isMultiple && currentFilesCount > 0) { + return false; + } + + return true; + } + destroyCallback() { super.destroyCallback(); + + this.$['lr-drop-area/targets']?.remove?.(this); + this._destroyDropzone?.(); + this._destroyContentWrapperDropzone?.(); if (this._onAreaClicked) { this.removeEventListener('click', this._onAreaClicked); } } } + +DropArea.template = /* HTML */ ` + +
+
+ + +
+ {{text}} +
+
+`; + +DropArea.bindAttributes({ + 'with-icon': null, + clickable: null, + text: null, + fullscreen: null, +}); diff --git a/blocks/DropArea/addDropzone.js b/blocks/DropArea/addDropzone.js index b045cea0c..9fa6ff16f 100644 --- a/blocks/DropArea/addDropzone.js +++ b/blocks/DropArea/addDropzone.js @@ -8,7 +8,7 @@ export const DropzoneState = { OVER: 3, }; -let FINAL_EVENTS = ['dragleave', 'dragexit', 'dragend', 'drop', 'mouseleave', 'mouseout']; +let RESET_EVENTS = ['focus']; let NEAR_OFFSET = 100; let nearnessRegistry = new Map(); @@ -29,56 +29,53 @@ function distance(p, r) { * @param {HTMLElement} desc.element * @param {Function} desc.onChange * @param {Function} desc.onItems + * @param {() => Boolean} desc.shouldIgnore */ export function addDropzone(desc) { + let eventCounter = 0; + + let body = document.body; let switchHandlers = new Set(); let handleSwitch = (fn) => switchHandlers.add(fn); let state = DropzoneState.INACTIVE; let setState = (newState) => { + if (desc.shouldIgnore() && newState !== DropzoneState.INACTIVE) { + return; + } if (state !== newState) { switchHandlers.forEach((fn) => fn(newState)); } state = newState; }; - let onFinalEvent = (e) => { - let { clientX, clientY } = e; - let bodyBounds = document.body.getBoundingClientRect(); - let isDrop = e.type === 'drop'; - let isOuterDrag = ['dragleave', 'dragexit', 'dragend'].includes(e.type) && clientX === 0 && clientY === 0; - let isOuterMouse = - ['mouseleave', 'mouseout'].includes(e.type) && - (clientX < 0 || clientX > bodyBounds.width || clientY < 0 || clientY > bodyBounds.height); - if (isDrop || isOuterDrag || isOuterMouse) { + handleSwitch((newState) => desc.onChange(newState)); + + let onResetEvent = () => { + eventCounter = 0; + setState(DropzoneState.INACTIVE); + }; + let onDragEnter = () => { + eventCounter += 1; + if (state === DropzoneState.INACTIVE) { + setState(DropzoneState.ACTIVE); + } + }; + let onDragLeave = () => { + eventCounter -= 1; + let draggingInPage = eventCounter > 0; + if (!draggingInPage) { setState(DropzoneState.INACTIVE); } + }; + let onDrop = (e) => { e.preventDefault(); + eventCounter = 0; + setState(DropzoneState.INACTIVE); }; - handleSwitch((newState) => desc.onChange(newState)); - handleSwitch((newState) => { - if (newState === DropzoneState.ACTIVE) { - FINAL_EVENTS.forEach((eventName) => { - window.addEventListener(eventName, onFinalEvent, false); - }); - } - }); - handleSwitch((newState) => { - if (newState === DropzoneState.INACTIVE) { - FINAL_EVENTS.forEach((eventName) => { - window.removeEventListener(eventName, onFinalEvent, false); - }); - } - }); - let onDragOver = (e) => { - // console.log(e) - // Not sure that it won't conflict with other dnd elements on the page e.preventDefault(); - if (state === DropzoneState.INACTIVE) { - setState(DropzoneState.ACTIVE); - } /** @type {[Number, Number]} */ let dragPoint = [e.x, e.y]; @@ -98,22 +95,32 @@ export function addDropzone(desc) { setState(DropzoneState.ACTIVE); } }; - window.addEventListener('dragover', onDragOver, false); - let onDrop = async (e) => { + let onElementDrop = async (e) => { e.preventDefault(); let items = await getDropItems(e.dataTransfer); desc.onItems(items); setState(DropzoneState.INACTIVE); }; - desc.element.addEventListener('drop', onDrop); + + body.addEventListener('drop', onDrop); + body.addEventListener('dragleave', onDragLeave); + body.addEventListener('dragenter', onDragEnter); + body.addEventListener('dragover', onDragOver); + desc.element.addEventListener('drop', onElementDrop); + RESET_EVENTS.forEach((eventName) => { + window.addEventListener(eventName, onResetEvent); + }); return () => { nearnessRegistry.delete(desc.element); - window.removeEventListener('dragover', onDragOver, false); - desc.element.removeEventListener('drop', onDrop); - FINAL_EVENTS.forEach((eventName) => { - window.removeEventListener(eventName, onFinalEvent, false); + body.removeEventListener('drop', onDrop); + body.removeEventListener('dragleave', onDragLeave); + body.removeEventListener('dragenter', onDragEnter); + body.removeEventListener('dragover', onDragOver); + desc.element.removeEventListener('drop', onElementDrop); + RESET_EVENTS.forEach((eventName) => { + window.removeEventListener(eventName, onResetEvent); }); }; } diff --git a/blocks/DropArea/drop-area.css b/blocks/DropArea/drop-area.css index 974ea2568..4cecb3cfd 100644 --- a/blocks/DropArea/drop-area.css +++ b/blocks/DropArea/drop-area.css @@ -1,44 +1,172 @@ lr-drop-area { - display: flex; - align-items: center; - justify-content: center; padding: var(--gap-min); + overflow: hidden; border: var(--border-dashed); border-radius: var(--border-radius-frame); - transition: border-color var(--transition-duration) ease, background-color var(--transition-duration) ease, - opacity var(--transition-duration) ease; + transition: var(--transition-duration) ease; +} + +lr-drop-area, +lr-drop-area .content-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; } -lr-drop-area::after { +lr-drop-area .text { + position: relative; + margin: var(--gap-mid); color: var(--clr-txt-light); - content: var(--l10n-drop-files-here); + transition: var(--transition-duration) ease; } lr-drop-area[ghost][drag-state='inactive'] { + display: none; opacity: 0; } -lr-drop-area[drag-state='inactive'] { - background-color: var(--clr-shade-lv1); +lr-drop-area[ghost]:not([fullscreen]):is([drag-state='active'], [drag-state='near'], [drag-state='over']) { + background: var(--clr-background); } -lr-drop-area[drag-state='active'] { - background-color: var(--clr-accent-lightest); +lr-drop-area[with-icon] + > .content-wrapper:is([drag-state='active'], [drag-state='near'], [drag-state='over']) + :is(.text, .icon-container) { + color: var(--clr-accent); +} + +lr-drop-area:is([drag-state='active'], [drag-state='near'], [drag-state='over'], :hover) { + color: var(--clr-accent); + background: var(--clr-accent-lightest); + border-color: var(--clr-accent-light); +} + +lr-drop-area:is([drag-state='active'], [drag-state='near']) { opacity: 1; } -lr-drop-area[drag-state='near'] { +lr-drop-area[drag-state='over'] { + border-color: var(--clr-accent); opacity: 1; } -lr-drop-area[drag-state='near'], -lr-drop-area:hover { - background-color: var(--clr-accent-lightest); - border-color: var(--clr-accent-light); +lr-drop-area[with-icon] { + min-height: calc(var(--ui-size) * 6); } -lr-drop-area[drag-state='over'] { +lr-drop-area[with-icon] .content-wrapper { + display: flex; + flex-direction: column; +} + +lr-drop-area[with-icon] .text { + color: var(--clr-txt); + font-weight: 500; + font-size: 1.1em; +} + +lr-drop-area[with-icon] .icon-container { + position: relative; + width: calc(var(--ui-size) * 2); + height: calc(var(--ui-size) * 2); + margin: var(--gap-mid); + overflow: hidden; + color: var(--clr-txt); + background-color: var(--clr-background); + border-radius: 50%; + transition: var(--transition-duration) ease; +} + +lr-drop-area[with-icon] lr-icon { + position: absolute; + top: calc(50% - var(--ui-size) / 2); + left: calc(50% - var(--ui-size) / 2); + transition: var(--transition-duration) ease; +} + +lr-drop-area[with-icon] lr-icon:last-child { + transform: translateY(calc(var(--ui-size) * 1.5)); +} + +lr-drop-area[with-icon]:hover .icon-container, +lr-drop-area[with-icon]:hover .text { + color: var(--clr-accent); +} + +lr-drop-area[with-icon]:hover .icon-container { background-color: var(--clr-accent-lightest); - border-color: var(--clr-accent); +} + +lr-drop-area[with-icon] + > .content-wrapper:is([drag-state='active'], [drag-state='near'], [drag-state='over']) + .icon-container { + color: white; + background-color: var(--clr-accent); +} + +lr-drop-area[with-icon] > .content-wrapper:is([drag-state='active'], [drag-state='near'], [drag-state='over']) .text { + color: var(--clr-accent); +} + +lr-drop-area[with-icon] + > .content-wrapper:is([drag-state='active'], [drag-state='near'], [drag-state='over']) + lr-icon:first-child { + transform: translateY(calc(var(--ui-size) * -1.5)); +} + +lr-drop-area[with-icon] + > .content-wrapper:is([drag-state='active'], [drag-state='near'], [drag-state='over']) + lr-icon:last-child { + transform: translateY(0); +} + +lr-drop-area[with-icon] > .content-wrapper[drag-state='near'] lr-icon:last-child { + transform: scale(1.3); +} + +lr-drop-area[with-icon] > .content-wrapper[drag-state='over'] lr-icon:last-child { + transform: scale(1.5); +} + +lr-drop-area[fullscreen] { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 2147483647; + display: flex; + align-items: center; + justify-content: center; + width: calc(100vw - var(--gap-mid) * 2); + height: calc(100vh - var(--gap-mid) * 2); + margin: var(--gap-mid); +} + +lr-drop-area[fullscreen] .content-wrapper { + width: 100%; + max-width: calc(var(--modal-normal-w) * 0.8); + height: calc(var(--ui-size) * 6); + color: var(--clr-txt); + background-color: var(--clr-background-light); + border-radius: var(--border-radius-frame); + box-shadow: var(--modal-shadow); + transition: var(--transition-duration) ease; +} + +lr-drop-area[with-icon][fullscreen][drag-state='active'] > .content-wrapper, +lr-drop-area[with-icon][fullscreen][drag-state='near'] > .content-wrapper { + transform: translateY(var(--gap-mid)); + opacity: 0; +} + +lr-drop-area[with-icon][fullscreen][drag-state='over'] > .content-wrapper { + transform: translateY(0px); opacity: 1; } + +:is(lr-drop-area[with-icon][fullscreen]) > .content-wrapper lr-icon:first-child { + transform: translateY(calc(var(--ui-size) * -1.5)); +} diff --git a/blocks/ExternalSource/ExternalSource.js b/blocks/ExternalSource/ExternalSource.js index f610966dc..8b70442e7 100644 --- a/blocks/ExternalSource/ExternalSource.js +++ b/blocks/ExternalSource/ExternalSource.js @@ -15,6 +15,8 @@ export class ExternalSource extends UploaderBlock { init$ = { ...this.ctxInit, + activityIcon: '', + activityCaption: '', counter: 0, onDone: () => { this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; @@ -34,14 +36,13 @@ export class ExternalSource extends UploaderBlock { let { externalSourceType } = /** @type {ActivityParams} */ (this.activityParams); this.set$({ - '*activityCaption': `${externalSourceType?.[0].toUpperCase()}${externalSourceType?.slice(1)}`, - '*activityIcon': externalSourceType, + activityCaption: `${externalSourceType?.[0].toUpperCase()}${externalSourceType?.slice(1)}`, + activityIcon: externalSourceType, }); this.$.counter = 0; this.mountIframe(); }, - onClose: () => this.historyBack(), }); this.sub('*currentActivity', (val) => { if (val !== this.activityType) { @@ -137,13 +138,27 @@ export class ExternalSource extends UploaderBlock { } ExternalSource.template = /* HTML */ ` -
-
- -
-
{{counter}}
- +
+ + {{activityCaption}} +
+ + +
+
+
+ +
+
{{counter}}
+ +
`; diff --git a/blocks/ExternalSource/external-source.css b/blocks/ExternalSource/external-source.css index 079845376..487ce12bc 100644 --- a/blocks/ExternalSource/external-source.css +++ b/blocks/ExternalSource/external-source.css @@ -1,9 +1,29 @@ lr-external-source { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background-color: var(--clr-background-light); +} + +lr-modal lr-external-source { + width: min(calc(var(--modal-max-w) - var(--gap-mid) * 2), calc(100vw - var(--gap-mid) * 2)); + height: var(--modal-content-height-fill, 100%); + max-height: var(--modal-max-content-height); +} + +lr-external-source > .content { position: relative; display: grid; + flex: 1; grid-template-rows: 1fr min-content; - height: var(--modal-content-height-fill, 100%); - max-height: var(--modal-max-content-height); +} + +@media only screen and (max-width: 430px) { + lr-external-source { + width: calc(100vw - var(--gap-mid) * 2); + height: var(--modal-content-height-fill, 100%); + } } lr-external-source iframe { @@ -41,4 +61,5 @@ lr-external-source .selected-counter { align-items: center; justify-content: space-between; padding: var(--gap-mid); + color: var(--clr-txt-light); } diff --git a/blocks/FileItem/FileItem.js b/blocks/FileItem/FileItem.js index 4a09197e3..9b7814a3f 100644 --- a/blocks/FileItem/FileItem.js +++ b/blocks/FileItem/FileItem.js @@ -36,24 +36,29 @@ export class FileItem extends UploaderBlock { ...this.ctxInit, uid: '', itemName: '', + errorText: '', thumbUrl: '', progressValue: 0, progressVisible: false, progressUnknown: false, - notImage: true, badgeIcon: '', isFinished: false, isFailed: false, isUploading: false, isFocused: false, + isEditable: false, state: FileItemState.IDLE, '*uploadTrigger': null, onEdit: () => { this.set$({ '*focusedEntry': this._entry, - '*currentActivity': ActivityBlock.activities.DETAILS, }); + if (this.findBlockInCtx((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'); @@ -193,21 +198,12 @@ export class FileItem extends UploaderBlock { this._subEntry('validationErrorMsg', (validationErrorMsg) => { this._debouncedCalculateState(); - if (!validationErrorMsg) { - return; - } - let caption = - this.l10n('validation-error') + ': ' + (entry.getValue('file')?.name || entry.getValue('externalUrl')); - this._showMessage('error', caption, validationErrorMsg); + this.$.errorText = validationErrorMsg; }); this._subEntry('uploadError', (uploadError) => { this._debouncedCalculateState(); - if (!uploadError) { - return; - } - let caption = this.l10n('upload-error') + ': ' + (entry.getValue('file')?.name || entry.getValue('externalUrl')); - this._showMessage('error', caption, uploadError.message); + this.$.errorText = uploadError?.message; }); this._subEntry('isUploading', () => { @@ -293,6 +289,7 @@ export class FileItem extends UploaderBlock { isUploading: state === FileItemState.UPLOADING, isFinished: state === FileItemState.FINISHED, progressVisible: state === FileItemState.UPLOADING, + isEditable: state === FileItemState.FINISHED && this._entry?.getValue('isImage'), }); if (state === FileItemState.FAILED) { @@ -396,9 +393,6 @@ export class FileItem extends UploaderBlock { }, signal: abortController.signal, }); - if (entry === this._entry) { - this._debouncedCalculateState(); - } entry.setMultipleValues({ fileInfo, isUploading: false, @@ -409,10 +403,16 @@ export class FileItem extends UploaderBlock { uuid: fileInfo.uuid, cdnUrl: fileInfo.cdnUrl, }); + + if (entry === this._entry) { + this._debouncedCalculateState(); + } } catch (error) { - entry.setValue('abortController', null); - entry.setValue('isUploading', false); - entry.setValue('uploadProgress', 0); + entry.setMultipleValues({ + abortController: null, + isUploading: false, + uploadProgress: 0, + }); if (entry === this._entry) { this._debouncedCalculateState(); @@ -434,14 +434,15 @@ FileItem.template = /* HTML */ `
{{itemName}} + {{errorText}}
- - - .inner { position: relative; display: grid; grid-template-columns: min-content auto min-content min-content; + gap: var(--gap-min); + align-items: center; + margin-bottom: var(--gap-small); padding: var(--gap-mid); - background-color: transparent; - border-bottom: var(--border-light); + overflow: hidden; + font-size: 0.95em; + background-color: var(--clr-background); + border-radius: var(--border-radius-element); transition: var(--transition-duration); } lr-file-item:last-of-type > .inner { - border-bottom: none; -} - -lr-file-item:hover > .inner { - background-color: var(--clr-background); + margin-bottom: 0; } lr-file-item > .inner[focused] { @@ -48,7 +52,8 @@ lr-file-item .file-name-wrapper { padding-right: var(--gap-mid); padding-left: var(--gap-mid); overflow: hidden; - color: var(--clr-txt); + color: var(--clr-txt-light); + transition: color var(--transition-duration); } lr-file-item .file-name { @@ -58,40 +63,44 @@ lr-file-item .file-name { text-overflow: ellipsis; } -lr-file-item button { - width: var(--ui-size); - height: var(--ui-size); - padding: 0; +lr-file-item .file-error { + display: none; + color: var(--clr-error); + font-size: 0.85em; + line-height: 130%; +} + +lr-file-item button.remove-btn, +lr-file-item button.edit-btn { color: var(--clr-txt-lightest); - background-color: transparent; - cursor: pointer; - opacity: var(--opacity-normal); - transition: opacity var(--transition-duration); } -lr-file-item .upload-btn { +lr-file-item button.upload-btn { display: none; } lr-file-item button:hover { - opacity: var(--opacity-hover); + color: var(--clr-txt-light); } lr-file-item .badge { position: absolute; - top: calc(var(--ui-size) * -0.15); - right: calc(var(--ui-size) * -0.15); - display: none; - width: calc(var(--ui-size) * 0.42); - height: calc(var(--ui-size) * 0.42); + top: calc(var(--ui-size) * -0.13); + right: calc(var(--ui-size) * -0.13); + width: calc(var(--ui-size) * 0.44); + height: calc(var(--ui-size) * 0.44); color: var(--clr-background-light); background-color: var(--clr-txt); border-radius: 50%; + transform: scale(0.3); + opacity: 0; + transition: var(--transition-duration) ease; } lr-file-item > .inner[failed] .badge, lr-file-item > .inner[finished] .badge { - display: block; + transform: scale(1); + opacity: 1; } lr-file-item > .inner[finished] .badge { @@ -102,6 +111,10 @@ lr-file-item > .inner[failed] .badge { background-color: var(--clr-error); } +lr-file-item > .inner[failed] .file-error { + display: block; +} + lr-file-item .badge lr-icon, lr-file-item .badge lr-icon svg { width: 100%; diff --git a/blocks/Icon/Icon.js b/blocks/Icon/Icon.js index 4fc82f6e8..d164215e8 100644 --- a/blocks/Icon/Icon.js +++ b/blocks/Icon/Icon.js @@ -31,7 +31,7 @@ export class Icon extends Block { this.ref.svg.innerHTML = path; } else { this.removeAttribute('raw'); - this.ref.svg.innerHTML = ``; + this.ref.svg.innerHTML = ``; } }); diff --git a/blocks/Icon/icon.css b/blocks/Icon/icon.css index 7ffe6fd1d..9d7a72567 100644 --- a/blocks/Icon/icon.css +++ b/blocks/Icon/icon.css @@ -7,8 +7,8 @@ lr-icon { } lr-icon svg { - width: 1.2em; - height: 1.2em; + width: calc(var(--ui-size) / 2); + height: calc(var(--ui-size) / 2); } lr-icon:not([raw]) path { diff --git a/blocks/Img/ImgBase.js b/blocks/Img/ImgBase.js index b91924fe2..eddf7689d 100644 --- a/blocks/Img/ImgBase.js +++ b/blocks/Img/ImgBase.js @@ -1,5 +1,5 @@ import { BaseComponent } from '@symbiotejs/symbiote'; -import { applyTemplateData } from '../../utils/applyTemplateData.js'; +import { applyTemplateData } from '../../utils/template-utils.js'; import { createCdnUrl, createCdnUrlModifiers, createOriginalUrl } from '../../utils/cdn-utils.js'; import { PROPS_MAP } from './props-map.js'; diff --git a/blocks/LiveHtml/LiveHtml.js b/blocks/LiveHtml/LiveHtml.js index b123550ad..a38fa9e3f 100644 --- a/blocks/LiveHtml/LiveHtml.js +++ b/blocks/LiveHtml/LiveHtml.js @@ -105,6 +105,15 @@ class Caret { const headerHtml = /* HTML */ ` `; diff --git a/blocks/Modal/Modal.js b/blocks/Modal/Modal.js index 814228615..65c8bcc9a 100644 --- a/blocks/Modal/Modal.js +++ b/blocks/Modal/Modal.js @@ -1,29 +1,31 @@ import { Block } from '../../abstract/Block.js'; export class Modal extends Block { - _handleClose = () => { - if (this.$['*modalCloseCallback']) { - this.$['*modalCloseCallback'](); - return; - } - this.set$({ - '*modalActive': false, - '*currentActivity': '', - }); + static StateConsumerScope = 'modal'; + + _handleBackdropClick = () => { + this._closeDialog(); + }; + + _closeDialog = () => { + this.setForCtxTarget(Modal.StateConsumerScope, '*modalActive', false); }; - _handleClick = (e) => { + _handleDialogClose = () => { + this._closeDialog(); + }; + + _handleDialogClick = (e) => { if (e.target === this.ref.dialog) { - this._handleClose(); + this._closeDialog(); } }; + init$ = { ...this.ctxInit, '*modalActive': false, - '*modalHeaderHidden': false, - '*modalCloseCallback': null, isOpen: false, - closeClicked: this._handleClose, + closeClicked: this._handleDialogClose, }; cssInit$ = { @@ -31,25 +33,26 @@ export class Modal extends Block { }; show() { - if (this.ref.dialog.showModal) { - this.ref.dialog.showModal(); - } else { - this.setAttribute('dialog-fallback', ''); - } + this.ref.dialog.showModal?.(); } hide() { - if (this.ref.dialog.close) { - this.ref.dialog.close(); - } else { - this.removeAttribute('dialog-fallback'); - } + this.ref.dialog.close?.(); } initCallback() { super.initCallback(); - this.ref.dialog.addEventListener('close', this._handleClose); - this.ref.dialog.addEventListener('click', this._handleClick); + if (typeof HTMLDialogElement === 'function') { + this.ref.dialog.addEventListener('close', this._handleDialogClose); + this.ref.dialog.addEventListener('click', this._handleDialogClick); + } else { + this.setAttribute('dialog-fallback', ''); + let backdrop = document.createElement('div'); + backdrop.className = 'backdrop'; + this.appendChild(backdrop); + backdrop.addEventListener('click', this._handleBackdropClick); + } + this.sub('*modalActive', (modalActive) => { if (this.$.isOpen !== modalActive) { this.$.isOpen = modalActive; @@ -83,21 +86,13 @@ export class Modal extends Block { destroyCallback() { super.destroyCallback(); - this.ref.dialog.removeEventListener('close', this._handleClose); - this.ref.dialog.removeEventListener('click', this._handleClick); + this.ref.dialog.removeEventListener('close', this._handleDialogClose); + this.ref.dialog.removeEventListener('click', this._handleDialogClick); } } Modal.template = /* HTML */ ` - -
- - -
-
- -
+ + `; diff --git a/blocks/Modal/modal.css b/blocks/Modal/modal.css index 1adda386a..3bdf54870 100644 --- a/blocks/Modal/modal.css +++ b/blocks/Modal/modal.css @@ -2,28 +2,62 @@ lr-modal { --modal-max-content-height: calc(var(--uploadcare-blocks-window-height, 100vh) - 4 * var(--gap-mid) - var(--ui-size)); --modal-content-height-fill: var(--uploadcare-blocks-window-height, 100vh); } + lr-modal[dialog-fallback] { + --lr-z-max: 2147483647; + position: fixed; - top: 0px; - left: 0px; + z-index: var(--lr-z-max); display: flex; align-items: center; justify-content: center; width: 100vw; height: 100vh; + pointer-events: none; + inset: 0; } -lr-modal > :is(.dialog::backdrop), -lr-modal[dialog-fallback] { +lr-modal[dialog-fallback] dialog[open] { + z-index: var(--lr-z-max); + pointer-events: auto; +} + +lr-modal[dialog-fallback] dialog[open] + .backdrop { + position: fixed; + top: 0px; + left: 0px; + z-index: calc(var(--lr-z-max) - 1); + align-items: center; + justify-content: center; + width: 100vw; + height: 100vh; background-color: var(--clr-curtain); + pointer-events: auto; } -lr-modal[strokes] > :is(.dialog::backdrop), -lr-modal[dialog-fallback][strokes] { +lr-modal[strokes][dialog-fallback] dialog[open] + .backdrop { background-image: var(--modal-backdrop-background-image); } -lr-modal > .dialog:not([open]) { +@supports selector(dialog::backdrop) { + lr-modal > dialog::backdrop { + /* backdrop don't inherit theme properties */ + background-color: rgba(247 247 248 60%); + } + lr-modal[strokes] > dialog::backdrop { + /* TODO: it's not working, fix it */ + background-image: var(--modal-backdrop-background-image); + } +} + +lr-modal > dialog[open] { + transform: translateY(0px); + visibility: visible; + opacity: 1; +} + +lr-modal > dialog:not([open]) { + transform: translateY(20px); visibility: hidden; opacity: 0; } @@ -32,64 +66,38 @@ lr-modal button.close-btn { display: inline-flex; align-items: center; justify-content: center; - width: var(--ui-size); - height: var(--ui-size); - padding: 0; - color: var(--clr-txt-light); - background-color: transparent; - border: none; - cursor: pointer; - opacity: var(--opacity-normal); - transition: opacity var(--transition-duration) ease; + color: var(--clr-txt-mid); } lr-modal button.close-btn:hover { - opacity: var(--opacity-hover); + background-color: var(--clr-background); } lr-modal button.close-btn:active { - opacity: var(--opacity-active); + background-color: var(--clr-background-dark); } -lr-modal > .dialog { +lr-modal > dialog { display: flex; flex-direction: column; - width: 100%; - max-width: calc(var(--modal-max-w) - var(--gap-mid) * 2); + + /* there was `fit-content` but it doesn't reduce width after activity change */ + width: max-content; + max-width: min(calc(100% - var(--gap-mid) * 2), calc(var(--modal-max-w) - var(--gap-mid) * 2)); min-height: var(--ui-size); max-height: calc(var(--modal-max-h) - var(--gap-mid) * 2); margin: auto; padding: 0; overflow: hidden; - background-color: var(--clr-background); + background-color: var(--clr-background-light); border: 0; border-radius: var(--border-radius-frame); box-shadow: var(--modal-shadow); - transition: transform 0.4s; + transition: transform calc(var(--transition-duration) * 2); } -@media only screen and (max-width: 600px), only screen and (max-height: 800px) { - lr-modal > .dialog > .content { +@media only screen and (max-width: 430px), only screen and (max-height: 600px) { + lr-modal > dialog > .content { height: var(--modal-max-content-height); } } - -lr-modal .dialog:not([open]) { - transform: translateY(20px); -} - -lr-modal > .dialog > .content { - display: contents; -} - -lr-modal .heading { - display: grid; - grid-template-columns: min-content 1fr min-content; - padding: var(--gap-mid); - color: var(--clr-txt-light); - font-weight: 500; - font-size: 1.1em; - line-height: var(--ui-size); - background-color: var(--clr-background-light); - border-bottom: var(--border-light); -} diff --git a/blocks/README.md b/blocks/README.md index 0a28c1f4c..8a974a054 100644 --- a/blocks/README.md +++ b/blocks/README.md @@ -4,8 +4,6 @@ If our pre-built [uploader solution](/solutions/file-uploader/) isn't enough for ## Blocks list -- [ActivityCaption](/blocks/ActivityCaption/) — shows heading text for the current activity -- [ActivityIcon](/blocks/ActivityIcon/) — shows actual icon for the current activity - [CameraSource](/blocks/CameraSource/) — getting image for upload from the device camera - [CloudImageEditor](/blocks/CloudImageEditor/) — image editing via Uploadcare cloud functions - [Color](/blocks/Color/) — simple wrapper for the native color selector in browser @@ -126,8 +124,6 @@ Block components can be used separately or in combinations. You can combine them - - @@ -136,7 +132,6 @@ Block components can be used separately or in combinations. You can combine them - diff --git a/blocks/ShadowWrapper/ShadowWrapper.js b/blocks/ShadowWrapper/ShadowWrapper.js index db8f24dc1..1f64ad25c 100644 --- a/blocks/ShadowWrapper/ShadowWrapper.js +++ b/blocks/ShadowWrapper/ShadowWrapper.js @@ -9,6 +9,7 @@ export class ShadowWrapper extends Block { initCallback() { super.initCallback(); + this.setAttribute('hidden', ''); let href = this.getAttribute(CSS_ATTRIBUTE); if (href) { this.renderShadow = true; @@ -25,6 +26,7 @@ export class ShadowWrapper extends Block { window.requestAnimationFrame(() => { this.render(); window.setTimeout(() => { + this.removeAttribute('hidden'); this.shadowReadyCallback(); }); }); @@ -32,6 +34,7 @@ export class ShadowWrapper extends Block { this.shadowRoot.appendChild(link); } else { this.render(); + this.removeAttribute('hidden'); this.shadowReadyCallback(); } } diff --git a/blocks/SimpleBtn/simple-btn.css b/blocks/SimpleBtn/simple-btn.css index 02800a9d7..99607f6b6 100644 --- a/blocks/SimpleBtn/simple-btn.css +++ b/blocks/SimpleBtn/simple-btn.css @@ -10,6 +10,11 @@ lr-simple-btn button { box-shadow: var(--shadow-btn-secondary); } +lr-simple-btn button lr-icon svg { + width: calc(var(--ui-size) * 0.4); + height: calc(var(--ui-size) * 0.4); +} + lr-simple-btn button:hover { background-color: var(--clr-btn-bgr-secondary-hover); } diff --git a/blocks/SourceBtn/SourceBtn.js b/blocks/SourceBtn/SourceBtn.js index 1cab3c615..135a419cd 100644 --- a/blocks/SourceBtn/SourceBtn.js +++ b/blocks/SourceBtn/SourceBtn.js @@ -15,7 +15,6 @@ export class SourceBtn extends UploaderBlock { initTypes() { this.registerType({ type: UploaderBlock.sourceTypes.LOCAL, - // activity: '', onClick: () => { this.openSystemDialog(); }, @@ -28,6 +27,14 @@ export class SourceBtn extends UploaderBlock { this.registerType({ type: UploaderBlock.sourceTypes.CAMERA, activity: ActivityBlock.activities.CAMERA, + onClick: () => { + let el = document.createElement('input'); + var supportsCapture = el.capture !== undefined; + if (supportsCapture) { + this.openSystemDialog({ captureCamera: true }); + } + return !supportsCapture; + }, }); this.registerType({ type: 'draw', @@ -78,12 +85,12 @@ export class SourceBtn extends UploaderBlock { this.applyL10nKey('src-type', `${L10N_PREFIX}${textKey}`); this.$.iconName = icon; this.onclick = (e) => { - activity && + const showActivity = onClick ? onClick(e) : !!activity; + showActivity && this.set$({ '*currentActivityParams': activityParams, '*currentActivity': activity, }); - onClick && onClick(e); }; } } diff --git a/blocks/SourceBtn/source-btn.css b/blocks/SourceBtn/source-btn.css index e467489a2..6a36de0dc 100644 --- a/blocks/SourceBtn/source-btn.css +++ b/blocks/SourceBtn/source-btn.css @@ -1,7 +1,8 @@ lr-source-btn { display: flex; align-items: center; - padding-right: 1em; + margin-bottom: var(--gap-min); + padding: var(--gap-min) var(--gap-mid); color: var(--clr-txt-mid); border-radius: var(--border-radius-element); cursor: pointer; @@ -11,13 +12,13 @@ lr-source-btn { } lr-source-btn:hover { - color: var(--clr-txt); - background-color: var(--clr-shade-lv1); + color: var(--clr-accent); + background-color: var(--clr-accent-lightest); } lr-source-btn:active { - color: var(--clr-txt); - background-color: var(--clr-shade-lv2); + color: var(--clr-accent); + background-color: var(--clr-accent-light); } lr-source-btn lr-icon { @@ -25,6 +26,8 @@ lr-source-btn lr-icon { flex-grow: 1; justify-content: center; min-width: var(--ui-size); + margin-right: var(--gap-mid); + opacity: 0.8; } lr-source-btn[type='local'] > .txt::after { diff --git a/blocks/StartFrom/StartFrom.js b/blocks/StartFrom/StartFrom.js index 25cbea356..f7f6926da 100644 --- a/blocks/StartFrom/StartFrom.js +++ b/blocks/StartFrom/StartFrom.js @@ -1,21 +1,11 @@ import { ActivityBlock } from '../../abstract/ActivityBlock.js'; export class StartFrom extends ActivityBlock { + historyTracked = true; activityType = 'start-from'; initCallback() { super.initCallback(); - this.registerActivity(this.activityType, { - onActivate: () => { - this.add$( - { - '*activityCaption': this.l10n('select-file-source'), - '*activityIcon': 'default', - }, - true - ); - }, - onClose: () => this.historyBack(), - }); + this.registerActivity(this.activityType); } } diff --git a/blocks/StartFrom/start-from.css b/blocks/StartFrom/start-from.css index 483610036..9cafa3b6c 100644 --- a/blocks/StartFrom/start-from.css +++ b/blocks/StartFrom/start-from.css @@ -1,9 +1,14 @@ lr-start-from { display: grid; - grid-template-columns: min-content auto; - gap: var(--gap-mid); - - /* height: 100%; - this breaks inner modal layout height in Safari */ - padding: var(--gap-mid); + grid-template-rows: 1fr max-content max-content; + gap: var(--gap-max); + width: 100%; + height: max-content; + padding: var(--gap-max); overflow-y: auto; + background-color: var(--clr-background-light); +} + +lr-modal lr-start-from { + width: min(calc(var(--modal-normal-w) - var(--gap-mid) * 2), calc(100vw - var(--gap-mid) * 2)); } diff --git a/blocks/UploadDetails/UploadDetails.js b/blocks/UploadDetails/UploadDetails.js index f132b3ed7..6646ae185 100644 --- a/blocks/UploadDetails/UploadDetails.js +++ b/blocks/UploadDetails/UploadDetails.js @@ -53,15 +53,9 @@ export class UploadDetails extends UploaderBlock { this.render(); this.$.fileSize = this.l10n('file-size-unknown'); this.registerActivity(this.activityType, { - onActivate: () => { - this.set$({ - '*activityCaption': this.l10n('caption-edit-file'), - }); - }, onDeactivate: () => { this.ref.filePreview.clear(); }, - onClose: () => this.historyBack(), }); /** @type {import('../FilePreview/FilePreview.js').FilePreview} */ this.sub('*focusedEntry', (/** @type {import('../../abstract/TypedData.js').TypedData} */ entry) => { @@ -144,46 +138,57 @@ export class UploadDetails extends UploaderBlock { } UploadDetails.template = /* HTML */ ` - -
-
-
- -
+ + + + + +
+ +
+
+
+ +
-
-
-
{{fileSize}}
-
+
+
+
{{fileSize}}
+
-
-
- {{cdnUrl}} -
+
+
+ {{cdnUrl}} +
-
{{errorTxt}}
-
+
{{errorTxt}}
+
- - + + -
- - -
- +
+ + +
+ +
`; diff --git a/blocks/UploadDetails/upload-details.css b/blocks/UploadDetails/upload-details.css index 6074c882e..17f419762 100644 --- a/blocks/UploadDetails/upload-details.css +++ b/blocks/UploadDetails/upload-details.css @@ -1,10 +1,18 @@ lr-upload-details { - position: relative; - display: grid; - grid-template-rows: auto min-content; + display: flex; + flex-direction: column; + width: min(calc(var(--modal-max-w) - var(--gap-mid) * 2), calc(100vw - var(--gap-mid) * 2)); height: var(--modal-content-height-fill, 100%); max-height: var(--modal-max-content-height); overflow: hidden; + background-color: var(--clr-background-light); +} + +lr-upload-details > .content { + position: relative; + display: grid; + flex: 1; + grid-template-rows: auto min-content; } lr-upload-details lr-tabs .tabs-context { diff --git a/blocks/UploadList/UploadList.js b/blocks/UploadList/UploadList.js index 755e77037..babc16122 100644 --- a/blocks/UploadList/UploadList.js +++ b/blocks/UploadList/UploadList.js @@ -1,11 +1,11 @@ import { UploaderBlock } from '../../abstract/UploaderBlock.js'; import { ActivityBlock } from '../../abstract/ActivityBlock.js'; -import { UiConfirmation } from '../ConfirmationDialog/ConfirmationDialog.js'; import { UiMessage } from '../MessageBox/MessageBox.js'; import { EVENT_TYPES, EventData, EventManager } from '../../abstract/EventManager.js'; import { debounce } from '../utils/debounce.js'; export class UploadList extends UploaderBlock { + historyTracked = true; activityType = ActivityBlock.activities.UPLOAD_LIST; init$ = { @@ -13,9 +13,9 @@ export class UploadList extends UploaderBlock { doneBtnVisible: false, doneBtnEnabled: false, uploadBtnVisible: false, - uploadBtnEnabled: false, addMoreBtnVisible: false, addMoreBtnEnabled: false, + headerText: '', hasFiles: false, onAdd: () => { @@ -29,25 +29,17 @@ export class UploadList extends UploaderBlock { this.cancelFlow(); }, onCancel: () => { - let cfn = new UiConfirmation(); - cfn.confirmAction = () => { - let data = this.getOutputData((dataItem) => { - return !!dataItem.getValue('uuid'); - }); - EventManager.emit( - new EventData({ - type: EVENT_TYPES.REMOVE, - ctx: this.ctxName, - data, - }) - ); - this.uploadCollection.clearAll(); - this.historyBack(); - }; - cfn.denyAction = () => { - this.historyBack(); - }; - this.$['*confirmation'] = cfn; + let data = this.getOutputData((dataItem) => { + return !!dataItem.getValue('uuid'); + }); + EventManager.emit( + new EventData({ + type: EVENT_TYPES.REMOVE, + ctx: this.ctxName, + data, + }) + ); + this.uploadCollection.clearAll(); }, }; @@ -139,32 +131,47 @@ export class UploadList extends UploaderBlock { let fitValidation = summary.failed === 0; let doneBtnEnabled = summary.total > 0 && fitCountRestrictions && fitValidation; - let uploadBtnEnabled = - summary.total - summary.succeed - summary.uploading - summary.failed > 0 && fitCountRestrictions; + let uploadBtnVisible = + !allDone && summary.total - summary.succeed - summary.uploading - summary.failed > 0 && fitCountRestrictions; this.set$({ doneBtnVisible: allDone, doneBtnEnabled: doneBtnEnabled, - uploadBtnVisible: !allDone, - uploadBtnEnabled, + uploadBtnVisible, addMoreBtnEnabled: summary.total === 0 || (!tooMany && !exact), addMoreBtnVisible: !exact || this.getCssData('--cfg-multiple'), + + headerText: this._getHeaderText(summary), }); } + /** @private */ + _getHeaderText(summary) { + const localizedText = (status) => { + const count = summary[status]; + return this.l10n(`header-${status}`, { + count: count, + }); + }; + if (summary.uploading > 0) { + return localizedText('uploading'); + } + if (summary.failed > 0) { + return localizedText('failed'); + } + if (summary.succeed > 0) { + return localizedText('succeed'); + } + + return localizedText('selected'); + } + initCallback() { super.initCallback(); - this.registerActivity(this.activityType, { - onActivate: () => { - this.set$({ - '*activityCaption': this.l10n('selected'), - '*activityIcon': 'local', - }); - }, - }); + this.registerActivity(this.activityType); this.sub('--cfg-multiple', this._debouncedHandleCollectionUpdate); this.sub('--cfg-multiple-min', this._debouncedHandleCollectionUpdate); @@ -192,7 +199,7 @@ export class UploadList extends UploaderBlock { }); if (list?.length === 0 && !this.getCssData('--cfg-show-empty-list')) { - this.cancelFlow(); + this.historyBack(); } }); } @@ -204,6 +211,13 @@ export class UploadList extends UploaderBlock { } UploadList.template = /* HTML */ ` + + {{headerText}} + + +
@@ -217,12 +231,13 @@ UploadList.template = /* HTML */ ` type="button" class="add-more-btn secondary-btn" set="onclick: onAdd; @disabled: !addMoreBtnEnabled; @hidden: !addMoreBtnVisible" - l10n="add-more" - > + > + +
+ + `; diff --git a/blocks/UploadList/upload-list.css b/blocks/UploadList/upload-list.css index 34832b1fd..778d2d036 100644 --- a/blocks/UploadList/upload-list.css +++ b/blocks/UploadList/upload-list.css @@ -1,31 +1,58 @@ lr-upload-list { display: flex; flex-direction: column; - max-height: var(--modal-max-content-height); + width: 100%; + height: 100%; overflow: hidden; background-color: var(--clr-background-light); transition: opacity var(--transition-duration); } +lr-modal lr-upload-list { + width: min(calc(var(--modal-normal-w) - var(--gap-mid) * 2), calc(100vw - var(--gap-mid) * 2)); + height: max-content; + max-height: var(--modal-max-content-height); +} + lr-upload-list .no-files { - height: var(--file-item-height); + height: var(--ui-size); padding: var(--gap-max); } lr-upload-list .files { display: block; + flex: 1; + min-height: var(--ui-size); + padding: 0 var(--gap-mid); overflow: auto; } lr-upload-list .toolbar { display: flex; - gap: var(--gap-mid); + gap: var(--gap-small); justify-content: space-between; padding: var(--gap-mid); - background-color: var(--clr-background); - border-top: var(--border-light); + background-color: var(--clr-background-light); +} + +lr-upload-list .toolbar .add-more-btn { + padding-left: 0.2em; } lr-upload-list .toolbar-spacer { flex: 1; } + +lr-upload-list lr-drop-area { + position: absolute; + top: 0; + left: 0; + width: calc(100% - var(--gap-mid) * 2); + height: calc(100% - var(--gap-mid) * 2); + margin: var(--gap-mid); + border-radius: var(--border-radius-element); +} + +lr-upload-list lr-activity-header > .header-text { + padding: 0 var(--gap-mid); +} diff --git a/blocks/UrlSource/UrlSource.js b/blocks/UrlSource/UrlSource.js index 1c51a14b1..afad083ae 100644 --- a/blocks/UrlSource/UrlSource.js +++ b/blocks/UrlSource/UrlSource.js @@ -25,20 +25,29 @@ export class UrlSource extends UploaderBlock { initCallback() { super.initCallback(); - this.registerActivity(this.activityType, { - onActivate: () => { - this.set$({ - '*activityCaption': this.l10n('caption-from-url'), - '*activityIcon': 'url', - }); - }, - onClose: () => this.historyBack(), - }); + this.registerActivity(this.activityType); } } UrlSource.template = /* HTML */ ` - - - + + +
+ + +
+ +
+
+ + +
`; diff --git a/blocks/UrlSource/url-source.css b/blocks/UrlSource/url-source.css index af562b6c8..4de8c8705 100644 --- a/blocks/UrlSource/url-source.css +++ b/blocks/UrlSource/url-source.css @@ -1,8 +1,18 @@ lr-url-source { + display: block; + background-color: var(--clr-background-light); +} + +lr-modal lr-url-source { + width: min(calc(var(--modal-normal-w) - var(--gap-mid) * 2), calc(100vw - var(--gap-mid) * 2)); +} + +lr-url-source > .content { display: grid; - grid-gap: var(--gap-mid); - grid-template-columns: 1fr min-content min-content; + grid-gap: var(--gap-small); + grid-template-columns: 1fr min-content; padding: var(--gap-mid); + padding-top: 0; } lr-url-source .url-input { diff --git a/blocks/Video/video.css b/blocks/Video/video.css index 0a91fc827..85499ee3c 100644 --- a/blocks/Video/video.css +++ b/blocks/Video/video.css @@ -1,4 +1,4 @@ -@import '../Range/range.css'; +@import url('../Range/range.css'); lr-video { --color-accent: rgb(196, 243, 255); diff --git a/blocks/svg-backgrounds/svg-backgrounds.js b/blocks/svg-backgrounds/svg-backgrounds.js index f0e28ed2c..042fc17c9 100644 --- a/blocks/svg-backgrounds/svg-backgrounds.js +++ b/blocks/svg-backgrounds/svg-backgrounds.js @@ -36,42 +36,10 @@ export function strokesCssBg(color = 'rgba(0, 0, 0, .1)') { * @param {String} [color] * @returns {String} */ -export function fileCssBg(color = 'hsl(0, 0%, 100%)', width = 36, height = 36) { +export function fileCssBg(color = 'hsl(209, 21%, 65%)', width = 32, height = 32) { return createSvgBlobUrl(/*svg*/ ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + `); } diff --git a/blocks/test/custom-source-btn/index.htm b/blocks/test/custom-source-btn/index.htm index dca8929a3..d5330b8ba 100644 --- a/blocks/test/custom-source-btn/index.htm +++ b/blocks/test/custom-source-btn/index.htm @@ -18,7 +18,6 @@ - diff --git a/blocks/test/inline-mode.htm b/blocks/test/inline-mode.htm index 91c310acb..841083f37 100644 --- a/blocks/test/inline-mode.htm +++ b/blocks/test/inline-mode.htm @@ -6,6 +6,7 @@ .lr-wgt-common { --ctx-name: 'my-uploader'; --cfg-pubkey: 'demopublickey'; + --darkmode: 0; } @@ -37,10 +38,6 @@
-
- - -
@@ -50,7 +47,6 @@ - diff --git a/blocks/test/raw-build.htm b/blocks/test/raw-build.htm index 7965ee816..fb0fc2703 100644 --- a/blocks/test/raw-build.htm +++ b/blocks/test/raw-build.htm @@ -2,37 +2,13 @@ - + - - - - - - - - - - - - - - - - - - - - - - + diff --git a/blocks/test/raw-minimal.htm b/blocks/test/raw-minimal.htm new file mode 100644 index 000000000..c7b0889dc --- /dev/null +++ b/blocks/test/raw-minimal.htm @@ -0,0 +1,34 @@ + + + + + + + + + + + + diff --git a/blocks/test/raw-regular.htm b/blocks/test/raw-regular.htm new file mode 100644 index 000000000..d03758395 --- /dev/null +++ b/blocks/test/raw-regular.htm @@ -0,0 +1,33 @@ + + + + + + + + + + + + diff --git a/blocks/test/raw.htm b/blocks/test/raw.htm deleted file mode 100644 index 878b79269..000000000 --- a/blocks/test/raw.htm +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - -
diff --git a/blocks/test/split-mode.htm b/blocks/test/split-mode.htm index 154f2af5a..381eceed2 100644 --- a/blocks/test/split-mode.htm +++ b/blocks/test/split-mode.htm @@ -6,6 +6,7 @@ .lr-wgt-common { --ctx-name: 'my-uploader'; --cfg-pubkey: 'demopublickey'; + --darkmode: 0; } @@ -47,10 +48,6 @@
-
- - -
- + css-src="./minimal/index.css"> .toolbar { + background-color: transparent; } lr-upload-list { width: 100%; height: unset; - padding: var(--gap-min); + padding: var(--gap-small); background-color: transparent; border: var(--border-dashed); - border-style: solid; border-radius: var(--border-radius-frame); } +lr-upload-list .files { + padding: 0; +} + lr-upload-list .toolbar { + display: block; + padding: 0; +} + +lr-upload-list .toolbar .cancel-btn, +lr-upload-list .toolbar .upload-btn, +lr-upload-list .toolbar .done-btn { + display: none; +} + +lr-upload-list .toolbar .add-more-btn { + width: 100%; + height: calc(var(--ui-size) + var(--gap-mid) * 2); + margin-top: var(--gap-small); + background-color: var(--clr-background); +} + +lr-upload-list .toolbar .add-more-btn:hover { + background-color: var(--clr-background-dark); +} + +lr-upload-list .toolbar .add-more-btn > span { display: none; } lr-file-item { + background-color: var(--clr-background); border-radius: var(--border-radius-element); } @@ -129,3 +168,10 @@ lr-file-item lr-progress-bar .progress { background-color: var(--clr-accent-lightest); border-radius: var(--border-radius-element); } + +lr-upload-list lr-drop-area { + width: 100%; + height: 100%; + margin: 0; + border-radius: var(--border-radius-frame); +} diff --git a/solutions/file-uploader/minimal/index.js b/solutions/file-uploader/minimal/index.js index 9051a0d96..b214bfa9f 100644 --- a/solutions/file-uploader/minimal/index.js +++ b/solutions/file-uploader/minimal/index.js @@ -7,6 +7,7 @@ import { FileItem } from '../../../blocks/FileItem/FileItem.js'; import { Icon } from '../../../blocks/Icon/Icon.js'; import { ProgressBar } from '../../../blocks/ProgressBar/ProgressBar.js'; import { MessageBox } from '../../../blocks/MessageBox/MessageBox.js'; +import { Copyright } from '../../../blocks/Copyright/Copyright.js'; registerBlocks({ FileUploaderMinimal, @@ -17,4 +18,5 @@ registerBlocks({ Icon, ProgressBar, MessageBox, + Copyright, }); diff --git a/solutions/file-uploader/regular/FileUploaderRegular.js b/solutions/file-uploader/regular/FileUploaderRegular.js index c001b4443..d85bbf2b9 100644 --- a/solutions/file-uploader/regular/FileUploaderRegular.js +++ b/solutions/file-uploader/regular/FileUploaderRegular.js @@ -6,17 +6,15 @@ FileUploaderRegular.template = /* HTML */ ` - - + - + - diff --git a/solutions/file-uploader/regular/demo.htm b/solutions/file-uploader/regular/demo.htm index c52180776..52b441ac1 100644 --- a/solutions/file-uploader/regular/demo.htm +++ b/solutions/file-uploader/regular/demo.htm @@ -8,6 +8,7 @@

Button with modal window

@@ -25,6 +26,7 @@

Single source

lr-file-uploader-regular { --cfg-pubkey: 'demopublickey'; --cfg-source-list: 'local'; + --darkmode: 0; } diff --git a/static-gen/importmap.json b/static-gen/importmap.json index 6db743902..bd3a40b8b 100644 --- a/static-gen/importmap.json +++ b/static-gen/importmap.json @@ -1,6 +1,6 @@ { "imports": { - "@symbiotejs/symbiote": "https://cdn.skypack.dev/pin/@symbiotejs/symbiote@v1.11.1-nb4uuMnoyFEHQ78uWIov/mode=imports,min/optimized/@symbiotejs/symbiote.js", - "@uploadcare/upload-client": "https://cdn.skypack.dev/-/@uploadcare/upload-client@v5.1.0-ikuQtxJ0wUDrZwcMWBtL/dist=es2020,mode=raw,min/dist/index.browser.js" + "@symbiotejs/symbiote": "https://cdn.skypack.dev/pin/@symbiotejs/symbiote@v1.11.5-xok0d4yq3JnqOTheB68R/mode=imports,min/optimized/@symbiotejs/symbiote.js", + "@uploadcare/upload-client": "https://cdn.skypack.dev/-/@uploadcare/upload-client@v6.2.1-BoihH4bL0d7hAb0UH0Rx/dist=es2020,mode=raw,min/dist/index.browser.js" } } diff --git a/types/tags.d.ts b/types/tags.d.ts index 7f8bbefc9..5d18cf47c 100644 --- a/types/tags.d.ts +++ b/types/tags.d.ts @@ -37,8 +37,7 @@ declare namespace JSX { 'lr-external-source': any; 'lr-tabs': any; 'lr-data-output': any; - 'lr-activity-caption': any; - 'lr-activity-icon': any; + 'lr-activity-heading': any; 'lr-file-uploader-regular': any; 'lr-file-uploader-minimal': any; } diff --git a/utils/applyTemplateData.js b/utils/applyTemplateData.js deleted file mode 100644 index c02f60c48..000000000 --- a/utils/applyTemplateData.js +++ /dev/null @@ -1,25 +0,0 @@ -/** @typedef {{ [key: String]: String | Number | Boolean | InputData }} InputData */ - -const DEFAULT_TRANSFORMER = (value) => value; - -/** - * @typedef {Object} Options - * @property {String} [openToken='{{'] Default is `'{{'` - * @property {String} [closeToken='}}'] Default is `'}}'` - * @property {(value: String) => String} [transform=DEFAULT_TRANSFORMER] Default is `DEFAULT_TRANSFORMER` - */ - -/** - * @param {String} template - * @param {InputData} [data={}] Default is `{}` - * @param {Options} [options={}] Default is `{}` - * @returns {String} - */ -export function applyTemplateData(template, data, options = {}) { - let { openToken = '{{', closeToken = '}}', transform = DEFAULT_TRANSFORMER } = options; - for (let key in data) { - let value = data[key]?.toString(); - template = template.replaceAll(openToken + key + closeToken, typeof value === 'string' ? transform(value) : value); - } - return template; -} diff --git a/utils/applyTemplateData.test.js b/utils/applyTemplateData.test.js deleted file mode 100644 index 21f8db308..000000000 --- a/utils/applyTemplateData.test.js +++ /dev/null @@ -1,41 +0,0 @@ -import { expect } from '@esm-bundle/chai'; -import { applyTemplateData } from './applyTemplateData.js'; - -describe('applyTemplateData', () => { - it('should return the same string if no variables passed', () => { - let result = applyTemplateData('Hello world!'); - expect(result).to.equal('Hello world!'); - }); - - it('should replace variables', () => { - let result = applyTemplateData("Hello world! My name is {{name}}. I'm {{age}} years old.", { - name: 'John Doe', - age: 12, - }); - expect(result).to.equal("Hello world! My name is John Doe. I'm 12 years old."); - }); - - it('should work with variables at start/end', () => { - const result = applyTemplateData("{{name}} my name is. I'm {{age}}", { name: 'John Doe', age: 12 }); - expect(result).to.equal("John Doe my name is. I'm 12"); - }); - - it('should work with single variable', () => { - const result = applyTemplateData('{{name}}', { name: 'John Doe' }); - expect(result).to.equal('John Doe'); - }); - - it('should not replace non-defined variabled', () => { - let result = applyTemplateData('My name is {{name}}'); - expect(result).to.equal('My name is {{name}}'); - }); - - it('should accept `transform` option', () => { - let result = applyTemplateData( - 'My name is {{name}}', - { name: 'John Doe' }, - { transform: (value) => value.toUpperCase() } - ); - expect(result).to.equal('My name is JOHN DOE'); - }); -}); diff --git a/utils/cdn-utils.js b/utils/cdn-utils.js index 7ba796816..c234adc6b 100644 --- a/utils/cdn-utils.js +++ b/utils/cdn-utils.js @@ -68,6 +68,41 @@ export function extractFilename(cdnUrl) { return filename; } +/** + * Extract UUID from CDN URL + * + * @param {string} cdnUrl + * @returns {string} + */ +export function extractUuid(cdnUrl) { + let url = new URL(cdnUrl); + let { pathname } = url; + const slashIndex = pathname.indexOf('/'); + const secondSlashIndex = pathname.indexOf('/', slashIndex + 1); + return pathname.substring(slashIndex + 1, secondSlashIndex); +} + +/** + * Extract UUID from CDN URL + * + * @param {string} cdnUrl + * @returns {string[]} + */ +export function extractOperations(cdnUrl) { + let withoutFilename = trimFilename(cdnUrl); + let url = new URL(withoutFilename); + let operationsMarker = url.pathname.indexOf('/-/'); + if (operationsMarker === -1) { + return []; + } + let operationsStr = url.pathname.substring(operationsMarker); + + return operationsStr + .split('/-/') + .filter(Boolean) + .map((operation) => normalizeCdnOperation(operation)); +} + /** * Trim filename or file URL * diff --git a/utils/cdn-utils.test.js b/utils/cdn-utils.test.js index 23ec899d2..11583cee9 100644 --- a/utils/cdn-utils.test.js +++ b/utils/cdn-utils.test.js @@ -7,6 +7,8 @@ import { createOriginalUrl, extractFilename, trimFilename, + extractUuid, + extractOperations, } from './cdn-utils.js'; const falsyValues = ['', undefined, null, false, true, 0, 10]; @@ -183,3 +185,29 @@ describe('cdn-utils/trimFilename', () => { ); }); }); + +describe('cdn-utils/extractUuid', () => { + it('should extract uuid from cdn url', () => { + expect(extractUuid('https://ucarecdn.com/:uuid/image.jpeg')).to.eq(':uuid'); + expect(extractUuid('https://ucarecdn.com/:uuid/-/resize/100x/image.jpeg')).to.eq(':uuid'); + + expect(extractUuid('https://ucarecdn.com/c2499162-eb07-4b93-b31e-94a89a47e858/image.jpeg')).to.eq( + 'c2499162-eb07-4b93-b31e-94a89a47e858' + ); + expect(extractUuid('https://ucarecdn.com/c2499162-eb07-4b93-b31e-94a89a47e858/-/resize/100x/image.jpeg')).to.eq( + 'c2499162-eb07-4b93-b31e-94a89a47e858' + ); + }); +}); + +describe('cdn-utils/extractOperations', () => { + it('should extract operations from cdn url', () => { + expect(extractOperations('https://ucarecdn.com/:uuid/image.jpeg')).to.eql([]); + expect( + extractOperations('https://ucarecdn.com/c2499162-eb07-4b93-b31e-94a89a47e858/-/resize/100x/image.jpeg') + ).to.eql(['resize/100x']); + expect(extractOperations('https://domain.ucr.io:8080/-/resize/100x/https://domain.com/image.jpg?q=1#hash')).to.eql([ + 'resize/100x', + ]); + }); +}); diff --git a/utils/template-utils.js b/utils/template-utils.js new file mode 100644 index 000000000..6c52891ad --- /dev/null +++ b/utils/template-utils.js @@ -0,0 +1,50 @@ +/** @typedef {{ [key: String]: String | Number | Boolean | InputData }} InputData */ + +const DEFAULT_TRANSFORMER = (value) => value; +const OPEN_TOKEN = '{{'; +const CLOSE_TOKEN = '}}'; +const PLURAL_PREFIX = 'plural:'; + +/** + * @typedef {Object} Options + * @property {String} [openToken='{{'] Default is `'{{'` + * @property {String} [closeToken='}}'] Default is `'}}'` + * @property {(value: String) => String} [transform=DEFAULT_TRANSFORMER] Default is `DEFAULT_TRANSFORMER` + */ + +/** + * @param {String} template + * @param {InputData} [data={}] Default is `{}` + * @param {Options} [options={}] Default is `{}` + * @returns {String} + */ +export function applyTemplateData(template, data, options = {}) { + let { openToken = OPEN_TOKEN, closeToken = CLOSE_TOKEN, transform = DEFAULT_TRANSFORMER } = options; + + for (let key in data) { + let value = data[key]?.toString(); + template = template.replaceAll(openToken + key + closeToken, typeof value === 'string' ? transform(value) : value); + } + return template; +} + +/** + * @param {String} template + * @returns {{ variable: string; pluralKey: string; countVariable: string }[]} + */ +export function getPluralObjects(template) { + const pluralObjects = []; + let open = template.indexOf(OPEN_TOKEN); + while (open !== -1) { + const close = template.indexOf(CLOSE_TOKEN, open); + const variable = template.substring(open + 2, close); + if (variable.startsWith(PLURAL_PREFIX)) { + const keyValue = template.substring(open + 2, close).replace(PLURAL_PREFIX, ''); + const key = keyValue.substring(0, keyValue.indexOf('(')); + const count = keyValue.substring(keyValue.indexOf('(') + 1, keyValue.indexOf(')')); + pluralObjects.push({ variable, pluralKey: key, countVariable: count }); + } + open = template.indexOf(OPEN_TOKEN, close); + } + return pluralObjects; +} diff --git a/utils/template-utils.test.js b/utils/template-utils.test.js new file mode 100644 index 000000000..b0d637222 --- /dev/null +++ b/utils/template-utils.test.js @@ -0,0 +1,56 @@ +import { expect } from '@esm-bundle/chai'; +import { applyTemplateData, getPluralObjects } from './template-utils.js'; + +describe('template-utils', () => { + describe('applyTemplateData', () => { + it('should return the same string if no variables passed', () => { + let result = applyTemplateData('Hello world!'); + expect(result).to.equal('Hello world!'); + }); + + it('should replace variables', () => { + let result = applyTemplateData("Hello world! My name is {{name}}. I'm {{age}} years old.", { + name: 'John Doe', + age: 12, + }); + expect(result).to.equal("Hello world! My name is John Doe. I'm 12 years old."); + }); + + it('should work with variables at start/end', () => { + const result = applyTemplateData("{{name}} my name is. I'm {{age}}", { name: 'John Doe', age: 12 }); + expect(result).to.equal("John Doe my name is. I'm 12"); + }); + + it('should work with single variable', () => { + const result = applyTemplateData('{{name}}', { name: 'John Doe' }); + expect(result).to.equal('John Doe'); + }); + + it('should not replace non-defined variabled', () => { + let result = applyTemplateData('My name is {{name}}'); + expect(result).to.equal('My name is {{name}}'); + }); + + it('should accept `transform` option', () => { + let result = applyTemplateData( + 'My name is {{name}}', + { name: 'John Doe' }, + { transform: (value) => value.toUpperCase() } + ); + expect(result).to.equal('My name is JOHN DOE'); + }); + }); + + describe('getPluralObjects', () => { + it('should return array of plural objects', () => { + expect( + getPluralObjects( + 'Uploading {{filesCount}} {{plural:file(filesCount)}} with {{errorsCount}} {{plural:error(errorsCount)}}' + ) + ).to.deep.equal([ + { variable: 'plural:file(filesCount)', pluralKey: 'file', countVariable: 'filesCount' }, + { variable: 'plural:error(errorsCount)', pluralKey: 'error', countVariable: 'errorsCount' }, + ]); + }); + }); +});