UNPKG

@uploadcare/file-uploader

Version:

Building blocks for Uploadcare products integration

397 lines (359 loc) 13.5 kB
// @ts-check import { ActivityBlock } from './ActivityBlock.js'; import { applyStyles, Data } from '@symbiotejs/symbiote'; import { calcCameraModes } from '../blocks/CameraSource/calcCameraModes.js'; import { CameraSourceTypes } from '../blocks/CameraSource/constants.js'; import { EventType } from '../blocks/UploadCtxProvider/EventEmitter.js'; import { UploadSource } from '../blocks/utils/UploadSource.js'; import { serializeCsv } from '../blocks/utils/comma-separated.js'; import { browserFeatures } from '../utils/browser-info.js'; import { BASIC_IMAGE_WILDCARD, BASIC_VIDEO_WILDCARD, fileIsImage, IMAGE_ACCEPT_LIST, mergeFileTypes, } from '../utils/fileTypes.js'; import { parseCdnUrl } from '../utils/parseCdnUrl.js'; import { stringToArray } from '../utils/stringToArray.js'; import { buildOutputCollectionState } from './buildOutputCollectionState.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); if (!entry) return false; 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; modeCamera?: import('../blocks/CameraSource/constants.js').ModeCameraType }} options */ openSystemDialog = (options = {}) => { const accept = serializeCsv( mergeFileTypes([this.cfg.accept ?? '', ...(this.cfg.imgOnly ? IMAGE_ACCEPT_LIST : [])]), ); const INPUT_ATTR_NAME = 'uploadcare-file-input'; const fileInput = document.createElement('input'); fileInput.setAttribute(INPUT_ATTR_NAME, ''); applyStyles(fileInput, { opacity: 0, height: 0, width: 0, visibility: 'hidden', }); fileInput.type = 'file'; fileInput.multiple = this.cfg.multiple; if (options.captureCamera) { fileInput.capture = this.cfg.cameraCapture; const { isPhotoEnabled, isVideoRecordingEnabled } = calcCameraModes(this.cfg); if (options.modeCamera === CameraSourceTypes.PHOTO && isPhotoEnabled) { fileInput.accept = BASIC_IMAGE_WILDCARD; } else if (options.modeCamera === CameraSourceTypes.VIDEO && isVideoRecordingEnabled) { fileInput.accept = BASIC_VIDEO_WILDCARD; } else { fileInput.accept = [BASIC_IMAGE_WILDCARD, isVideoRecordingEnabled && BASIC_VIDEO_WILDCARD] .filter(Boolean) .join(','); } } else { fileInput.accept = accept; } fileInput.addEventListener( 'change', () => { if (!fileInput.files) { return; } [...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.modalManager.open(ActivityBlock.activities.UPLOAD_LIST); this._ctx.$['*currentActivity'] = ActivityBlock.activities.UPLOAD_LIST; fileInput.remove(); }, { once: true, }, ); document.querySelectorAll(`[${INPUT_ATTR_NAME}]`).forEach((el) => el.remove()); /** * Some browsers (e.g. Safari) require the file input to be in the DOM to work properly. Without it the file input * will open system dialog but won't trigger the change event sometimes. */ document.body.appendChild(fileInput); fileInput.dispatchEvent(new MouseEvent('click')); }; /** * @template {import('../types').OutputFileStatus} TStatus * @param {string} entryId * @returns {import('../types/exported.js').OutputFileEntry<TStatus>} */ getOutputItem = (entryId) => { const uploadEntryData = /** @type {import('./uploadEntrySchema.js').UploadEntryData} */ ( 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, source: uploadEntryData?.source, }; return /** @type {import('../types/exported.js').OutputFileEntry<TStatus>} */ (outputItem); }; /** @template {import('../types').OutputCollectionStatus} TStatus */ getOutputCollectionState = () => { return /** @type {ReturnType<typeof buildOutputCollectionState<TStatus>>} */ ( buildOutputCollectionState(this._ctx) ); }; /** @param {Boolean} [force] */ initFlow = (force = false) => { if (this._uploadCollection.size > 0 && !force) { this._ctx.modalManager?.open(ActivityBlock.activities.UPLOAD_LIST); this._ctx.set$({ '*currentActivity': ActivityBlock.activities.UPLOAD_LIST, }); } 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; } if (srcKey === 'camera' && browserFeatures.htmlMediaCapture) { const { isPhotoEnabled, isVideoRecordingEnabled } = calcCameraModes(this.cfg); if (isPhotoEnabled && isVideoRecordingEnabled) { this._ctx.set$({ '*currentActivity': ActivityBlock.activities.START_FROM, }); return; } else if (isPhotoEnabled || isVideoRecordingEnabled) { this.openSystemDialog({ captureCamera: true, modeCamera: isPhotoEnabled ? CameraSourceTypes.PHOTO : CameraSourceTypes.VIDEO, }); return; } else { this.openSystemDialog({ captureCamera: true, modeCamera: CameraSourceTypes.PHOTO, }); } } /** @type {Set<import('./Block').Block>} */ 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.modalManager?.open(this._ctx.$['*currentActivity']); } } else { // Multiple sources case: this._ctx.modalManager?.open(ActivityBlock.activities.START_FROM); this._ctx.set$({ '*currentActivity': ActivityBlock.activities.START_FROM, }); } } }; doneFlow = () => { this._ctx.set$({ '*currentActivity': this._ctx.doneActivity, '*history': this._ctx.doneActivity ? [this._ctx.doneActivity] : [], }); if (!this._ctx.$['*currentActivity']) { this._ctx.modalManager.closeAll(); } }; /** * @type {<T extends import('./ActivityBlock.js').ActivityType>( * activityType: T, * ...params: T extends keyof import('./ActivityBlock.js').ActivityParamsMap * ? [import('./ActivityBlock.js').ActivityParamsMap[T]] * : T extends import('./ActivityBlock.js').RegisteredActivityType * ? [undefined?] * : [any?] * ) => void} */ setCurrentActivity = (activityType, params = undefined) => { 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`); }; /** @returns {import('./ActivityBlock.js').ActivityType} */ getCurrentActivity = () => { return this._ctx.$['*currentActivity']; }; /** @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; } if (opened) { this._ctx.modalManager?.open(this._ctx.$['*currentActivity']); } else { this._ctx.modalManager?.close(this._ctx.$['*currentActivity']); this._ctx.$['*currentActivity'] = null; } }; /** * @private * @type {string[]} */ get _sourceList() { /** @type {string[]} */ let list = []; if (this.cfg.sourceList) { list = stringToArray(this.cfg.sourceList); } return list; } }