diff --git a/abstract/ActivityBlock.js b/abstract/ActivityBlock.js index 6878ed45e..2dbbbc1bc 100644 --- a/abstract/ActivityBlock.js +++ b/abstract/ActivityBlock.js @@ -8,7 +8,9 @@ const ACTIVE_ATTR = 'active'; const ACTIVE_PROP = '___ACTIVITY_IS_ACTIVE___'; export class ActivityBlock extends Block { + /** @protected */ historyTracked = false; + init$ = activityBlockCtx(this); _debouncedHistoryFlush = debounce(this._historyFlush.bind(this), 10); @@ -37,6 +39,7 @@ export class ActivityBlock extends Block { }); } + /** @protected */ initCallback() { super.initCallback(); if (this.hasAttribute('current-activity')) { @@ -131,6 +134,7 @@ export class ActivityBlock extends Block { ActivityBlock._activityCallbacks.delete(this); } + /** @protected */ destroyCallback() { super.destroyCallback(); this._isActivityRegistered() && this.unregisterActivity(); @@ -144,6 +148,7 @@ export class ActivityBlock extends Block { if (!hasCurrentActivityInCtx) { this.$['*currentActivity'] = null; + this.setOrAddState('*modalActive', false); } } @@ -199,4 +204,4 @@ ActivityBlock.activities = Object.freeze({ DETAILS: 'details', }); -/** @typedef {(typeof ActivityBlock)['activities'][keyof (typeof ActivityBlock)['activities']] | null} ActivityType */ +/** @typedef {(typeof ActivityBlock)['activities'][keyof (typeof ActivityBlock)['activities']] | (string & {}) | null} ActivityType */ diff --git a/abstract/Block.js b/abstract/Block.js index f4e68c554..29df50e6b 100644 --- a/abstract/Block.js +++ b/abstract/Block.js @@ -23,8 +23,10 @@ export class Block extends BaseComponent { /** @type {string[]} */ static styleAttrs = []; + + /** @protected */ requireCtxName = false; - allowCustomTemplate = true; + /** @type {import('./ActivityBlock.js').ActivityType} */ activityType = null; @@ -52,6 +54,7 @@ export class Block extends BaseComponent { } /** + * @private * @param {string} key * @param {number} count * @returns {string} @@ -65,6 +68,7 @@ export class Block extends BaseComponent { /** * @param {string} key * @param {() => void} resolver + * @protected */ bindL10n(key, resolver) { this.localeManager?.bindL10n(this, key, resolver); @@ -118,15 +122,7 @@ export class Block extends BaseComponent { ); } - /** @param {import('./ActivityBlock.js').ActivityType} activityType */ - setActivity(activityType) { - if (this.hasBlockInCtx((b) => b.activityType === activityType)) { - this.$['*currentActivity'] = activityType; - return; - } - console.warn(`Activity type "${activityType}" not found in the context`); - } - + /** @protected */ connectedCallback() { const styleAttrs = /** @type {typeof Block} */ (this.constructor).styleAttrs; styleAttrs.forEach((attr) => { @@ -158,11 +154,13 @@ export class Block extends BaseComponent { WindowHeightTracker.registerClient(this); } + /** @protected */ disconnectedCallback() { super.disconnectedCallback(); WindowHeightTracker.unregisterClient(this); } + /** @protected */ initCallback() { if (!this.has('*blocksRegistry')) { this.add('*blocksRegistry', new Set()); @@ -188,12 +186,18 @@ export class Block extends BaseComponent { }); } - /** @returns {LocaleManager | null} */ + /** + * @private + * @returns {LocaleManager | null} + */ get localeManager() { return this.has('*localeManager') ? this.$['*localeManager'] : null; } - /** @returns {A11y | null} */ + /** + * @returns {A11y | null} + * @protected + */ get a11y() { return this.has('*a11y') ? this.$['*a11y'] : null; } @@ -203,9 +207,10 @@ export class Block extends BaseComponent { return this.$['*blocksRegistry']; } + /** @protected */ destroyCallback() { let blocksRegistry = this.blocksRegistry; - blocksRegistry.delete(this); + blocksRegistry?.delete(this); this.localeManager?.destroyL10nBindings(this); this.l10nProcessorSubs = new Map(); @@ -214,7 +219,7 @@ export class Block extends BaseComponent { // TODO: this should be done inside symbiote Data.deleteCtx(this); - if (blocksRegistry.size === 0) { + if (blocksRegistry?.size === 0) { setTimeout(() => { // Destroy global context after all blocks are destroyed and all callbacks are run this.destroyCtxCallback(); @@ -234,28 +239,10 @@ export class Block extends BaseComponent { this.a11y?.destroy(); } - /** - * @param {Number} bytes - * @param {Number} [decimals] - */ - fileSizeFmt(bytes, decimals = 2) { - let units = ['B', 'KB', 'MB', 'GB', 'TB']; - /** - * @param {String} str - * @returns {String} - */ - if (bytes === 0) { - return `0 ${units[0]}`; - } - let k = 1024; - let dm = decimals < 0 ? 0 : decimals; - let i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / k ** i).toFixed(dm)) + ' ' + units[i]; - } - /** * @param {String} url * @returns {String} + * @protected */ proxyUrl(url) { if (this.cfg.secureDeliveryProxy && this.cfg.secureDeliveryProxyUrlResolver) { diff --git a/abstract/CTX.js b/abstract/CTX.js index 520a8abc7..4462de40b 100644 --- a/abstract/CTX.js +++ b/abstract/CTX.js @@ -6,14 +6,14 @@ export const blockCtx = () => ({}); /** @param {import('./Block').Block} fnCtx */ export const activityBlockCtx = (fnCtx) => ({ ...blockCtx(), - '*currentActivity': '', + '*currentActivity': null, '*currentActivityParams': {}, '*history': [], '*historyBack': null, '*closeModal': () => { fnCtx.set$({ + '*currentActivity': null, '*modalActive': false, - '*currentActivity': '', }); }, }); @@ -25,7 +25,6 @@ export const uploaderBlockCtx = (fnCtx) => ({ '*uploadList': [], '*focusedEntry': null, '*uploadQueue': new Queue(1), - '*uploadCollection': null, /** @type {ReturnType[]} */ '*collectionErrors': [], /** @type {import('../types').OutputCollectionState | null} */ diff --git a/abstract/UploaderBlock.js b/abstract/UploaderBlock.js index 1a78b901b..87618d5f1 100644 --- a/abstract/UploaderBlock.js +++ b/abstract/UploaderBlock.js @@ -6,27 +6,26 @@ import { uploadFileGroup } from '@uploadcare/upload-client'; import { calculateMaxCenteredCropFrame } from '../blocks/CloudImageEditor/src/crop-utils.js'; import { parseCropPreset } from '../blocks/CloudImageEditor/src/lib/parseCropPreset.js'; import { EventType } from '../blocks/UploadCtxProvider/EventEmitter.js'; -import { UploadSource } from '../blocks/utils/UploadSource.js'; -import { serializeCsv } from '../blocks/utils/comma-separated.js'; import { debounce } from '../blocks/utils/debounce.js'; import { customUserAgent } from '../blocks/utils/userAgent.js'; import { createCdnUrl, createCdnUrlModifiers } from '../utils/cdn-utils.js'; -import { IMAGE_ACCEPT_LIST, fileIsImage, mergeFileTypes } from '../utils/fileTypes.js'; -import { stringToArray } from '../utils/stringToArray.js'; import { uploaderBlockCtx } from './CTX.js'; -import { TypedCollection } from './TypedCollection.js'; -import { buildOutputCollectionState } from './buildOutputCollectionState.js'; -import { uploadEntrySchema } from './uploadEntrySchema.js'; -import { parseCdnUrl } from '../utils/parseCdnUrl.js'; import { SecureUploadsManager } from './SecureUploadsManager.js'; +import { TypedCollection } from './TypedCollection.js'; +import { UploaderPublicApi } from './UploaderPublicApi.js'; import { ValidationManager } from './ValidationManager.js'; +import { uploadEntrySchema } from './uploadEntrySchema.js'; export class UploaderBlock extends ActivityBlock { + /** @protected */ couldBeCtxOwner = false; + + /** @private */ isCtxOwner = false; init$ = uploaderBlockCtx(this); + /** @private */ get hasCtxOwner() { return this.hasBlockInCtx((block) => { if (block instanceof UploaderBlock) { @@ -36,17 +35,22 @@ export class UploaderBlock extends ActivityBlock { }); } + /** @protected */ initCallback() { super.initCallback(); - if (!this.$['*uploadCollection']) { + if (!this.has('*uploadCollection')) { let uploadCollection = new TypedCollection({ typedSchema: uploadEntrySchema, watchList: ['uploadProgress', 'uploadError', 'fileInfo', 'errors', 'cdnUrl', 'isUploading'], }); - this.$['*uploadCollection'] = uploadCollection; + this.add('*uploadCollection', uploadCollection); } - // + + if (!this.has('*publicApi')) { + this.add('*publicApi', new UploaderPublicApi(this)); + } + if (!this.has('*validationManager')) { this.add('*validationManager', new ValidationManager(this)); } @@ -56,11 +60,38 @@ export class UploaderBlock extends ActivityBlock { } } - /** @returns {ValidationManager | null} */ + /** + * @returns {ValidationManager} + * @protected + */ get validationManager() { - return this.has('*validationManager') ? this.$['*validationManager'] : null; + if (!this.has('*validationManager')) { + throw new Error('Unexpected error: ValidationManager is not initialized'); + } + return this.$['*validationManager']; } + /** @returns {UploaderPublicApi} */ + get api() { + if (!this.has('*publicApi')) { + throw new Error('Unexpected error: UploaderPublicApi is not initialized'); + } + return this.$['*publicApi']; + } + + getAPI() { + return this.api; + } + + /** @returns {TypedCollection} */ + get uploadCollection() { + if (!this.has('*uploadCollection')) { + throw new Error('Unexpected error: TypedCollection is not initialized'); + } + return this.$['*uploadCollection']; + } + + /** @protected */ destroyCtxCallback() { this._unobserveCollectionProperties?.(); this._unobserveCollection?.(); @@ -70,6 +101,7 @@ export class UploaderBlock extends ActivityBlock { super.destroyCtxCallback(); } + /** @private */ initCtxOwner() { this.isCtxOwner = true; @@ -88,210 +120,16 @@ export class UploaderBlock extends ActivityBlock { if (!this.$['*secureUploadsManager']) { this.$['*secureUploadsManager'] = new SecureUploadsManager(this); } - } - - // TODO: Probably we should not allow user to override `source` property - - /** - * @param {string} url - * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] - * @returns {import('../types').OutputFileEntry<'idle'>} - */ - addFileFromUrl(url, { silent, fileName, source } = {}) { - const internalId = this.uploadCollection.add({ - externalUrl: url, - fileName: fileName ?? null, - silent: silent ?? false, - source: source ?? UploadSource.API, - }); - return this.getOutputItem(internalId); - } - - /** - * @param {string} uuid - * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] - * @returns {import('../types').OutputFileEntry<'idle'>} - */ - addFileFromUuid(uuid, { silent, fileName, source } = {}) { - const internalId = this.uploadCollection.add({ - uuid, - fileName: fileName ?? null, - silent: silent ?? false, - source: source ?? UploadSource.API, - }); - return this.getOutputItem(internalId); - } - - /** - * @param {string} cdnUrl - * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] - * @returns {import('../types').OutputFileEntry<'idle'>} - */ - addFileFromCdnUrl(cdnUrl, { silent, fileName, source } = {}) { - const parsedCdnUrl = parseCdnUrl({ url: cdnUrl, cdnBase: this.cfg.cdnCname }); - if (!parsedCdnUrl) { - throw new Error('Invalid CDN URL'); - } - const internalId = this.uploadCollection.add({ - uuid: parsedCdnUrl.uuid, - cdnUrl, - cdnUrlModifiers: parsedCdnUrl.cdnUrlModifiers, - fileName: fileName ?? parsedCdnUrl.filename ?? null, - silent: silent ?? false, - source: source ?? UploadSource.API, - }); - return this.getOutputItem(internalId); - } - - /** - * @param {File} file - * @param {{ silent?: boolean; fileName?: string; source?: string; fullPath?: string }} [options] - * @returns {import('../types').OutputFileEntry<'idle'>} - */ - addFileFromObject(file, { silent, fileName, source, fullPath } = {}) { - const internalId = this.uploadCollection.add({ - file, - isImage: fileIsImage(file), - mimeType: file.type, - fileName: fileName ?? file.name, - fileSize: file.size, - silent: silent ?? false, - source: source ?? UploadSource.API, - fullPath: fullPath ?? null, - }); - return this.getOutputItem(internalId); - } - - /** @param {string} internalId */ - removeFileByInternalId(internalId) { - if (!this.uploadCollection.read(internalId)) { - throw new Error(`File with internalId ${internalId} not found`); - } - this.uploadCollection.remove(internalId); - } - - removeAllFiles() { - this.uploadCollection.clearAll(); - } - - uploadAll = () => { - const itemsToUpload = this.uploadCollection.items().filter((id) => { - const entry = this.uploadCollection.read(id); - return !entry.getValue('isRemoved') && !entry.getValue('isUploading') && !entry.getValue('fileInfo'); - }); - - if (itemsToUpload.length === 0) { - return; - } - this.$['*uploadTrigger'] = new Set(itemsToUpload); - this.emit( - EventType.COMMON_UPLOAD_START, - /** @type {import('../types').OutputCollectionState<'uploading'>} */ (this.getOutputCollectionState()), - ); - }; - - /** @param {{ captureCamera?: boolean }} options */ - openSystemDialog(options = {}) { - let accept = serializeCsv(mergeFileTypes([this.cfg.accept ?? '', ...(this.cfg.imgOnly ? IMAGE_ACCEPT_LIST : [])])); - - if (this.cfg.accept && !!this.cfg.imgOnly) { - console.warn( - 'There could be a mistake.\n' + - 'Both `accept` and `imgOnly` parameters are set.\n' + - 'The value of `accept` will be concatenated with the internal image mime types list.', - ); - } - this.fileInput = document.createElement('input'); - this.fileInput.type = 'file'; - this.fileInput.multiple = this.cfg.multiple; - if (options.captureCamera) { - this.fileInput.capture = this.cfg.cameraCapture; - this.fileInput.accept = 'image/*'; - } else { - this.fileInput.accept = accept; - } - this.fileInput.dispatchEvent(new MouseEvent('click')); - this.fileInput.onchange = () => { - // @ts-ignore TODO: fix this - [...this.fileInput['files']].forEach((file) => - this.addFileFromObject(file, { source: options.captureCamera ? UploadSource.CAMERA : UploadSource.LOCAL }), - ); - // To call uploadTrigger UploadList should draw file items first: - this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; - this.setOrAddState('*modalActive', true); - // @ts-ignore TODO: fix this - this.fileInput['value'] = ''; - this.fileInput = null; - }; - } - - /** @type {string[]} */ - get sourceList() { - /** @type {string[]} */ - let list = []; - if (this.cfg.sourceList) { - list = stringToArray(this.cfg.sourceList); - } - // @ts-ignore TODO: fix this - return list; - } - - /** @param {Boolean} [force] */ - initFlow(force = false) { - if (this.uploadCollection.size > 0 && !force) { - this.set$({ - '*currentActivity': ActivityBlock.activities.UPLOAD_LIST, - }); - this.setOrAddState('*modalActive', true); - } else { - if (this.sourceList?.length === 1) { - const srcKey = this.sourceList[0]; - - // TODO: We should refactor those handlers - if (srcKey === 'local') { - this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; - this?.['openSystemDialog'](); - return; - } - - const blocksRegistry = this.blocksRegistry; - /** - * @param {import('./Block').Block} block - * @returns {block is import('../blocks/SourceBtn/SourceBtn.js').SourceBtn} - */ - const isSourceBtn = (block) => 'type' in block && block.type === srcKey; - const sourceBtnBlock = [...blocksRegistry].find(isSourceBtn); - // TODO: This is weird that we have this logic inside UI component, we should consider to move it somewhere else - sourceBtnBlock?.activate(); - if (this.$['*currentActivity']) { - this.setOrAddState('*modalActive', true); + if (this.has('*modalActive')) { + this.sub('*modalActive', (modalActive) => { + if (modalActive && !this.$['*currentActivity']) { + this.$['*modalActive'] = false; } - } else { - // Multiple sources case: - this.set$({ - '*currentActivity': ActivityBlock.activities.START_FROM, - }); - this.setOrAddState('*modalActive', true); - } - } - } - - doneFlow() { - this.set$({ - '*currentActivity': this.doneActivity, - '*history': this.doneActivity ? [this.doneActivity] : [], - }); - if (!this.$['*currentActivity']) { - this.setOrAddState('*modalActive', false); + }); } } - /** @returns {TypedCollection} */ - get uploadCollection() { - return this.$['*uploadCollection']; - } - /** * @private * @param {import('../types').OutputCollectionState} collectionState @@ -309,10 +147,10 @@ export class UploaderBlock extends ActivityBlock { } this.$['*groupInfo'] = resp; const collectionStateWithGroup = /** @type {import('../types').OutputCollectionState<'success', 'has-group'>} */ ( - this.getOutputCollectionState() + this.api.getOutputCollectionState() ); this.emit(EventType.GROUP_CREATED, collectionStateWithGroup); - this.emit(EventType.CHANGE, () => this.getOutputCollectionState(), { debounce: true }); + this.emit(EventType.CHANGE, () => this.api.getOutputCollectionState(), { debounce: true }); this.$['*collectionState'] = collectionStateWithGroup; } @@ -322,9 +160,9 @@ export class UploaderBlock extends ActivityBlock { if (data.length !== this.uploadCollection.size) { return; } - const collectionState = this.getOutputCollectionState(); + const collectionState = this.api.getOutputCollectionState(); this.$['*collectionState'] = collectionState; - this.emit(EventType.CHANGE, () => this.getOutputCollectionState(), { debounce: true }); + this.emit(EventType.CHANGE, () => this.api.getOutputCollectionState(), { debounce: true }); if (this.cfg.groupOutput && collectionState.totalCount > 0 && collectionState.status === 'success') { this._createGroup(collectionState); @@ -340,14 +178,12 @@ export class UploaderBlock extends ActivityBlock { if (added.size || removed.size) { this.$['*groupInfo'] = null; } - if (this.validationManager) { - this.validationManager.runFileValidators(); - this.validationManager.runCollectionValidators(); - } + this.validationManager.runFileValidators(); + this.validationManager.runCollectionValidators(); for (const entry of added) { if (!entry.getValue('silent')) { - this.emit(EventType.FILE_ADDED, this.getOutputItem(entry.uid)); + this.emit(EventType.FILE_ADDED, this.api.getOutputItem(entry.uid)); } } @@ -362,7 +198,7 @@ export class UploaderBlock extends ActivityBlock { uploadProgress: 0, }); URL.revokeObjectURL(entry?.getValue('thumbUrl')); - this.emit(EventType.FILE_REMOVED, this.getOutputItem(entry.uid)); + this.emit(EventType.FILE_REMOVED, this.api.getOutputItem(entry.uid)); } this.$['*uploadList'] = entries.map((uid) => { @@ -393,14 +229,14 @@ export class UploaderBlock extends ActivityBlock { entriesToRunValidation.length > 0 && setTimeout(() => { // We can't modify entry properties in the same tick, so we need to wait a bit - if (this.validationManager) this.validationManager.runFileValidators(entriesToRunValidation); + this.validationManager.runFileValidators(entriesToRunValidation); }); if (changeMap.uploadProgress) { for (const entryId of changeMap.uploadProgress) { const { isUploading, silent } = Data.getCtx(entryId).store; if (isUploading && !silent) { - this.emit(EventType.FILE_UPLOAD_PROGRESS, this.getOutputItem(entryId)); + this.emit(EventType.FILE_UPLOAD_PROGRESS, this.api.getOutputItem(entryId)); } } @@ -410,7 +246,7 @@ export class UploaderBlock extends ActivityBlock { for (const entryId of changeMap.isUploading) { const { isUploading, silent } = Data.getCtx(entryId).store; if (isUploading && !silent) { - this.emit(EventType.FILE_UPLOAD_START, this.getOutputItem(entryId)); + this.emit(EventType.FILE_UPLOAD_START, this.api.getOutputItem(entryId)); } } } @@ -418,44 +254,50 @@ export class UploaderBlock extends ActivityBlock { for (const entryId of changeMap.fileInfo) { const { fileInfo, silent } = Data.getCtx(entryId).store; if (fileInfo && !silent) { - this.emit(EventType.FILE_UPLOAD_SUCCESS, this.getOutputItem(entryId)); + this.emit(EventType.FILE_UPLOAD_SUCCESS, this.api.getOutputItem(entryId)); } } if (this.cfg.cropPreset) { this.setInitialCrop(); } - let loadedItems = uploadCollection.findItems((entry) => { - return !!entry.getValue('fileInfo'); - }); - let errorItems = uploadCollection.findItems((entry) => { - return entry.getValue('errors').length > 0; - }); - if (uploadCollection.size > 0 && errorItems.length === 0 && uploadCollection.size === loadedItems.length) { - this.emit( - EventType.COMMON_UPLOAD_SUCCESS, - /** @type {import('../types').OutputCollectionState<'success'>} */ (this.getOutputCollectionState()), - ); - } } if (changeMap.errors) { for (const entryId of changeMap.errors) { const { errors } = Data.getCtx(entryId).store; if (errors.length > 0) { - this.emit(EventType.FILE_UPLOAD_FAILED, this.getOutputItem(entryId)); + this.emit(EventType.FILE_UPLOAD_FAILED, this.api.getOutputItem(entryId)); this.emit( EventType.COMMON_UPLOAD_FAILED, - () => /** @type {import('../types').OutputCollectionState<'failed'>} */ (this.getOutputCollectionState()), + () => + /** @type {import('../types').OutputCollectionState<'failed'>} */ (this.api.getOutputCollectionState()), { debounce: true }, ); } } + const loadedItems = uploadCollection.findItems((entry) => { + return !!entry.getValue('fileInfo'); + }); + const errorItems = uploadCollection.findItems((entry) => { + return entry.getValue('errors').length > 0; + }); + if ( + uploadCollection.size > 0 && + errorItems.length === 0 && + uploadCollection.size === loadedItems.length && + this.$['*collectionErrors'].length === 0 + ) { + this.emit( + EventType.COMMON_UPLOAD_SUCCESS, + /** @type {import('../types').OutputCollectionState<'success'>} */ (this.api.getOutputCollectionState()), + ); + } } if (changeMap.cdnUrl) { const uids = [...changeMap.cdnUrl].filter((uid) => { return !!this.uploadCollection.read(uid)?.getValue('cdnUrl'); }); uids.forEach((uid) => { - this.emit(EventType.FILE_URL_CHANGED, this.getOutputItem(uid)); + this.emit(EventType.FILE_URL_CHANGED, this.api.getOutputItem(uid)); }); this.$['*groupInfo'] = null; @@ -481,7 +323,7 @@ export class UploaderBlock extends ActivityBlock { this.$['*commonProgress'] = progress; this.emit( EventType.COMMON_UPLOAD_PROGRESS, - /** @type {import('../types').OutputCollectionState<'uploading'>} */ (this.getOutputCollectionState()), + /** @type {import('../types').OutputCollectionState<'uploading'>} */ (this.api.getOutputCollectionState()), ); }; @@ -532,14 +374,17 @@ export class UploaderBlock extends ActivityBlock { async getMetadataFor(entryId) { const configValue = this.cfg.metadata || undefined; if (typeof configValue === 'function') { - const outputFileEntry = this.getOutputItem(entryId); + const outputFileEntry = this.api.getOutputItem(entryId); const metadata = await configValue(outputFileEntry); return metadata; } return configValue; } - /** @returns {Promise} */ + /** + * @returns {Promise} + * @protected + */ async getUploadClientOptions() { /** @type {SecureUploadsManager} */ const secureUploadsManager = this.$['*secureUploadsManager']; @@ -566,69 +411,12 @@ export class UploaderBlock extends ActivityBlock { return options; } - /** - * @template {import('../types').OutputFileStatus} TStatus - * @param {string} entryId - * @returns {import('../types/exported.js').OutputFileEntry} - */ - getOutputItem(entryId) { - const uploadEntryData = /** @type {import('./uploadEntrySchema.js').UploadEntry} */ (Data.getCtx(entryId).store); - - /** @type {import('@uploadcare/upload-client').UploadcareFile?} */ - const fileInfo = uploadEntryData.fileInfo; - - /** @type {import('../types').OutputFileEntry['status']} */ - let status = uploadEntryData.isRemoved - ? 'removed' - : uploadEntryData.errors.length > 0 - ? 'failed' - : !!uploadEntryData.fileInfo - ? 'success' - : uploadEntryData.isUploading - ? 'uploading' - : 'idle'; - - /** @type {unknown} */ - const outputItem = { - uuid: fileInfo?.uuid ?? uploadEntryData.uuid ?? null, - internalId: entryId, - name: fileInfo?.originalFilename ?? uploadEntryData.fileName, - size: fileInfo?.size ?? uploadEntryData.fileSize, - isImage: fileInfo?.isImage ?? uploadEntryData.isImage, - mimeType: fileInfo?.mimeType ?? uploadEntryData.mimeType, - file: uploadEntryData.file, - externalUrl: uploadEntryData.externalUrl, - cdnUrlModifiers: uploadEntryData.cdnUrlModifiers, - cdnUrl: uploadEntryData.cdnUrl ?? fileInfo?.cdnUrl ?? null, - fullPath: uploadEntryData.fullPath, - uploadProgress: uploadEntryData.uploadProgress, - fileInfo: fileInfo ?? null, - metadata: uploadEntryData.metadata ?? fileInfo?.metadata ?? null, - isSuccess: status === 'success', - isUploading: status === 'uploading', - isFailed: status === 'failed', - isRemoved: status === 'removed', - errors: /** @type {import('../types/exported.js').OutputFileEntry['errors']} */ (uploadEntryData.errors), - status, - }; - - return /** @type {import('../types/exported.js').OutputFileEntry} */ (outputItem); - } - - /** - * @param {(item: import('./TypedData.js').TypedData) => Boolean} [checkFn] - * @returns {import('../types/exported.js').OutputFileEntry[]} - */ - getOutputData(checkFn) { - const entriesIds = checkFn ? this.uploadCollection.findItems(checkFn) : this.uploadCollection.items(); - const data = entriesIds.map((itemId) => this.getOutputItem(itemId)); + /** @returns {import('../types/exported.js').OutputFileEntry[]} */ + getOutputData() { + const entriesIds = this.uploadCollection.items(); + const data = entriesIds.map((itemId) => this.api.getOutputItem(itemId)); return data; } - - /** @template {import('../types').OutputCollectionStatus} TStatus */ - getOutputCollectionState() { - return /** @type {ReturnType>} */ (buildOutputCollectionState(this)); - } } /** @enum {String} */ diff --git a/abstract/UploaderPublicApi.js b/abstract/UploaderPublicApi.js new file mode 100644 index 000000000..daef7e8a9 --- /dev/null +++ b/abstract/UploaderPublicApi.js @@ -0,0 +1,316 @@ +// @ts-check +import { ActivityBlock } from './ActivityBlock.js'; + +import { Data } from '@symbiotejs/symbiote'; +import { EventType } from '../blocks/UploadCtxProvider/EventEmitter.js'; +import { UploadSource } from '../blocks/utils/UploadSource.js'; +import { serializeCsv } from '../blocks/utils/comma-separated.js'; +import { IMAGE_ACCEPT_LIST, fileIsImage, mergeFileTypes } from '../utils/fileTypes.js'; +import { parseCdnUrl } from '../utils/parseCdnUrl.js'; +import { buildOutputCollectionState } from './buildOutputCollectionState.js'; +import { stringToArray } from '../utils/stringToArray.js'; + +export class UploaderPublicApi { + /** + * @private + * @type {import('./UploaderBlock.js').UploaderBlock} + */ + _ctx; + + /** @param {import('./UploaderBlock.js').UploaderBlock} ctx */ + constructor(ctx) { + this._ctx = ctx; + } + + /** @private */ + get _uploadCollection() { + return this._ctx.uploadCollection; + } + + get cfg() { + return this._ctx.cfg; + } + + get l10n() { + return this._ctx.l10n.bind(this._ctx); + } + + /** + * TODO: Probably we should not allow user to override `source` property + * + * @param {string} url + * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] + * @returns {import('../types').OutputFileEntry<'idle'>} + */ + addFileFromUrl = (url, { silent, fileName, source } = {}) => { + const internalId = this._uploadCollection.add({ + externalUrl: url, + fileName: fileName ?? null, + silent: silent ?? false, + source: source ?? UploadSource.API, + }); + return this.getOutputItem(internalId); + }; + + /** + * @param {string} uuid + * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] + * @returns {import('../types').OutputFileEntry<'idle'>} + */ + addFileFromUuid = (uuid, { silent, fileName, source } = {}) => { + const internalId = this._uploadCollection.add({ + uuid, + fileName: fileName ?? null, + silent: silent ?? false, + source: source ?? UploadSource.API, + }); + return this.getOutputItem(internalId); + }; + + /** + * @param {string} cdnUrl + * @param {{ silent?: boolean; fileName?: string; source?: string }} [options] + * @returns {import('../types').OutputFileEntry<'idle'>} + */ + addFileFromCdnUrl = (cdnUrl, { silent, fileName, source } = {}) => { + const parsedCdnUrl = parseCdnUrl({ url: cdnUrl, cdnBase: this.cfg.cdnCname }); + if (!parsedCdnUrl) { + throw new Error('Invalid CDN URL'); + } + const internalId = this._uploadCollection.add({ + uuid: parsedCdnUrl.uuid, + cdnUrl, + cdnUrlModifiers: parsedCdnUrl.cdnUrlModifiers, + fileName: fileName ?? parsedCdnUrl.filename ?? null, + silent: silent ?? false, + source: source ?? UploadSource.API, + }); + return this.getOutputItem(internalId); + }; + + /** + * @param {File} file + * @param {{ silent?: boolean; fileName?: string; source?: string; fullPath?: string }} [options] + * @returns {import('../types').OutputFileEntry<'idle'>} + */ + addFileFromObject = (file, { silent, fileName, source, fullPath } = {}) => { + const internalId = this._uploadCollection.add({ + file, + isImage: fileIsImage(file), + mimeType: file.type, + fileName: fileName ?? file.name, + fileSize: file.size, + silent: silent ?? false, + source: source ?? UploadSource.API, + fullPath: fullPath ?? null, + }); + return this.getOutputItem(internalId); + }; + + /** @param {string} internalId */ + removeFileByInternalId = (internalId) => { + if (!this._uploadCollection.read(internalId)) { + throw new Error(`File with internalId ${internalId} not found`); + } + this._uploadCollection.remove(internalId); + }; + + removeAllFiles() { + this._uploadCollection.clearAll(); + } + + uploadAll = () => { + const itemsToUpload = this._uploadCollection.items().filter((id) => { + const entry = this._uploadCollection.read(id); + return !entry.getValue('isRemoved') && !entry.getValue('isUploading') && !entry.getValue('fileInfo'); + }); + + if (itemsToUpload.length === 0) { + return; + } + + this._ctx.$['*uploadTrigger'] = new Set(itemsToUpload); + this._ctx.emit( + EventType.COMMON_UPLOAD_START, + /** @type {import('../types').OutputCollectionState<'uploading'>} */ (this.getOutputCollectionState()), + ); + }; + + /** @param {{ captureCamera?: boolean }} options */ + openSystemDialog = (options = {}) => { + let accept = serializeCsv(mergeFileTypes([this.cfg.accept ?? '', ...(this.cfg.imgOnly ? IMAGE_ACCEPT_LIST : [])])); + + if (this.cfg.accept && !!this.cfg.imgOnly) { + console.warn( + 'There could be a mistake.\n' + + 'Both `accept` and `imgOnly` parameters are set.\n' + + 'The value of `accept` will be concatenated with the internal image mime types list.', + ); + } + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.multiple = this.cfg.multiple; + if (options.captureCamera) { + fileInput.capture = this.cfg.cameraCapture; + fileInput.accept = 'image/*'; + } else { + fileInput.accept = accept; + } + fileInput.dispatchEvent(new MouseEvent('click')); + fileInput.onchange = () => { + // @ts-ignore TODO: fix this + [...fileInput['files']].forEach((file) => + this.addFileFromObject(file, { source: options.captureCamera ? UploadSource.CAMERA : UploadSource.LOCAL }), + ); + // To call uploadTrigger UploadList should draw file items first: + this._ctx.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; + this._ctx.setOrAddState('*modalActive', true); + // @ts-ignore TODO: fix this + fileInput['value'] = ''; + }; + }; + + /** + * @template {import('../types').OutputFileStatus} TStatus + * @param {string} entryId + * @returns {import('../types/exported.js').OutputFileEntry} + */ + getOutputItem = (entryId) => { + const uploadEntryData = /** @type {import('./uploadEntrySchema.js').UploadEntry} */ (Data.getCtx(entryId).store); + + /** @type {import('@uploadcare/upload-client').UploadcareFile?} */ + const fileInfo = uploadEntryData.fileInfo; + + /** @type {import('../types').OutputFileEntry['status']} */ + let status = uploadEntryData.isRemoved + ? 'removed' + : uploadEntryData.errors.length > 0 + ? 'failed' + : !!uploadEntryData.fileInfo + ? 'success' + : uploadEntryData.isUploading + ? 'uploading' + : 'idle'; + + /** @type {unknown} */ + const outputItem = { + uuid: fileInfo?.uuid ?? uploadEntryData.uuid ?? null, + internalId: entryId, + name: fileInfo?.originalFilename ?? uploadEntryData.fileName, + size: fileInfo?.size ?? uploadEntryData.fileSize, + isImage: fileInfo?.isImage ?? uploadEntryData.isImage, + mimeType: fileInfo?.mimeType ?? uploadEntryData.mimeType, + file: uploadEntryData.file, + externalUrl: uploadEntryData.externalUrl, + cdnUrlModifiers: uploadEntryData.cdnUrlModifiers, + cdnUrl: uploadEntryData.cdnUrl ?? fileInfo?.cdnUrl ?? null, + fullPath: uploadEntryData.fullPath, + uploadProgress: uploadEntryData.uploadProgress, + fileInfo: fileInfo ?? null, + metadata: uploadEntryData.metadata ?? fileInfo?.metadata ?? null, + isSuccess: status === 'success', + isUploading: status === 'uploading', + isFailed: status === 'failed', + isRemoved: status === 'removed', + errors: /** @type {import('../types/exported.js').OutputFileEntry['errors']} */ (uploadEntryData.errors), + status, + }; + + return /** @type {import('../types/exported.js').OutputFileEntry} */ (outputItem); + }; + + /** @template {import('../types').OutputCollectionStatus} TStatus */ + getOutputCollectionState = () => { + return /** @type {ReturnType>} */ ( + buildOutputCollectionState(this._ctx) + ); + }; + + /** @param {Boolean} [force] */ + initFlow = (force = false) => { + if (this._uploadCollection.size > 0 && !force) { + this._ctx.set$({ + '*currentActivity': ActivityBlock.activities.UPLOAD_LIST, + }); + this._ctx.setOrAddState('*modalActive', true); + } else { + if (this._sourceList?.length === 1) { + const srcKey = this._sourceList[0]; + + // TODO: We should refactor those handlers + if (srcKey === 'local') { + this._ctx.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; + this.openSystemDialog(); + return; + } + + /** @type {Set} */ + const blocksRegistry = this._ctx.$['*blocksRegistry']; + /** + * @param {import('./Block').Block} block + * @returns {block is import('../blocks/SourceBtn/SourceBtn.js').SourceBtn} + */ + const isSourceBtn = (block) => 'type' in block && block.type === srcKey; + const sourceBtnBlock = [...blocksRegistry].find(isSourceBtn); + // TODO: This is weird that we have this logic inside UI component, we should consider to move it somewhere else + sourceBtnBlock?.activate(); + if (this._ctx.$['*currentActivity']) { + this._ctx.setOrAddState('*modalActive', true); + } + } else { + // Multiple sources case: + this._ctx.set$({ + '*currentActivity': ActivityBlock.activities.START_FROM, + }); + this._ctx.setOrAddState('*modalActive', true); + } + } + }; + + doneFlow = () => { + this._ctx.set$({ + '*currentActivity': this._ctx.doneActivity, + '*history': this._ctx.doneActivity ? [this._ctx.doneActivity] : [], + }); + if (!this._ctx.$['*currentActivity']) { + this._ctx.setOrAddState('*modalActive', false); + } + }; + + /** + * @param {import('./ActivityBlock.js').ActivityType} activityType + * @param {import('../blocks/ExternalSource/ExternalSource.js').ActivityParams | {}} [params] + */ + setCurrentActivity = (activityType, params = {}) => { + if (this._ctx.hasBlockInCtx((b) => b.activityType === activityType)) { + this._ctx.set$({ + '*currentActivityParams': params, + '*currentActivity': activityType, + }); + return; + } + console.warn(`Activity type "${activityType}" not found in the context`); + }; + + /** @param {boolean} opened */ + setModalState = (opened) => { + if (opened && !this._ctx.$['*currentActivity']) { + console.warn(`Can't open modal without current activity. Please use "setCurrentActivity" method first.`); + return; + } + this._ctx.setOrAddState('*modalActive', opened); + }; + + /** + * @private + * @type {string[]} + */ + get _sourceList() { + /** @type {string[]} */ + let list = []; + if (this.cfg.sourceList) { + list = stringToArray(this.cfg.sourceList); + } + return list; + } +} diff --git a/abstract/ValidationManager.js b/abstract/ValidationManager.js index 045573bfa..0574a15dd 100644 --- a/abstract/ValidationManager.js +++ b/abstract/ValidationManager.js @@ -11,7 +11,7 @@ import { validateMultiple, validateCollectionUploadError } from '../utils/valida /** * @typedef {( * outputEntry: import('../types').OutputFileEntry, - * ctx: import('./UploaderBlock.js').UploaderBlock, + * api: import('./UploaderPublicApi.js').UploaderPublicApi, * ) => undefined | import('../types').OutputErrorFile} FuncFileValidator */ @@ -22,7 +22,7 @@ import { validateMultiple, validateCollectionUploadError } from '../utils/valida * import('../types').OutputCollectionStatus * > * >, - * ctx: import('./UploaderBlock.js').UploaderBlock, + * api: import('./UploaderPublicApi.js').UploaderPublicApi, * ) => undefined | import('../types').OutputErrorCollection} FuncCollectionValidator */ @@ -76,7 +76,7 @@ export class ValidationManager { } runCollectionValidators() { - const collection = this._blockInstance.getOutputCollectionState(); + const collection = this._blockInstance.api.getOutputCollectionState(); const errors = []; for (const validator of [ @@ -84,7 +84,7 @@ export class ValidationManager { ...this._addCustomTypeToValidators(this._blockInstance.cfg.collectionValidators), ]) { try { - const errorOrErrors = validator(collection, this._blockInstance); + const errorOrErrors = validator(collection, this._blockInstance.api); if (!errorOrErrors) { continue; } @@ -107,7 +107,7 @@ export class ValidationManager { EventType.COMMON_UPLOAD_FAILED, () => /** @type {import('../types').OutputCollectionState<'failed'>} */ ( - this._blockInstance.getOutputCollectionState() + this._blockInstance.api.getOutputCollectionState() ), { debounce: true }, ); @@ -119,7 +119,7 @@ export class ValidationManager { * @param {import('./TypedData.js').TypedData} entry */ _runFileValidatorsForEntry(entry) { - const outputEntry = this._blockInstance.getOutputItem(entry.uid); + const outputEntry = this._blockInstance.api.getOutputItem(entry.uid); const errors = []; for (const validator of [ @@ -127,7 +127,7 @@ export class ValidationManager { ...this._addCustomTypeToValidators(this._blockInstance.cfg.fileValidators), ]) { try { - const error = validator(outputEntry, this._blockInstance); + const error = validator(outputEntry, this._blockInstance.api); if (!error) { continue; } diff --git a/blocks/CameraSource/CameraSource.js b/blocks/CameraSource/CameraSource.js index af01923aa..a47be2ad3 100644 --- a/blocks/CameraSource/CameraSource.js +++ b/blocks/CameraSource/CameraSource.js @@ -171,7 +171,7 @@ export class CameraSource extends UploaderBlock { lastModified: date, type: format, }); - this.addFileFromObject(file, { source: UploadSource.CAMERA }); + this.api.addFileFromObject(file, { source: UploadSource.CAMERA }); this.set$({ '*currentActivity': ActivityBlock.activities.UPLOAD_LIST, }); diff --git a/blocks/CloudImageEditor/src/svg-sprite.js b/blocks/CloudImageEditor/src/svg-sprite.js index ac2f498be..832ac3c9a 100644 --- a/blocks/CloudImageEditor/src/svg-sprite.js +++ b/blocks/CloudImageEditor/src/svg-sprite.js @@ -1 +1 @@ -export default ""; \ No newline at end of file +export default ""; diff --git a/blocks/DropArea/DropArea.js b/blocks/DropArea/DropArea.js index 4106d125c..20a2113a5 100644 --- a/blocks/DropArea/DropArea.js +++ b/blocks/DropArea/DropArea.js @@ -109,9 +109,9 @@ export class DropArea extends UploaderBlock { items.forEach((/** @type {import('./getDropItems.js').DropItem} */ item) => { if (item.type === 'url') { - this.addFileFromUrl(item.url, { source: UploadSource.DROP_AREA }); + this.api.addFileFromUrl(item.url, { source: UploadSource.DROP_AREA }); } else if (item.type === 'file') { - this.addFileFromObject(item.file, { source: UploadSource.DROP_AREA, fullPath: item.fullPath }); + this.api.addFileFromObject(item.file, { source: UploadSource.DROP_AREA, fullPath: item.fullPath }); } }); if (this.uploadCollection.size) { @@ -173,10 +173,10 @@ export class DropArea extends UploaderBlock { if (event.type === 'keydown') { // @ts-ignore if (event.code === 'Space' || event.code === 'Enter') { - this.openSystemDialog(); + this.api.openSystemDialog(); } } else if (event.type === 'click') { - this.openSystemDialog(); + this.api.openSystemDialog(); } }; diff --git a/blocks/ExternalSource/ExternalSource.js b/blocks/ExternalSource/ExternalSource.js index 871e63675..87c7e417d 100644 --- a/blocks/ExternalSource/ExternalSource.js +++ b/blocks/ExternalSource/ExternalSource.js @@ -49,7 +49,7 @@ export class ExternalSource extends UploaderBlock { const url = this.extractUrlFromMessage(message); const { filename } = message; const { externalSourceType } = this.activityParams; - this.addFileFromUrl(url, { fileName: filename, source: externalSourceType }); + this.api.addFileFromUrl(url, { fileName: filename, source: externalSourceType }); } this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; @@ -72,6 +72,13 @@ export class ExternalSource extends UploaderBlock { onActivate: () => { let { externalSourceType } = /** @type {ActivityParams} */ (this.activityParams); + if (!externalSourceType) { + this.$['*currentActivity'] = null; + this.setOrAddState('*modalActive', false); + console.error(`Param "externalSourceType" is required for activity "${this.activityType}"`); + return; + } + this.set$({ activityCaption: `${externalSourceType?.[0].toUpperCase()}${externalSourceType?.slice(1)}`, activityIcon: externalSourceType, @@ -80,6 +87,13 @@ export class ExternalSource extends UploaderBlock { this.mountIframe(); }, }); + this.sub('*currentActivityParams', (val) => { + if (!this.isActivityActive) { + return; + } + this.unmountIframe(); + this.mountIframe(); + }); this.sub('*currentActivity', (val) => { if (val !== this.activityType) { this.unmountIframe(); diff --git a/blocks/FileItem/file-item.css b/blocks/FileItem/file-item.css index 2a800c41e..43fd862d6 100644 --- a/blocks/FileItem/file-item.css +++ b/blocks/FileItem/file-item.css @@ -3,10 +3,8 @@ lr-file-item { --uc-file-item-height: calc(var(--uc-preview-size) + var(--uc-padding) * 2 + var(--uc-file-item-gap)); display: block; - content-visibility: auto; - height: var(--uc-file-item-height); - contain-intrinsic-size: auto var(--uc-file-item-height); overflow: hidden; + min-height: var(--uc-file-item-height); } lr-file-item:last-of-type { @@ -52,6 +50,7 @@ lr-file-item .thumb { } lr-file-item .file-name-wrapper { + text-align: left; display: flex; flex-direction: column; align-items: flex-start; diff --git a/blocks/SimpleBtn/SimpleBtn.js b/blocks/SimpleBtn/SimpleBtn.js index 236ea9668..828f7bb08 100644 --- a/blocks/SimpleBtn/SimpleBtn.js +++ b/blocks/SimpleBtn/SimpleBtn.js @@ -12,7 +12,7 @@ export class SimpleBtn extends UploaderBlock { ...this.init$, withDropZone: true, onClick: () => { - this.initFlow(); + this.api.initFlow(); }, 'button-text': '', }; diff --git a/blocks/SourceBtn/SourceBtn.js b/blocks/SourceBtn/SourceBtn.js index c0c514cee..eb0fad35a 100644 --- a/blocks/SourceBtn/SourceBtn.js +++ b/blocks/SourceBtn/SourceBtn.js @@ -39,7 +39,7 @@ export class SourceBtn extends UploaderBlock { this.registerType({ type: UploaderBlock.sourceTypes.LOCAL, activate: () => { - this.openSystemDialog(); + this.api.openSystemDialog(); return false; }, }); @@ -54,7 +54,7 @@ export class SourceBtn extends UploaderBlock { activate: () => { const supportsCapture = 'capture' in document.createElement('input'); if (supportsCapture) { - this.openSystemDialog({ captureCamera: true }); + this.api.openSystemDialog({ captureCamera: true }); } return !supportsCapture; }, diff --git a/blocks/UploadList/UploadList.js b/blocks/UploadList/UploadList.js index 57daa1345..8813f81fe 100644 --- a/blocks/UploadList/UploadList.js +++ b/blocks/UploadList/UploadList.js @@ -35,16 +35,16 @@ export class UploadList extends UploaderBlock { hasFiles: false, onAdd: () => { - this.initFlow(true); + this.api.initFlow(true); }, onUpload: () => { this.emit(EventType.UPLOAD_CLICK); - this.uploadAll(); + this.api.uploadAll(); this._throttledHandleCollectionUpdate(); }, onDone: () => { - this.emit(EventType.DONE_CLICK, this.getOutputCollectionState()); - this.doneFlow(); + this.emit(EventType.DONE_CLICK, this.api.getOutputCollectionState()); + this.api.doneFlow(); }, onCancel: () => { this.uploadCollection.clearAll(); @@ -66,7 +66,7 @@ export class UploadList extends UploaderBlock { /** @private */ _updateUploadsState() { - const collectionState = this.getOutputCollectionState(); + const collectionState = this.api.getOutputCollectionState(); /** @type {Summary} */ const summary = { total: collectionState.totalCount, @@ -163,7 +163,7 @@ export class UploadList extends UploaderBlock { }); if (!this.cfg.confirmUpload) { - this.uploadAll(); + this.api.uploadAll(); } }, false, diff --git a/blocks/UrlSource/UrlSource.js b/blocks/UrlSource/UrlSource.js index d71d79008..255a22ab9 100644 --- a/blocks/UrlSource/UrlSource.js +++ b/blocks/UrlSource/UrlSource.js @@ -13,7 +13,7 @@ export class UrlSource extends UploaderBlock { e.preventDefault(); let url = this.ref.input['value']; - this.addFileFromUrl(url, { source: UploadSource.URL_TAB }); + this.api.addFileFromUrl(url, { source: UploadSource.URL_TAB }); this.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; }, onCancel: () => { diff --git a/blocks/themes/lr-basic/svg-sprite.js b/blocks/themes/lr-basic/svg-sprite.js index c5906653f..f67fa0a4e 100644 --- a/blocks/themes/lr-basic/svg-sprite.js +++ b/blocks/themes/lr-basic/svg-sprite.js @@ -1 +1 @@ -export default ""; \ No newline at end of file +export default ""; diff --git a/build-svg-sprite.js b/build-svg-sprite.js index 64d3580f1..36d2e1f1c 100644 --- a/build-svg-sprite.js +++ b/build-svg-sprite.js @@ -69,7 +69,9 @@ DATA.forEach((item) => { throw error; } - const jsTemplate = `export default "${result.symbol.sprite.contents.toString().replace(/\"/g, "'")}";`; + const jsTemplate = `export default "${result.symbol.sprite.contents.toString().replace(/\"/g, "'")}";` + .trim() + .concat('\n'); fs.writeFileSync(item.output, jsTemplate); }); diff --git a/types/exported.d.ts b/types/exported.d.ts index ba034a1d6..5ea490df0 100644 --- a/types/exported.d.ts +++ b/types/exported.d.ts @@ -3,6 +3,7 @@ import type { complexConfigKeys } from '../blocks/Config/Config'; import type { FuncFileValidator, FuncCollectionValidator } from '../abstract/ValidationManager'; export type { FuncFileValidator, FuncCollectionValidator } from '../abstract/ValidationManager'; +export type { UploaderPublicApi } from '../abstract/UploaderPublicApi'; export type UploadError = import('@uploadcare/upload-client').UploadError; export type UploadcareFile = import('@uploadcare/upload-client').UploadcareFile; diff --git a/types/test/lr-config.test-d.tsx b/types/test/lr-config.test-d.tsx index f5e8ca914..584f2e502 100644 --- a/types/test/lr-config.test-d.tsx +++ b/types/test/lr-config.test-d.tsx @@ -75,13 +75,13 @@ import { OutputFileEntry, FuncCollectionValidator, FuncFileValidator } from '../ if (ref.current) { const config = ref.current; - const maxSize: FuncFileValidator = (outputEntry, block) => ({ - message: block.l10n('images-only-accepted'), + const maxSize: FuncFileValidator = (outputEntry, api) => ({ + message: api.l10n('images-only-accepted'), payload: { entry: outputEntry }, }) - const maxCollection: FuncCollectionValidator = (collection, block) => ({ - message: block.l10n('some-files-were-not-uploaded'), + const maxCollection: FuncCollectionValidator = (collection, api) => ({ + message: api.l10n('some-files-were-not-uploaded'), }) config.fileValidators = [maxSize] diff --git a/types/test/lr-upload-ctx-provider.test-d.tsx b/types/test/lr-upload-ctx-provider.test-d.tsx index 994d39d8b..f6ea3c6bc 100644 --- a/types/test/lr-upload-ctx-provider.test-d.tsx +++ b/types/test/lr-upload-ctx-provider.test-d.tsx @@ -14,11 +14,12 @@ import { useRef } from 'react'; import { UploadcareFile, UploadcareGroup } from '@uploadcare/upload-client'; const instance = new UploadCtxProvider(); - -instance.addFileFromUrl('https://example.com/image.png'); instance.uploadCollection.size; instance.setOrAddState('fileId', 'uploading'); +const api = instance.getAPI(); +api.addFileFromUrl('https://example.com/image.png'); + instance.addEventListener('change', (e) => { expectType(e); }); @@ -42,7 +43,7 @@ instance.addEventListener('change', (e) => { expectType(state.isFailed); expectType(state.isUploading); expectType<[]>(state.errors); - expectType<'success'>(state.allEntries[0].status) + expectType<'success'>(state.allEntries[0].status); } else if (state.isFailed) { expectType<'failed'>(state.status); expectType(state.isSuccess); @@ -61,7 +62,7 @@ instance.addEventListener('change', (e) => { expectType(state.isFailed); expectType(state.isUploading); expectType<[]>(state.errors); - expectType<'success' | 'idle'>(state.allEntries[0].status) + expectType<'success' | 'idle'>(state.allEntries[0].status); } }); @@ -204,7 +205,9 @@ instance.addEventListener('modal-open', (e) => { instance.addEventListener('activity-change', (e) => { const payload = e.detail; - expectType<(typeof ActivityBlock)['activities'][keyof (typeof ActivityBlock)['activities']] | null>(payload.activity); + expectType<(typeof ActivityBlock)['activities'][keyof (typeof ActivityBlock)['activities']] | null | (string & {})>( + payload.activity, + ); }); () => { diff --git a/utils/validators/collection/validateCollectionUploadError.js b/utils/validators/collection/validateCollectionUploadError.js index 56fe2d9c0..38af5a693 100644 --- a/utils/validators/collection/validateCollectionUploadError.js +++ b/utils/validators/collection/validateCollectionUploadError.js @@ -1,11 +1,11 @@ // @ts-check /** @type {import('../../../abstract/ValidationManager.js').FuncCollectionValidator} */ -export const validateCollectionUploadError = (collection, block) => { +export const validateCollectionUploadError = (collection, api) => { if (collection.failedCount > 0) { return { type: 'SOME_FILES_HAS_ERRORS', - message: block.l10n('some-files-were-not-uploaded'), + message: api.l10n('some-files-were-not-uploaded'), }; } }; diff --git a/utils/validators/collection/validateMultiple.js b/utils/validators/collection/validateMultiple.js index 5e0dee893..dba19c313 100644 --- a/utils/validators/collection/validateMultiple.js +++ b/utils/validators/collection/validateMultiple.js @@ -1,13 +1,13 @@ //@ts-check /** @type {import('../../../abstract/ValidationManager.js').FuncCollectionValidator} */ -export const validateMultiple = (collection, block) => { +export const validateMultiple = (collection, api) => { const total = collection.totalCount; - const multipleMin = block.cfg.multiple ? block.cfg.multipleMin : 0; - const multipleMax = block.cfg.multiple ? block.cfg.multipleMax : 1; + const multipleMin = api.cfg.multiple ? api.cfg.multipleMin : 0; + const multipleMax = api.cfg.multiple ? api.cfg.multipleMax : 1; if (multipleMin && total < multipleMin) { - const message = block.l10n('files-count-limit-error-too-few', { + const message = api.l10n('files-count-limit-error-too-few', { min: multipleMin, max: multipleMax, total, @@ -25,7 +25,7 @@ export const validateMultiple = (collection, block) => { } if (multipleMax && total > multipleMax) { - const message = block.l10n('files-count-limit-error-too-many', { + const message = api.l10n('files-count-limit-error-too-many', { min: multipleMin, max: multipleMax, total, diff --git a/utils/validators/file/validateFileType.js b/utils/validators/file/validateFileType.js index c7ba28ff9..c47bcf581 100644 --- a/utils/validators/file/validateFileType.js +++ b/utils/validators/file/validateFileType.js @@ -2,9 +2,9 @@ import { IMAGE_ACCEPT_LIST, matchExtension, matchMimeType, mergeFileTypes } from '../../fileTypes.js'; /** @type {import('../../../abstract/ValidationManager.js').FuncFileValidator} */ -export const validateFileType = (outputEntry, block) => { - const imagesOnly = block.cfg.imgOnly; - const accept = block.cfg.accept; +export const validateFileType = (outputEntry, api) => { + const imagesOnly = api.cfg.imgOnly; + const accept = api.cfg.accept; const allowedFileTypes = mergeFileTypes([...(imagesOnly ? IMAGE_ACCEPT_LIST : []), accept]); if (!allowedFileTypes.length) return; @@ -23,7 +23,7 @@ export const validateFileType = (outputEntry, block) => { // Assume file type is not allowed if both mime and ext checks fail return { type: 'FORBIDDEN_FILE_TYPE', - message: block.l10n('file-type-not-allowed'), + message: api.l10n('file-type-not-allowed'), payload: { entry: outputEntry }, }; } diff --git a/utils/validators/file/validateIsImage.js b/utils/validators/file/validateIsImage.js index 0d9343ced..620092ea9 100644 --- a/utils/validators/file/validateIsImage.js +++ b/utils/validators/file/validateIsImage.js @@ -1,8 +1,8 @@ // @ts-check -/** @type import('../../../abstract/ValidationManager.js').FuncFileValidator */ -export const validateIsImage = (outputEntry, block) => { - const imagesOnly = block.cfg.imgOnly; +/** @type {import('../../../abstract/ValidationManager.js').FuncFileValidator} */ +export const validateIsImage = (outputEntry, api) => { + const imagesOnly = api.cfg.imgOnly; const isImage = outputEntry.isImage; if (!imagesOnly || isImage) { @@ -19,7 +19,7 @@ export const validateIsImage = (outputEntry, block) => { return { type: 'NOT_AN_IMAGE', - message: block.l10n('images-only-accepted'), + message: api.l10n('images-only-accepted'), payload: { entry: outputEntry }, }; }; diff --git a/utils/validators/file/validateMaxSizeLimit.js b/utils/validators/file/validateMaxSizeLimit.js index d8f9d73a0..57226d57a 100644 --- a/utils/validators/file/validateMaxSizeLimit.js +++ b/utils/validators/file/validateMaxSizeLimit.js @@ -2,13 +2,13 @@ import { prettyBytes } from '../../prettyBytes.js'; /** @type {import('../../../abstract/ValidationManager.js').FuncFileValidator} */ -export const validateMaxSizeLimit = (outputEntry, block) => { - const maxFileSize = block.cfg.maxLocalFileSizeBytes; +export const validateMaxSizeLimit = (outputEntry, api) => { + const maxFileSize = api.cfg.maxLocalFileSizeBytes; const fileSize = outputEntry.size; if (maxFileSize && fileSize && fileSize > maxFileSize) { return { type: 'FILE_SIZE_EXCEEDED', - message: block.l10n('files-max-size-limit-error', { maxFileSize: prettyBytes(maxFileSize) }), + message: api.l10n('files-max-size-limit-error', { maxFileSize: prettyBytes(maxFileSize) }), payload: { entry: outputEntry }, }; } diff --git a/utils/validators/file/validateUploadError.js b/utils/validators/file/validateUploadError.js index f9a6d9a0c..76687def3 100644 --- a/utils/validators/file/validateUploadError.js +++ b/utils/validators/file/validateUploadError.js @@ -2,11 +2,14 @@ import { NetworkError, UploadError } from '@uploadcare/upload-client'; /** @type {import('../../../abstract/ValidationManager.js').FuncFileValidator} */ -export const validateUploadError = (outputEntry, block) => { +export const validateUploadError = (outputEntry, api) => { const { internalId } = outputEntry; + // @ts-expect-error Use private API that is not exposed in the types + const internalEntry = api._uploadCollection.read(internalId); + /** @type {unknown} */ - const cause = block.uploadCollection.read(internalId)?.getValue('uploadError'); + const cause = internalEntry?.getValue('uploadError'); if (!cause) { return; }