diff --git a/unitylibs/blocks/unity/unity.css b/unitylibs/blocks/unity/unity.css index e69de29..4a774a0 100644 --- a/unitylibs/blocks/unity/unity.css +++ b/unitylibs/blocks/unity/unity.css @@ -0,0 +1,3 @@ +.unity { + display: none !important; +} diff --git a/unitylibs/blocks/unity/unity.js b/unitylibs/blocks/unity/unity.js index a427006..e6a74d7 100644 --- a/unitylibs/blocks/unity/unity.js +++ b/unitylibs/blocks/unity/unity.js @@ -24,5 +24,5 @@ export default async function init(el) { }); await stylePromise; const { default: wfinit } = await import(`${unitylibs}/core/workflow/workflow.js`); - await wfinit(el, projectName, unitylibs); + await wfinit(el, projectName, unitylibs, 'v2'); } diff --git a/unitylibs/core/features/progress-circle/progress-circle.css b/unitylibs/core/features/progress-circle/progress-circle.css index a729e22..29a6852 100644 --- a/unitylibs/core/features/progress-circle/progress-circle.css +++ b/unitylibs/core/features/progress-circle/progress-circle.css @@ -1,4 +1,5 @@ -.unity-enabled .interactive-area .progress-holder { +.unity-enabled .interactive-area .progress-holder, +.unity-enabled .interactive-area .progress-circle { display: none; justify-content: center; align-items: center; @@ -11,7 +12,8 @@ z-index: 2; } -.unity-enabled .interactive-area.loading .progress-holder { +.unity-enabled .interactive-area.loading .progress-holder, +.unity-enabled .interactive-area .progress-circle.show { display: flex; } diff --git a/unitylibs/core/styles/splash-screen.css b/unitylibs/core/styles/splash-screen.css new file mode 100644 index 0000000..73a5d1a --- /dev/null +++ b/unitylibs/core/styles/splash-screen.css @@ -0,0 +1,150 @@ +body > .splash-loader { + z-index: 10000000; +} + +.splash-loader { + display: none; + position: fixed; + top: 0; + left: 0; + height: 100%; + width: 100%; + max-height: 100%; + max-width: 100%; + color: #000; + z-index: 3; +} + +.splash-loader .section { + height: 100%; + width: 100%; + max-height: 100%; + max-width: 100%; +} + +.splash-loader.show { + display: flex !important; +} + +.splash-loader .text { + padding: 0; + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.splash-loader .text .con-button { + display: none; +} + +.progress-holder { + width: 100%; +} + +.hide-splash-overflow { + overflow: hidden; +} + +.progress-holder .spectrum-ProgressBar { + position: relative; + display: inline-flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: center; + font-size: 15px; + vertical-align: top; + inline-size: 192px; + width: 100%; +} + +.progress-holder .spectrum-ProgressBar .spectrum-ProgressBar-label, +.progress-holder .spectrum-ProgressBar .spectrum-ProgressBar-percentage { + text-align: start; + color: rgba(34,34,34); + } + +.progress-holder .spectrum-ProgressBar .spectrum-ProgressBar-label { + display: none; +} + +.progress-holder .spectrum-ProgressBar .spectrum-ProgressBar-percentage { + align-self: flex-start; + margin-inline-start: 5px; +} + +.progress-holder .spectrum-ProgressBar .spectrum-ProgressBar-track { + overflow: hidden; + inline-size: 100%; + block-size: 8px; + border-radius: 5px; + background: rgba(213,213,213); +} + +.progress-holder .spectrum-ProgressBar .spectrum-ProgressBar-fill { + border: none; + block-size: 8px; + transition: width 1s; + background: #FA0F00; +} + +.progress-holder .spectrum-ProgressBar--sideLabel { + display: inline-flex; + flex-flow: row; + justify-content: space-between; +} + +.progress-holder .spectrum-ProgressBar--sideLabel .spectrum-ProgressBar-track { + flex: 1 1 10px; +} + +.progress-holder .spectrum-ProgressBar--sideLabel .spectrum-ProgressBar-label { + flex-grow: 0; + margin-inline-end: 12px; + margin-block-end: 0; +} + +.progress-holder .spectrum-ProgressBar--sideLabel .spectrum-ProgressBar-percentage { + order: 3; + text-align: end; + margin-inline-start: 12px; + font-size: var(--type-body-l-size); + line-height: var(--type-body-l-lh); + font-weight: 700; +} + +.splash-loader .text .icon-area img, +.splash-loader .text video { + width: 104px; + height: auto; +} + +@media screen and (min-width: 600px) { + .splash-loader .text .icon-area img, + .splash-loader .text video { + width: 286px; + height: auto; + } + + .splash-loader h1, + .splash-loader h2, + .splash-loader h3, + .splash-loader h4, + .splash-loader h5 { + font-size: var(--type-heading-xl-size); + line-height: var(--type-heading-xl-lh); + font-weight: 700; + } +} + +@media screen and (min-width: 1200px) { + .splash-loader .text .con-button { + display: flex; + } + + .progress-holder { + min-width: 400px; + } +} + diff --git a/unitylibs/core/styles/styles.css b/unitylibs/core/styles/styles.css index e984fd5..458f11b 100644 --- a/unitylibs/core/styles/styles.css +++ b/unitylibs/core/styles/styles.css @@ -8,7 +8,10 @@ --animation-blue: #1273E6; } -.unity, +.unity { + display: none !important; +} + .unity-enabled .interactive-area .unity-widget.decorating { display: none; } @@ -140,12 +143,12 @@ } .unity-enabled .interactive-area .widget-refresh-button svg path, -.unity-enabled .interactive-area .unity-action-btn .btn-icon svg path { +.unity-enabled .interactive-area .unity-action-btn:not(.continue-in-app-button) .btn-icon svg path { fill: var(--color-white); } .unity-enabled .interactive-area.light .widget-refresh-button svg path, -.unity-enabled .interactive-area.light .unity-action-btn .btn-icon svg path { +.unity-enabled .interactive-area.light .unity-action-btn:not(.continue-in-app-button) .btn-icon svg path { fill: var(--color-black); } diff --git a/unitylibs/core/workflow/unity-config.js b/unitylibs/core/workflow/unity-config.js deleted file mode 100644 index e56200a..0000000 --- a/unitylibs/core/workflow/unity-config.js +++ /dev/null @@ -1,14 +0,0 @@ -const config = { - '.marquee': { - type: 'img', - selector: '.asset picture, .image picture', - handler: 'render', - renderWidget: true, - }, - '.aside': { - type: 'img', - selector: '.asset picture, .image picture', - handler: 'render', - renderWidget: true, - }, -}; diff --git a/unitylibs/core/workflow/workflow-acrobat/action-binder.js b/unitylibs/core/workflow/workflow-acrobat/action-binder.js new file mode 100644 index 0000000..86a4f66 --- /dev/null +++ b/unitylibs/core/workflow/workflow-acrobat/action-binder.js @@ -0,0 +1,456 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +/* eslint-disable no-restricted-syntax */ + +import { + unityConfig, + getUnityLibs, + createTag, + localizeLink, + priorityLoad, + loadArea, + loadImg, +} from '../../../scripts/utils.js'; +import getError from '../../../scripts/errors.js'; + +export default class ActionBinder { + constructor(unityEl, workflowCfg, wfblock, canvasArea, actionMap = {}, limits = {}) { + this.unityEl = unityEl; + this.workflowCfg = workflowCfg; + this.block = wfblock; + this.actionMap = actionMap; + this.limits = limits; + this.canvasArea = canvasArea; + this.operations = []; + this.acrobatApiConfig = this.getAcrobatApiConfig(); + this.serviceHandler = null; + this.splashScreenEl = null; + this.promiseStack = []; + this.LOADER_DELAY = 800; + this.LOADER_INCREMENT = 30; + this.LOADER_LIMIT = 95; + } + + getAcrobatApiConfig() { + unityConfig.acrobatEndpoint = { + createAsset: `${unityConfig.apiEndPoint}/asset`, + finalizeAsset: `${unityConfig.apiEndPoint}/asset/finalize`, + getMetadata: `${unityConfig.apiEndPoint}/asset/metadata`, + }; + return unityConfig; + } + + async handlePreloads() { + const parr = [`${getUnityLibs()}/core/workflow/${this.workflowCfg.name}/service-handler.js`]; + if (this.workflowCfg.targetCfg.showSplashScreen) { + parr.push( + `${getUnityLibs()}/core/styles/splash-screen.css`, + `${this.splashFragmentLink}.plain.html`, + ); + } + await priorityLoad(parr); + } + + updateProgressBar(layer, percentage) { + const p = Math.min(percentage, this.LOADER_LIMIT); + const spb = layer.querySelector('.spectrum-ProgressBar'); + spb?.setAttribute('value', p); + spb?.setAttribute('aria-valuenow', p); + layer.querySelector('.spectrum-ProgressBar-percentage').innerHTML = `${p}%`; + layer.querySelector('.spectrum-ProgressBar-fill').style.width = `${p}%`; + } + + createProgressBar() { + const pdom = `
+
+
0%
+
+
+
+
`; + return createTag('div', { class: 'progress-holder' }, pdom); + } + + async acrobatActionMaps(values, files, eventName) { + await this.handlePreloads(); + const { default: ServiceHandler } = await import(`${getUnityLibs()}/core/workflow/${this.workflowCfg.name}/service-handler.js`); + this.serviceHandler = new ServiceHandler( + this.workflowCfg.targetCfg.renderWidget, + this.canvasArea, + ); + for (const value of values) { + switch (true) { + case value.actionType === 'fillsign': + this.promiseStack = []; + await this.fillsign(files, eventName); + break; + case value.actionType === 'continueInApp': + this.LOADER_LIMIT = 100; + this.updateProgressBar(this.splashScreenEl, 100); + await this.continueInApp(); + break; + case value.actionType === 'interrupt': + await this.cancelAcrobatOperation(); + break; + default: + break; + } + } + } + + async initActionListeners(b = this.block, actMap = this.actionMap) { + for (const [key, values] of Object.entries(actMap)) { + const el = b.querySelector(key); + if (!el) return; + switch (true) { + case el.nodeName === 'A': + el.addEventListener('click', async (e) => { + e.preventDefault(); + await this.acrobatActionMaps(values); + }); + break; + case el.nodeName === 'DIV': + el.addEventListener('drop', async (e) => { + e.preventDefault(); + const files = this.extractFiles(e); + await this.acrobatActionMaps(values, files, 'drop'); + }); + break; + case el.nodeName === 'INPUT': + el.addEventListener('change', async (e) => { + const files = this.extractFiles(e); + await this.acrobatActionMaps(values, files, 'change'); + e.target.value = ''; + }); + break; + default: + break; + } + } + if (b === this.block) this.splashScreenEl = await this.loadSplashFragment(); + } + + extractFiles(e) { + const files = []; + if (e.dataTransfer?.items) { + [...e.dataTransfer.items].forEach((item) => { + if (item.kind === 'file') { + const file = item.getAsFile(); + files.push(file); + } + }); + } else if (e.target?.files) { + [...e.target.files].forEach((file) => { + files.push(file); + }); + } + return files; + } + + async dispatchErrorToast(code, showError = true, status = null) { + if (showError) { + const message = code in this.workflowCfg.errors + ? this.workflowCfg.errors[code] + : await getError(this.workflowCfg.enabledFeatures[0], code); + this.block.dispatchEvent(new CustomEvent( + unityConfig.errorToastEvent, + { detail: { code, message: message || 'Unable to process the request', ...(status !== null && { status }) } }, + )); + } + } + + async fillsign(files, eventName) { + if (!files || files.length > this.limits.maxNumFiles) { + await this.dispatchErrorToast('verb_upload_error_only_accept_one_file'); + return; + } + const file = files[0]; + if (!file) return; + await this.singleFileUpload(file, eventName); + } + + async getBlobData(file) { + const objUrl = URL.createObjectURL(file); + const response = await fetch(objUrl); + if (!response.ok) { + const error = new Error(); + error.status = response.status; + throw error; + } + const blob = await response.blob(); + URL.revokeObjectURL(objUrl); + return blob; + } + + async uploadFileToUnity(storageUrl, blobData, fileType) { + const uploadOptions = { + method: 'PUT', + headers: { 'Content-Type': fileType }, + body: blobData, + }; + const response = await fetch(storageUrl, uploadOptions); + return response; + } + + async chunkPdf(assetData, blobData, filetype) { + const totalChunks = Math.ceil(blobData.size / assetData.blocksize); + if (assetData.uploadUrls.length !== totalChunks) return; + const uploadPromises = Array.from({ length: totalChunks }, (_, i) => { + const start = i * assetData.blocksize; + const end = Math.min(start + assetData.blocksize, blobData.size); + const chunk = blobData.slice(start, end); + const url = assetData.uploadUrls[i]; + return this.uploadFileToUnity(url.href, chunk, filetype); + }); + this.promiseStack.push(...uploadPromises); + await Promise.all(this.promiseStack); + } + + async continueInApp() { + if (!this.operations.length) return; + const { assetId, filename, filesize, filetype } = this.operations[this.operations.length - 1]; + const cOpts = { + assetId, + targetProduct: this.workflowCfg.productName, + payload: { + languageRegion: this.workflowCfg.langRegion, + languageCode: this.workflowCfg.langCode, + verb: this.workflowCfg.enabledFeatures[0], + assetMetadata: { + [assetId]: { + name: filename, + size: filesize, + type: filetype, + }, + }, + }, + }; + this.promiseStack.push( + this.serviceHandler.postCallToService( + this.acrobatApiConfig.connectorApiEndPoint, + { body: JSON.stringify(cOpts) }, + ), + ); + await Promise.all(this.promiseStack) + .then((resArr) => { + const response = resArr[resArr.length - 1]; + if (!response?.url) throw new Error('Error connecting to App'); + this.block.dispatchEvent(new CustomEvent(unityConfig.trackAnalyticsEvent, { detail: { event: 'redirect to product' } })); + window.location.href = response.url; + }) + .catch(async (e) => { + await this.showSplashScreen(); + await this.dispatchErrorToast('verb_upload_error_generic', e?.showError); + }); + } + + async cancelAcrobatOperation() { + await this.showSplashScreen(); + const e = new Error(); + e.message = 'Operation termination requested.'; + e.showError = false; + const cancelPromise = Promise.reject(e); + this.promiseStack.unshift(cancelPromise); + } + + progressBarHandler(s, delay, i, initialize = false) { + if (!s) return; + delay = Math.min(delay + 100, 2000); + i = Math.max(i - 5, 5); + const progressBar = s.querySelector('.spectrum-ProgressBar'); + if (!initialize && progressBar?.getAttribute('value') >= this.LOADER_LIMIT) return; + if (initialize) this.updateProgressBar(s, 0); + setTimeout(() => { + const v = initialize ? 0 : parseInt(progressBar.getAttribute('value'), 10); + this.updateProgressBar(s, v + i); + this.progressBarHandler(s, delay, i); + }, delay); + } + + splashVisibilityController(displayOn) { + if (!displayOn) { + this.LOADER_LIMIT = 95; + this.splashScreenEl.parentElement?.classList.remove('hide-splash-overflow'); + this.splashScreenEl.classList.remove('show'); + return; + } + this.progressBarHandler(this.splashScreenEl, this.LOADER_DELAY, this.LOADER_INCREMENT, true); + this.splashScreenEl.classList.add('show'); + this.splashScreenEl.parentElement?.classList.add('hide-splash-overflow'); + } + + async loadSplashFragment() { + if (!this.workflowCfg.targetCfg.showSplashScreen) return; + this.splashFragmentLink = localizeLink(`${window.location.origin}${this.workflowCfg.targetCfg.splashScreenConfig.fragmentLink}`); + const resp = await fetch(`${this.splashFragmentLink}.plain.html`); + const html = await resp.text(); + const doc = new DOMParser().parseFromString(html, 'text/html'); + const sections = doc.querySelectorAll('body > div'); + const f = createTag('div', { class: 'fragment splash-loader decorate', style: 'display: none' }); + f.append(...sections); + const splashDiv = document.querySelector(this.workflowCfg.targetCfg.splashScreenConfig.splashScreenParent); + splashDiv.append(f); + const img = f.querySelector('img'); + if (img) loadImg(img); + await loadArea(f); + return f; + } + + async handleSplashProgressBar() { + const pb = this.createProgressBar(); + this.splashScreenEl.querySelector('.icon-progress-bar').replaceWith(pb); + this.progressBarHandler(this.splashScreenEl, this.LOADER_DELAY, this.LOADER_INCREMENT, true); + } + + handleOperationCancel() { + const actMap = { 'a.con-button[href*="#_cancel"]': [{ actionType: 'interrupt' }] }; + this.initActionListeners(this.splashScreenEl, actMap); + } + + async showSplashScreen(displayOn = false) { + if (!this.splashScreenEl && !this.workflowCfg.targetCfg.showSplashScreen) return; + if (this.splashScreenEl.classList.contains('decorate')) { + if (this.splashScreenEl.querySelector('.icon-progress-bar')) await this.handleSplashProgressBar(); + if (this.splashScreenEl.querySelector('a.con-button[href*="#_cancel"]')) this.handleOperationCancel(); + this.splashScreenEl.classList.remove('decorate'); + } + this.splashVisibilityController(displayOn); + } + + async verifyContent(assetData) { + try { + const finalAssetData = { + surfaceId: unityConfig.surfaceId, + targetProduct: this.workflowCfg.productName, + assetId: assetData.id, + }; + const finalizeJson = await this.serviceHandler.postCallToService( + this.acrobatApiConfig.acrobatEndpoint.finalizeAsset, + { body: JSON.stringify(finalAssetData), signal: AbortSignal.timeout?.(15000)}, + ); + if (!finalizeJson || Object.keys(finalizeJson).length !== 0) { + await this.showSplashScreen(); + await this.dispatchErrorToast('verb_upload_error_generic'); + this.operations = []; + return false; + } + const intervalDuration = 500; + const totalDuration = 5000; + let metadata = {}; + let intervalId; + let requestInProgress = false; + let metadataExists = false; + return new Promise((resolve) => { + const handleMetadata = async () => { + if (metadata.numPages > this.limits.maxNumPages) { + await this.showSplashScreen(); + await this.dispatchErrorToast('verb_upload_error_max_page_count'); + resolve(false); + return; + } + resolve(true); + } + intervalId = setInterval(async () => { + if (requestInProgress) return; + requestInProgress = true; + metadata = await this.serviceHandler.getCallToService( + this.acrobatApiConfig.acrobatEndpoint.getMetadata, + { id: assetData.id }, + ); + requestInProgress = false; + if (metadata?.numPages !== undefined) { + clearInterval(intervalId); + clearTimeout(timeoutId); + metadataExists = true; + await handleMetadata(); + } + }, intervalDuration); + const timeoutId = setTimeout(async () => { + clearInterval(intervalId); + if (!metadataExists) resolve(true); + else await handleMetadata(); + }, totalDuration); + }); + } catch (e) { + await this.showSplashScreen(); + await this.dispatchErrorToast('verb_upload_error_generic', e?.showError, e?.status); + this.operations = []; + return false; + } + } + + async singleFileUpload(file, eventName) { + if (file.type !== 'application/pdf') { + await this.dispatchErrorToast('verb_upload_error_unsupported_type'); + return; + } + if (!file.size) { + await this.dispatchErrorToast('verb_upload_error_empty_file'); + return; + } + if (file.size > this.limits.maxFileSize) { + await this.dispatchErrorToast('verb_upload_error_file_too_large'); + return; + } + const fileData = { + type: file.type, + size: file.size, + count: 1, + }; + this.block.dispatchEvent( + new CustomEvent( + unityConfig.trackAnalyticsEvent, + { detail: { event: eventName, data: fileData } }, + ), + ); + let assetData = null; + try { + await this.showSplashScreen(true); + const blobData = await this.getBlobData(file); + const data = { + surfaceId: unityConfig.surfaceId, + targetProduct: this.workflowCfg.productName, + name: file.name, + size: file.size, + format: file.type, + }; + assetData = await this.serviceHandler.postCallToService( + this.acrobatApiConfig.acrobatEndpoint.createAsset, + { body: JSON.stringify(data) }, + ); + this.block.dispatchEvent(new CustomEvent(unityConfig.trackAnalyticsEvent, { detail: { event: 'uploading' } })); + await this.chunkPdf(assetData, blobData, file.type); + const operationItem = { + assetId: assetData.id, + filename: file.name, + filesize: file.size, + filetype: file.type, + }; + this.operations.push(operationItem); + } catch (e) { + await this.showSplashScreen(); + switch (e.status) { + case 409: + await this.dispatchErrorToast('verb_upload_error_duplicate_asset', e.showError); + break; + case 401: + await this.dispatchErrorToast(e.message === 'notentitled' ? 'verb_upload_error_no_storage_provision' : 'verb_upload_error_generic', e.showError); + break; + case 403: + if (e.message === 'quotaexceeded') await this.dispatchErrorToast('verb_upload_error_max_quota_exceeded', e.showError); + else await this.dispatchErrorToast('verb_upload_error_no_storage_provision', e.showError); + break; + default: + await this.dispatchErrorToast('verb_upload_error_generic', e.showError, e.status); + break; + } + return; + } + const verified = await this.verifyContent(assetData); + if (!verified) { + this.operations = []; + return; + } + this.block.dispatchEvent(new CustomEvent(unityConfig.trackAnalyticsEvent, { detail: { event: 'uploaded' } })); + } +} diff --git a/unitylibs/core/workflow/workflow-acrobat/service-handler.js b/unitylibs/core/workflow/workflow-acrobat/service-handler.js new file mode 100644 index 0000000..5c7c344 --- /dev/null +++ b/unitylibs/core/workflow/workflow-acrobat/service-handler.js @@ -0,0 +1,70 @@ +/* eslint-disable eqeqeq */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +/* eslint-disable no-restricted-syntax */ + +import { + getGuestAccessToken, + unityConfig, +} from '../../../scripts/utils.js'; + +export default class ServiceHandler { + constructor(renderWidget = false, canvasArea = null) { + this.renderWidget = renderWidget; + this.canvasArea = canvasArea; + } + + getHeaders() { + return { + headers: { + 'Content-Type': 'application/json', + Authorization: getGuestAccessToken(), + 'x-api-key': unityConfig.apiKey, + }, + }; + } + + async fetchFromService(url, options) { + try { + const response = await fetch(url, options); + const error = new Error(); + const contentLength = response.headers.get('Content-Length'); + if (response.status !== 200) { + if (contentLength !== '0') { + const resJson = await response.json(); + ['quotaexceeded', 'notentitled'].forEach((errorMessage) => { + if (resJson.reason?.includes(errorMessage)) error.message = errorMessage; + }); + } + error.status = response.status; + throw error; + } + if (contentLength === '0') return {}; + return response.json(); + } catch (error) { + if (error.name === 'TimeoutError' || error.name === 'AbortError') { + error.status = 504; + } + throw error; + } + } + + async postCallToService(api, options) { + const postOpts = { + method: 'POST', + ...this.getHeaders(), + ...options, + }; + return this.fetchFromService(api, postOpts); + } + + async getCallToService(api, params) { + const getOpts = { + method: 'GET', + ...this.getHeaders(), + }; + const queryString = new URLSearchParams(params).toString(); + const url = `${api}?${queryString}`; + return this.fetchFromService(url, getOpts); + } +} diff --git a/unitylibs/core/workflow/workflow-acrobat/target-config.json b/unitylibs/core/workflow/workflow-acrobat/target-config.json new file mode 100644 index 0000000..0101423 --- /dev/null +++ b/unitylibs/core/workflow/workflow-acrobat/target-config.json @@ -0,0 +1,53 @@ +{ + "verb-widget.fillsign": { + "type": "pdf", + "selector": ".verb-wrapper", + "handler": "render", + "renderWidget": false, + "source": ".verb-wrapper .verb-container", + "target": ".verb-wrapper .verb-container", + "limits": { + "maxNumFiles": 1, + "maxFileSize": 104857600, + "maxNumPages": 100 + }, + "showSplashScreen": true, + "splashScreenConfig": { + "fragmentLink": "/dc-shared/fragments/shared-fragments/frictionless/splash-page/splashscreen", + "splashScreenParent": "body" + }, + "actionMap": { + ".verb-wrapper": [ + { + "actionType": "fillsign" + }, + { + "actionType": "continueInApp" + } + ], + "#file-upload": [ + { + "actionType": "fillsign" + }, + { + "actionType": "continueInApp" + } + ] + } + }, + "marquee": { + "type": "pdf", + "selector": ".action-area", + "handler": "render", + "renderWidget": false, + "source": ".action-area .con-button", + "target": ".action-area .con-button", + "actionMap": { + ".action-area .con-button": [ + { + "actionType": "upload" + } + ] + } + } +} diff --git a/unitylibs/core/workflow/workflow-photoshop/action-binder.js b/unitylibs/core/workflow/workflow-photoshop/action-binder.js new file mode 100644 index 0000000..b108fc5 --- /dev/null +++ b/unitylibs/core/workflow/workflow-photoshop/action-binder.js @@ -0,0 +1,374 @@ +/* eslint-disable eqeqeq */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +/* eslint-disable no-restricted-syntax */ + +import { + unityConfig, + getUnityLibs, + loadImg, + loadStyle, + createTag, + loadSvgs, +} from '../../../scripts/utils.js'; + +export default class ActionBinder { + constructor(unityEl, workflowCfg, wfblock, canvasArea, actionMap = {}, limits = {}) { + this.workflowCfg = workflowCfg; + this.block = wfblock; + this.actionMap = actionMap; + this.canvasArea = canvasArea; + this.operations = []; + this.progressCircleEl = null; + this.errorToastEl = null; + this.psApiConfig = this.getPsApiConfig(); + this.serviceHandler = null; + } + + getPsApiConfig() { + unityConfig.psEndPoint = { + assetUpload: `${unityConfig.apiEndPoint}/asset`, + acmpCheck: `${unityConfig.apiEndPoint}/asset/finalize`, + removeBackground: `${unityConfig.apiEndPoint}/providers/PhotoshopRemoveBackground`, + changeBackground: `${unityConfig.apiEndPoint}/providers/PhotoshopChangeBackground`, + }; + return unityConfig; + } + + hideElement(item, b) { + if (typeof item === 'string') b?.querySelector(item)?.classList.remove('show'); + else item?.classList.remove('show'); + } + + showElement(item, b) { + if (typeof item === 'string') b?.querySelector(item)?.classList.add('show'); + else item?.classList.add('show'); + } + + toggleElement(item, b) { + if (typeof item === 'string') { + if (b?.querySelector(item)?.classList.contains('show')) b?.querySelector(item)?.classList.remove('show'); + else b?.querySelector(item)?.classList.add('show'); + return; + } + if (item?.classList.contains('show')) item?.classList.remove('show'); + else item?.classList.add('show'); + } + + styleElement(itemSelector, propertyName, propertyValue) { + const item = this.block.querySelector(itemSelector); + item.style[propertyName] = propertyValue; + } + + async psActionMaps(values, e) { + const { default: ServiceHandler } = await import(`${getUnityLibs()}/core/workflow/${this.workflowCfg.name}/service-handler.js`); + this.serviceHandler = new ServiceHandler( + this.workflowCfg.targetCfg.renderWidget, + this.canvasArea, + ); + if (this.workflowCfg.targetCfg.renderWidget) { + const svgs = this.canvasArea.querySelectorAll('.unity-widget img[src*=".svg"'); + await loadSvgs(svgs); + if (!this.progressCircleEl) { + this.progressCircleEl = await this.createSpectrumProgress(); + this.canvasArea.append(this.progressCircleEl); + } + } + for (const value of values) { + switch (true) { + case value.actionType == 'hide': + value.targets.forEach((t) => this.hideElement(t, this.block)); + break; + case value.actionType == 'setCssStyle': + value.targets.forEach((t) => { this.styleElement(t, value.propertyName, value.propertyValue) }); + break; + case value.actionType == 'show': + value.targets.forEach((t) => this.showElement(t, this.block)); + break; + case value.actionType == 'toggle': + value.targets.forEach((t) => this.toggleElement(t, this.block)); + break; + case value.actionType == 'removebg': + await this.removeBackground(value); + break; + case value.actionType == 'changebg': + await this.changeBackground(value); + break; + case value.actionType == 'imageAdjustment': + this.changeAdjustments(e.target.value, value); + break; + case value.actionType == 'upload': + this.userImgUpload(value, e); + break; + case value.actionType == 'continueInApp': + this.continueInApp(value, e); + break; + case value.actionType == 'refresh': + value.target.src = value.sourceSrc; + this.operations = []; + break; + default: + break; + } + } + if (this.workflowCfg.targetCfg.renderWidget && this.operations.length) { + this.canvasArea.querySelector('.widget-product-icon')?.classList.remove('show'); + [...this.canvasArea.querySelectorAll('.widget-refresh-button')].forEach((w) => w.classList.add('show')); + } + } + + initActionListeners() { + for (const [key, values] of Object.entries(this.actionMap)) { + const el = this.block.querySelector(key); + if (!el) return; + switch (true) { + case el.nodeName === 'A': + el.href = '#'; + el.addEventListener('click', async (e) => { + e.preventDefault(); + await this.psActionMaps(values, e); + }); + break; + case el.nodeName === 'INPUT': + el.addEventListener('change', async (e) => { + await this.psActionMaps(values, e); + }); + break; + default: + break; + } + } + } + + getImageBlobData(url) { + return new Promise((res, rej) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.responseType = 'blob'; + xhr.onload = () => { + if (xhr.status === 200) res(xhr.response); + else rej(xhr.status); + }; + xhr.send(); + }); + } + + async uploadImgToUnity(storageUrl, id, blobData, fileType) { + const uploadOptions = { + method: 'PUT', + headers: { 'Content-Type': fileType }, + body: blobData, + }; + const response = await fetch(storageUrl, uploadOptions); + if (response.status != 200) return ''; + return id; + } + + getFileType() { + if (this.operations.length) { + const lastOperation = this.operations[this.operations.length - 1]; + if (lastOperation.operationType == 'upload') return lastOperation.fileType; + } + return 'image/jpeg'; + } + + async scanImgForSafety(assetId) { + const assetData = { assetId, targetProduct: this.workflowCfg.productName }; + const optionsBody = { body: JSON.stringify(assetData) }; + try { + this.serviceHandler.postCallToService( + this.psApiConfig.psEndPoint.acmpCheck, + optionsBody, + ); + } + catch(e) { + // Finalize Api call + } + } + + async uploadAsset(imgUrl) { + const resJson = await this.serviceHandler.postCallToService( + this.psApiConfig.psEndPoint.assetUpload, + {}, + ); + const { id, href } = resJson; + const blobData = await this.getImageBlobData(imgUrl); + const fileType = this.getFileType(); + const assetId = await this.uploadImgToUnity(href, id, blobData, fileType); + const { origin } = new URL(imgUrl); + if ((imgUrl.startsWith('blob:')) || (origin != window.location.origin)) this.scanImgForSafety(assetId); + return assetId; + } + + userImgUpload(params, e) { + this.canvasArea.querySelector('img').style.filter = ''; + this.operations = []; + const file = e.target.files[0]; + if (!file) return; + if (file.size > 400000000) { + // unityEl.dispatchEvent(new CustomEvent(errorToastEvent, { detail: { msg: eft } })); + // return; + } + const objUrl = URL.createObjectURL(file); + const { target } = params; + target.src = objUrl; + } + + async removeBackground(params) { + const optype = 'removeBackground'; + let { source, target } = params; + if (typeof(source) == 'string') source = this.block.querySelector(source); + if (typeof(target) == 'string') target = this.block.querySelector(target); + const operationItem = { + operationType: optype, + sourceAssetId: null, + sourceSrc: source.src, + assetId: null, + assetUrl: null, + }; + let assetId = null; + if (this.operations.length) assetId = this.operations[this.operations - 1].assetId; + else assetId = await this.uploadAsset(source.src); + operationItem.sourceAssetId = assetId; + const removeBgOptions = { body: `{"surfaceId":"Unity","assets":[{"id": "${assetId}"}]}` }; + const resJson = await this.serviceHandler.postCallToService( + this.psApiConfig.psEndPoint[optype], + removeBgOptions, + ); + const opId = resJson.assetId; + operationItem.assetId = opId; + operationItem.assetUrl = resJson.outputUrl; + target.src = resJson.outputUrl; + await loadImg(target); + this.operations.push(operationItem); + } + + async changeBackground(params) { + const opType = 'changeBackground'; + let { source, target, backgroundSrc} = params; + if (typeof(source) == 'string') source = this.block.querySelector(source); + if (typeof(target) == 'string') target = this.block.querySelector(target); + if (typeof(backgroundSrc) == 'string' && !backgroundSrc.startsWith("http")) backgroundSrc = this.block.querySelector(backgroundSrc); + const operationItem = { + operationType: opType, + sourceSrc: source.src, + backgroundSrc: backgroundSrc.src, + assetId: null, + assetUrl: null, + fgId: null, + bgId: null, + }; + const fgId = this.operations[this.operations.length - 1].assetId; + const bgId = await this.uploadAsset(backgroundSrc); + const changeBgOptions = { + body: `{ + "assets": [{ "id": "${fgId}" },{ "id": "${bgId}" }], + "metadata": { + "foregroundImageId": "${fgId}", + "backgroundImageId": "${bgId}" + } + }`, + }; + const resJson = await this.serviceHandler.postCallToService( + this.psApiConfig.psEndPoint[opType], + changeBgOptions, + ); + const changeBgId = resJson.assetId; + operationItem.assetId = changeBgId; + operationItem.assetUrl = resJson.outputUrl; + operationItem.fgId = fgId; + operationItem.bgId = bgId; + target.src = resJson.outputUrl; + await loadImg(target); + this.operations.push(operationItem); + } + + getFilterAttrValue(currFilter, filterName, value) { + if (!currFilter) return value; + const filterVals = currFilter.split(' '); + let hasFilter = false; + filterVals.forEach((f, i) => { + if (f.match(filterName)) { + hasFilter = true; + filterVals[i] = value; + } + }); + if (!hasFilter) filterVals.push(value); + return filterVals.join(' '); + } + + changeAdjustments(value, params) { + const { filterType, target } = params; + const operationItem = { + operationType: 'imageAdjustment', + adjustmentType: filterType, + filterValue: params, + }; + const currFilter = target.style.filter; + switch (filterType) { + case 'hue': + target.style.filter = this.getFilterAttrValue(currFilter, 'hue-rotate', `hue-rotate(${value}deg)`); + break; + case 'saturation': + target.style.filter = this.getFilterAttrValue(currFilter, 'saturate', `saturate(${value}%)`); + break; + default: + break; + } + this.operations.push(operationItem); + } + + continueInApp() { + const cOpts = { + assetId: null, + targetProduct: this.workflowCfg.productName, + payload: { + finalAssetId: null, + operations: [], + }, + }; + this.operations.forEach((op, i) => { + const idx = cOpts.payload.operations.length; + if ((i > 0) && (this.operations[i - 1].operationType == op.operationType)) { + cOpts.payload.operations[idx - 1][op.adjustmentType] = parseInt(op.filterValue.sliderElem.value, 10); + } else { + cOpts.payload.operations.push({ name: op.operationType }); + if (op.sourceAssetId && !cOpts.assetId) cOpts.assetId = op.sourceAssetId; + if (op.assetId) cOpts.payload.finalAssetId = op.assetId; + if (op.operationType == 'changeBackground') cOpts.payload.operations[idx].assetIds = [op.assetId]; + if (op.adjustmentType && op.filterValue) { + cOpts.payload.operations[idx][op.adjustmentType] = parseInt(op.filterValue.sliderElem.value, 10); + } + } + }); + this.serviceHandler.postCallToService( + this.psApiConfig.connectorApiEndPoint, + { body: JSON.stringify(cOpts) }, + ); + } + + async createSpectrumProgress() { + await new Promise((resolve) => { + loadStyle(`${getUnityLibs()}/core/features/progress-circle/progress-circle.css`, resolve); + }); + const pdom = `
+
+
+
+
+
+
+
+
+
+
+
+
`; + const loader = createTag( + 'div', + { class: 'progress-circle' }, + createTag('div', { class: 'spectrum-ProgressCircle spectrum-ProgressCircle--indeterminate' }, pdom), + ); + return loader; + } +} diff --git a/unitylibs/core/workflow/workflow-photoshop/service-handler.js b/unitylibs/core/workflow/workflow-photoshop/service-handler.js new file mode 100644 index 0000000..08f2ae1 --- /dev/null +++ b/unitylibs/core/workflow/workflow-photoshop/service-handler.js @@ -0,0 +1,75 @@ +/* eslint-disable eqeqeq */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +/* eslint-disable no-restricted-syntax */ + +import { + getUnityLibs, + createTag, + getGuestAccessToken, + decorateDefaultLinkAnalytics, + unityConfig, +} from '../../../scripts/utils.js'; + +export default class ServiceHandler { + constructor(renderWidget = false, canvasArea = null) { + this.renderWidget = renderWidget; + this.errorToastEl = null; + this.canvasArea = canvasArea; + } + + getHeaders() { + return { + headers: { + 'Content-Type': 'application/json', + Authorization: getGuestAccessToken(), + 'x-api-key': unityConfig.apiKey, + }, + }; + } + + async postCallToService(api, options) { + const postOpts = { + method: 'POST', + ...this.getHeaders(), + ...options, + }; + try { + const response = await fetch(api, postOpts); + const resJson = await response.json(); + return resJson; + } catch (err) { + if (this.renderWidget) await this.errorToast(err); + } + return {}; + } + + async errorToast(e) { + console.log(e); + if (!this.errorToastEl) { + this.errorToastEl = await this.createErrorToast(); + this.canvasArea.append(this.errorToastEl); + } + } + + async createErrorToast() { + const [alertImg, closeImg] = await Promise.all([ + fetch(`${getUnityLibs()}/img/icons/alert.svg`).then((res) => res.text()), + fetch(`${getUnityLibs()}/img/icons/close.svg`).then((res) => res.text()), + ]); + const alertContent = createTag('div', { class: 'alert-content' }); + const errholder = createTag('div', { class: 'alert-holder' }, createTag('div', { class: 'alert-toast' }, alertContent)); + const alertIcon = createTag('div', { class: 'alert-icon' }, alertImg); + const alertText = createTag('div', { class: 'alert-text' }, createTag('p', {}, 'Alert Text')); + alertIcon.append(alertText); + const alertClose = createTag('a', { class: 'alert-close', href: '#' }, closeImg); + alertClose.append(createTag('span', { class: 'alert-close-text' }, 'Close error toast')); + alertContent.append(alertIcon, alertClose); + alertClose.addEventListener('click', (e) => { + e.preventDefault(); + e.target.closest('.alert-holder').classList.remove('show'); + }); + decorateDefaultLinkAnalytics(errholder); + return errholder; + } +} diff --git a/unitylibs/core/workflow/workflow-photoshop/target-config.json b/unitylibs/core/workflow/workflow-photoshop/target-config.json new file mode 100644 index 0000000..54f1abf --- /dev/null +++ b/unitylibs/core/workflow/workflow-photoshop/target-config.json @@ -0,0 +1,76 @@ +{ + "marquee.removebg": { + "type": "img", + "selector": ".asset picture, .image picture", + "handler": "render", + "renderWidget": false, + "source": ".asset picture img", + "target": ".asset picture img", + "actionMap": { + ".action-area .con-button": [ + { + "actionType": "setCssStyle", + "propertyName": "opacity", + "propertyValue": 0.5, + "targets": [".asset picture img, .image picture img"] + }, + { + "itemType": "button", + "actionType": "removebg", + "source": ".asset picture img, .image picture img", + "target": ".asset picture img, .image picture img" + }, + { + "actionType": "setCssStyle", + "propertyName": "opacity", + "propertyValue": 1, + "targets": [".asset picture img, .image picture img"] + } + ] + } + }, + "brick.brickunityconfig": { + "type": "img", + "selector": ".brick-media picture", + "handler": "render", + "renderWidget": false, + "source": ".brick-media picture img", + "target": ".brick-media picture img", + "actionMap": { + ".action-area .con-button": [ + { + "actionType": "setCssStyle", + "propertyName": "opacity", + "propertyValue": 0.5, + "targets": [".brick-media picture img"] + }, + { + "itemType": "button", + "actionType": "removebg", + "source": ".brick-media picture img", + "target": ".brick-media picture img" + }, + { + "actionType": "setCssStyle", + "propertyName": "opacity", + "propertyValue": 1, + "targets": [".brick-media picture img"] + } + ] + } + }, + "marquee": { + "type": "img", + "selector": ".asset picture, .image picture", + "handler": "render", + "renderWidget": true, + "source": ".asset picture img", + "target": ".asset picture img" + }, + "aside": { + "type": "img", + "selector": ".asset picture, .image picture", + "handler": "render", + "renderWidget": true + } +} diff --git a/unitylibs/core/workflow/workflow-photoshop/widget.css b/unitylibs/core/workflow/workflow-photoshop/widget.css new file mode 100644 index 0000000..8d5a508 --- /dev/null +++ b/unitylibs/core/workflow/workflow-photoshop/widget.css @@ -0,0 +1,160 @@ +.unity-enabled .interactive-area .unity-option-area .changebg-options-tray { + display: none; + flex-direction: row; + padding: 8px; +} + +.unity-enabled .interactive-area .unity-option-area .changebg-options-tray.show { + display: flex; +} + +.unity-enabled .interactive-area .unity-option-area .changebg-options-tray .changebg-option { + border-radius: 5.36px; + cursor: pointer; + border: 3px solid transparent; + overflow: hidden; + position: relative; + top: 0; + left: 0; + object-fit: cover; + height: 67px; +} + +.unity-enabled .interactive-area .unity-option-area .changebg-options-tray .changebg-option:focus-visible { + outline: none; +} + +.unity-enabled .interactive-area .unity-option-area .adjustment-options-tray { + display: none; + flex-direction: column; + gap: 15px; + padding: 16px 16px 0; +} + +.unity-enabled .interactive-area .unity-option-area .adjustment-options-tray.show { + display: flex; +} + +.unity-enabled .interactive-area .unity-option-area .adjustment-options-tray .adjustment-container { + height: 2px; + border-radius: 1px; + margin: 7px 0; + position: relative; +} + +.unity-enabled .interactive-area .unity-option-area .adjustment-options-tray .adjustment-label { + font-size: var(--type-body-m-size); + line-height: var(--type-body-m-lh); +} + +.unity-enabled .interactive-area .unity-option-area .adjustment-options-tray .adjustment-container.hue { + background: linear-gradient(90deg, #E90B03 0%, #EF9100 9%, #FFE003 20.5%, #5AFE00 32.5%, #00FF84 43.5%, #00F1EF 53.5%, #001AFF 66.86%, #8700FC 77.17%, #FD00D6 89.5%, #FF0015 100%); +} + +.unity-enabled .interactive-area .unity-option-area .adjustment-options-tray .adjustment-container.saturation { + background: linear-gradient(90deg, #000 0%, #F20000 100%); +} + +.unity-enabled .interactive-area .unity-option-area .adjustment-options-tray .adjustment-slider { + width: 100%; + opacity: 0; + position: absolute; + top: -7px; + z-index: 1; + margin: 0; +} + +.unity-enabled .interactive-area .unity-option-area .adjustment-options-tray .adjustment-slider::-webkit-slider-thumb { + height: 20px; + width: 20px; + position: relative; + border-radius: 50%; + appearance: none; + background: transparent; + cursor: grab; + z-index: 2; +} + +.unity-enabled .interactive-area .unity-option-area .adjustment-options-tray .adjustment-circle { + position: absolute; + block-size: 16px; + inline-size: 16px; + background-color: #A2A2A2; + border-radius: 50%; + top: -7px; + inset-block-start: 50%; + inset-inline-start: 50%; + transform: translate(-50%, -50%); +} + +.unity-enabled .interactive-area .unity-option-area .adjustment-options-tray .adjustment-circle .analytics-content { + display: none; +} + +.unity-enabled .interactive-area .unity-option-area .changebg-options-tray .changebg-option:focus-visible, +.unity-enabled .interactive-area .unity-option-area .changebg-options-tray .changebg-option:hover { + border: 3px solid var(--highlight-blue); +} + +@media screen and (max-width: 600px) { + .unity-enabled .interactive-area .unity-option-area .changebg-options-tray { + padding: 5px 5px 0; + gap: 9px; + border-radius: 8px 8px 0 0; + } + + .unity-enabled .interactive-area .unity-option-area .changebg-options-tray .changebg-option { + width: 100%; + } + + .unity-enabled .interactive-area .unity-option-area .changebg-options-tray .changebg-option img { + width: 100%; + height: 67px; + min-height: 67px; + max-height: 67px; + position: relative; + top: 0; + left: 0; + object-fit: cover; + } + + .unity-enabled .interactive-area .unity-action-btn:active .btn-text, + .unity-enabled .interactive-area .unity-action-btn:active .btn-icon svg path { + color: var(--highlight-blue); + fill: var(--highlight-blue); + } +} + +@media screen and (min-width: 600px) and (min-width: 1200px) { + .aside.split.unity-enabled .split-image img { + position: relative; + } +} + +@media screen and (min-width: 600px) { + .unity-enabled .interactive-area .unity-option-area .changebg-options-tray { + gap: 2px; + width: fit-content; + border-radius: 8px; + padding: 5px; + } + + .unity-enabled .interactive-area .unity-option-area .changebg-options-tray img { + height: 67px; + min-height: 67px; + max-height: 67px; + width: 67px; + min-width: 67px; + max-width: 67px; + } + + .unity-enabled .interactive-area .unity-option-area .adjustment-options-tray { + padding: 8px 16px; + width: 212px; + min-width: 212px; + } + + .unity-enabled .interactive-area .unity-option-area .adjustment-options-tray .adjustment-label { + font-weight: bold; + } +} diff --git a/unitylibs/core/workflow/workflow-photoshop/widget.js b/unitylibs/core/workflow/workflow-photoshop/widget.js new file mode 100644 index 0000000..0eb545f --- /dev/null +++ b/unitylibs/core/workflow/workflow-photoshop/widget.js @@ -0,0 +1,313 @@ +import { + createTag, + decorateDefaultLinkAnalytics, + loadSvgs, +} from '../../../scripts/utils.js'; + +export default class UnityWidget { + constructor(target, el, workflowCfg) { + this.el = el; + this.target = target; + this.workflowCfg = workflowCfg; + this.widget = null; + this.actionMap = {}; + } + + async initWidget() { + const iWidget = createTag('div', { class: 'unity-widget' }); + const unityaa = createTag('div', { class: 'unity-action-area' }); + const unityoa = createTag('div', { class: 'unity-option-area' }); + iWidget.append(unityoa, unityaa); + const refreshCfg = this.el.querySelector('.icon-product-icon'); + if (refreshCfg) await this.addRestartOption(refreshCfg.closest('li'), unityaa); + this.workflowCfg.enabledFeatures.forEach((f, idx) => { + const addClasses = idx === 0 ? 'ps-action-btn show' : 'ps-action-btn'; + this.addFeatureButtons( + f, + this.workflowCfg.featureCfg[idx], + unityaa, + unityoa, + addClasses, + ); + }); + const uploadCfg = this.el.querySelector('.icon-upload'); + if (uploadCfg) this.addFeatureButtons('upload', uploadCfg.closest('li'), unityaa, unityoa, 'show'); + const continueInApp = this.el.querySelector('.icon-app-connector'); + if (continueInApp) this.addFeatureButtons('continue-in-app', continueInApp.closest('li'), unityaa, unityoa, ''); + this.widget = iWidget; + const svgs = iWidget.querySelectorAll('.show img[src*=".svg"'); + await loadSvgs(svgs); + this.target.append(iWidget); + decorateDefaultLinkAnalytics(iWidget); + return this.actionMap; + } + + createActionBtn(btnCfg, btnClass) { + const txt = btnCfg.innerText; + const img = btnCfg.querySelector('img[src*=".svg"]'); + const actionBtn = createTag('a', { href: '#', class: `unity-action-btn ${btnClass}` }); + let swapOrder = false; + if (img) { + actionBtn.append(createTag('div', { class: 'btn-icon' }, img)); + if (img.nextSibling?.nodeName == '#text') swapOrder = true; + } + if (txt) { + const btnTxt = createTag('div', { class: 'btn-text' }, txt.split('\n')[0].trim()); + if (swapOrder) actionBtn.prepend(btnTxt); + else actionBtn.append(btnTxt); + } + return actionBtn; + } + + initRefreshActionMap(w) { + this.actionMap[w] = [ + { + actionType: 'hide', + targets: ['.ps-action-btn.show', '.unity-option-area .show', '.continue-in-app-button'], + }, { + actionType: 'show', + targets: ['.ps-action-btn'], + }, { + actionType: 'refresh', + sourceSrc: this.el.querySelector('img').src, + target: this.target.querySelector('img'), + }, + ]; + } + + async addRestartOption(refreshCfg, unityaa) { + const [prodIcon, refreshIcon] = refreshCfg.querySelectorAll('img[src*=".svg"]'); + const iconHolder = createTag('div', { class: 'widget-product-icon show' }, prodIcon); + const refreshAnalyics = createTag('div', { class: 'widget-refresh-text' }, 'Restart'); + const refreshHolder = createTag('a', { href: '#', class: 'widget-refresh-button' }, refreshIcon); + refreshHolder.append(refreshAnalyics); + unityaa.append(iconHolder); + const mobileRefreshHolder = refreshHolder.cloneNode(true); + [refreshHolder, mobileRefreshHolder].forEach((w) => { + w.addEventListener('click', () => { + this.target.querySelector('img').style.filter = ''; + iconHolder.classList.add('show'); + refreshHolder.classList.remove('show'); + mobileRefreshHolder.classList.remove('show'); + }); + }); + this.initRefreshActionMap('.unity-action-area .widget-refresh-button'); + this.initRefreshActionMap('.interactive-area > .widget-refresh-button'); + unityaa.append(refreshHolder); + this.target.append(mobileRefreshHolder); + } + + addFeatureButtons(featName, authCfg, actionArea, optionArea, addClasses) { + const btn = this.createActionBtn(authCfg, `${featName}-button ${addClasses}`); + actionArea.append(btn); + if (!authCfg.querySelector('ul')) { + switch (featName) { + case 'removebg': + this.initRemoveBgActions(featName, btn); + break; + case 'upload': + const inpel = createTag('input', { class: 'file-upload', type: 'file', accept: 'image/png,image/jpg,image/jpeg', tabIndex: -1 }); + btn.append(inpel); + btn.addEventListener('keydown', (e) => { + if (e.key === 'Enter') inpel.click(); + }); + this.initUploadActions(featName); + break; + case 'continue-in-app': + this.initContinueInAppActions(featName); + break; + default: + break; + } + return; + } + this.addFeatureTray(featName, authCfg, optionArea, btn, addClasses); + } + + initRemoveBgActions(featName, btn) { + this.actionMap[`.${featName}-button`] = [ + { + actionType: 'show', + targets: ['.progress-circle'], + }, { + itemType: 'button', + actionType: featName, + source: this.target.querySelector('img'), + target: this.target.querySelector('img'), + }, { + actionType: 'show', + targets: ['.ps-action-btn.show + .ps-action-btn', '.changebg-options-tray', '.continue-in-app-button'], + }, { + actionType: 'hide', + targets: [btn, '.progress-circle'], + }, + ]; + } + + initChangeBgActions(key, btn, bgImg, bgSelectorTray) { + this.actionMap[key] = [ + { + actionType: 'show', + targets: ['.progress-circle'], + }, { + itemType: 'button', + actionType: 'changebg', + backgroundSrc: bgImg.src, + source: this.target.querySelector('img'), + target: this.target.querySelector('img'), + }, { + actionType: 'show', + targets: ['.ps-action-btn.show + .ps-action-btn', '.adjustment-options-tray', '.continue-in-app-button'], + }, { + actionType: 'hide', + targets: [btn, bgSelectorTray, '.progress-circle'], + }, + ]; + } + + initUploadActions(featName) { + this.actionMap[`.${featName}-button .file-upload`] = [ + { + actionType: 'show', + targets: ['.progress-circle'], + }, { + itemType: 'button', + actionType: 'upload', + assetType: 'img', + target: this.target.querySelector('img'), + }, { + itemType: 'button', + actionType: 'removebg', + source: this.target.querySelector('img'), + target: this.target.querySelector('img'), + }, { + actionType: 'show', + targets: ['.changebg-button'], + }, { + actionType: 'hide', + targets: ['.ps-action-btn.show', '.unity-option-area > div.show', '.progress-circle'], + }, + ]; + } + + initContinueInAppActions(featName) { + this.actionMap[`.${featName}-button`] = [ + { + itemType: 'button', + actionType: 'continueInApp', + appName: 'Photoshop', + }, + ]; + } + + addFeatureTray(featName, authCfg, optionArea, btn, addClasses) { + switch (featName) { + case 'changebg': { + const tray = this.addChangeBgTray(btn, authCfg, optionArea, addClasses.indexOf('show') > -1); + this.actionMap[`.${featName}-button`] = [ + { + actionType: 'toggle', + targets: [tray], + }, + ]; + break; + } + case 'slider': { + const tray = this.addAdjustmentTray(btn, authCfg, optionArea, addClasses.indexOf('show') > -1); + this.actionMap[`.${featName}-button`] = [ + { + actionType: 'toggle', + targets: [tray], + }, + ]; + break; + } + default: + break; + } + } + + addChangeBgTray(btn, authCfg, optionArea, isVisible) { + const bgSelectorTray = createTag('div', { class: `changebg-options-tray ${isVisible ? 'show' : ''}` }); + const bgOptions = authCfg.querySelectorAll(':scope ul li'); + [...bgOptions].forEach((o, num) => { + let thumbnail = null; + let bgImg = null; + [thumbnail, bgImg] = o.querySelectorAll('img'); + if (!bgImg) bgImg = thumbnail; + thumbnail.dataset.backgroundImg = bgImg.src; + const optionSelector = `changebg-option option-${num}`; + const a = createTag('a', { href: '#', class: optionSelector }, thumbnail); + bgSelectorTray.append(a); + this.initChangeBgActions(`.changebg-option.option-${num}`, btn, bgImg, bgSelectorTray); + a.addEventListener('click', (e) => { e.preventDefault(); }); + }); + optionArea.append(bgSelectorTray); + return bgSelectorTray; + } + + addAdjustmentTray(btn, authCfg, optionArea, isVisible) { + const sliderTray = createTag('div', { class: `adjustment-options-tray ${isVisible ? 'show' : ''}` }); + const sliderOptions = authCfg.querySelectorAll(':scope > ul li'); + [...sliderOptions].forEach((o) => { + let iconName = null; + const psAction = o.querySelector(':scope > .icon'); + [...psAction.classList].forEach((cn) => { if (cn.match('icon-')) iconName = cn; }); + const [, actionName] = iconName.split('-'); + switch (actionName) { + case 'hue': + this.createSlider(sliderTray, 'hue', o.innerText, -180, 180); + break; + case 'saturation': + this.createSlider(sliderTray, 'saturation', o.innerText, 0, 300); + break; + default: + break; + } + }); + optionArea.append(sliderTray); + return sliderTray; + } + + createSlider(tray, propertyName, label, minVal, maxVal) { + const actionDiv = createTag('div', { class: 'adjustment-option' }); + const actionLabel = createTag('label', { class: 'adjustment-label' }, label); + const actionSliderDiv = createTag('div', { class: `adjustment-container ${propertyName}` }); + const actionSliderInput = createTag('input', { + type: 'range', + min: minVal, + max: maxVal, + value: (minVal + maxVal) / 2, + class: `adjustment-slider ${propertyName}`, + }); + const actionAnalytics = createTag('div', { class: 'analytics-content' }, `Adjust ${label} slider`); + const actionSliderCircle = createTag('a', { href: '#', class: `adjustment-circle ${propertyName}` }, actionAnalytics); + actionSliderDiv.append(actionSliderInput, actionSliderCircle); + actionDiv.append(actionLabel, actionSliderDiv); + this.actionMap[`.adjustment-slider.${propertyName}`] = [ + { + actionType: 'show', + targets: ['.continue-in-app-button'], + }, { + itemType: 'slider', + actionType: 'imageAdjustment', + filterType: propertyName, + sliderElem: actionSliderInput, + target: this.target.querySelector('img'), + }, + ]; + actionSliderInput.addEventListener('input', () => { + const { value } = actionSliderInput; + const centerOffset = (value - minVal) / (maxVal - minVal); + const moveCircle = 3 + (centerOffset * 94); + actionSliderCircle.style.left = `${moveCircle}%`; + }); + actionSliderInput.addEventListener('change', () => { + actionSliderCircle.click(); + }); + actionSliderCircle.addEventListener('click', (evt) => { + evt.preventDefault(); + }); + tray.append(actionDiv); + } +} diff --git a/unitylibs/core/workflow/workflow.js b/unitylibs/core/workflow/workflow.js index 3d9ae73..f447114 100644 --- a/unitylibs/core/workflow/workflow.js +++ b/unitylibs/core/workflow/workflow.js @@ -6,6 +6,7 @@ import { unityConfig, defineDeviceByScreenSize, getConfig, + priorityLoad, } from '../../scripts/utils.js'; export function getImgSrc(pic) { @@ -89,6 +90,7 @@ function getWorkFlowInformation(el) { changebg: { endpoint: 'providers/PhotoshopChangeBackground' }, slider: {}, }, + 'workflow-acrobat': {}, }; [...el.classList].forEach((cn) => { if (cn.match('workflow-')) wfName = cn; }); if (!wfName || !workflowCfg[wfName]) return []; @@ -107,25 +109,208 @@ async function initWorkflow(cfg) { }, { once: true }); } -export default async function init(el, project = 'unity', unityLibs = '/unitylibs') { +class WfInitiator { + constructor() { + this.el = null; + this.targetBlock = {}; + this.unityLibs = '/unityLibs'; + this.interactiveArea = null; + this.project = 'unity'; + this.targetConfig = {}; + this.operations = {}; + this.actionMap = {}; + } + + async priorityLibFetch(renderWidget, workflowName) { + const priorityList = [ + `${getUnityLibs()}/core/workflow/${workflowName}/action-binder.js`, + ]; + if (renderWidget) { + priorityList.push( + `${getUnityLibs()}/core/workflow/${workflowName}/widget.css`, + `${getUnityLibs()}/core/workflow/${workflowName}/widget.js`, + ); + } + await priorityLoad(priorityList); + } + + async init(el, project = 'unity', unityLibs = '/unitylibs', langRegion, langCode) { + setUnityLibs(unityLibs, project); + this.el = el; + this.unityLibs = unityLibs; + this.project = project; + this.enabledFeatures = []; + this.workflowCfg = this.getWorkFlowInformation(); + this.workflowCfg.langRegion = langRegion; + this.workflowCfg.langCode = langCode; + [this.targetBlock, this.interactiveArea, this.targetConfig] = await this.getTarget(); + this.getEnabledFeatures(); + this.callbackMap = {}; + this.workflowCfg.targetCfg = this.targetConfig; + await this.priorityLibFetch(this.targetConfig.renderWidget, this.workflowCfg.name); + if (this.targetConfig.renderWidget) { + loadStyle(`${getUnityLibs()}/core/workflow/${this.workflowCfg.name}/widget.css`); + const { default: UnityWidget } = await import(`${getUnityLibs()}/core/workflow/${this.workflowCfg.name}/widget.js`); + this.actionMap = await new UnityWidget( + this.interactiveArea, + this.el, + this.workflowCfg, + ).initWidget(); + } else { + this.actionMap = this.targetConfig.actionMap; + } + this.limits = this.targetConfig.limits; + const { default: ActionBinder } = await import(`${getUnityLibs()}/core/workflow/${this.workflowCfg.name}/action-binder.js`); + await new ActionBinder( + this.el, + this.workflowCfg, + this.targetBlock, + this.interactiveArea, + this.actionMap, + this.limits, + ).initActionListeners(); + } + + checkRenderStatus(block, selector, res, rej, etime, rtime) { + if (etime > 20000) { rej(); return; } + if (block.querySelector(selector)) res(); + else setTimeout(() => this.checkRenderStatus(block, selector, res, rej, etime + rtime), rtime); + } + + intEnbReendered(block, selector) { + return new Promise((res, rej) => { + try { + this.checkRenderStatus(block, selector, res, rej, 0, 100); + } catch (err) { rej(); } + }); + } + + async getTarget() { + const res = await fetch(`${getUnityLibs()}/core/workflow/${this.workflowCfg.name}/target-config.json`); + const targetConfig = await res.json(); + const prevElem = this.el.previousElementSibling; + const supportedBlocks = Object.keys(targetConfig); + let targetCfg = null; + for (let k = 0; k < supportedBlocks.length; k += 1) { + const classes = supportedBlocks[k].split('.'); + let hasAllClasses = true; + for (let c of classes) { + const hasClass = prevElem.classList.contains(c); + const hasChild = prevElem.querySelector(`.${c}`); + if (!(hasClass || hasChild)) { + hasAllClasses = false; + break; + } + } + if (hasAllClasses) { + targetCfg = targetConfig[supportedBlocks[k]]; + break; + } + } + if (!targetCfg) return [null, null, null]; + await this.intEnbReendered(prevElem, targetCfg.selector); + let ta = null; + ta = this.createInteractiveArea(prevElem, targetCfg.selector, targetCfg); + prevElem.classList.add('unity-enabled'); + return [prevElem, ta, targetCfg]; + } + + getImgSrc(pic) { + const viewport = defineDeviceByScreenSize(); + let source = ''; + if (viewport === 'MOBILE') source = pic.querySelector('source[type="image/webp"]:not([media])'); + else source = pic.querySelector('source[type="image/webp"][media]'); + return source ? source.srcset : pic.querySelector('img').src; + } + + createInteractiveArea(block, selector, targetCfg) { + const iArea = createTag('div', { class: 'interactive-area' }); + const asset = block.querySelector(selector); + if (asset.nodeName === 'PICTURE') { + asset.querySelector('img').src = this.getImgSrc(asset); + [...asset.querySelectorAll('source')].forEach((s) => s.remove()); + const newPic = asset.cloneNode(true); + this.el.querySelector(':scope > div > div').prepend(newPic); + } + if (!targetCfg.renderWidget) return null; + asset.insertAdjacentElement('beforebegin', iArea); + iArea.append(asset); + if (this.el.classList.contains('light')) iArea.classList.add('light'); + else iArea.classList.add('dark'); + return iArea; + } + + getWorkFlowInformation() { + let wfName = ''; + const workflowCfg = { + 'workflow-photoshop': { + productName: 'Photoshop', + sfList: new Set(['removebg', 'changebg', 'slider']), + }, + 'workflow-acrobat': { + productName: 'acrobat', + sfList: new Set(['fillsign']), + } + }; + [...this.el.classList].forEach((cn) => { if (cn.match('workflow-')) wfName = cn; }); + if (!wfName || !workflowCfg[wfName]) return []; + return { + name: wfName, + productName: workflowCfg[wfName].productName, + supportedFeatures: workflowCfg[wfName].sfList, + enabledFeatures: [], + featureCfg: [], + errors: {}, + }; + } + + getEnabledFeatures() { + const { supportedFeatures } = this.workflowCfg; + const configuredFeatures = this.el.querySelectorAll(':scope > div > div > ul > li > span.icon'); + configuredFeatures.forEach((cf) => { + const cfName = [...cf.classList].find((cn) => cn.match('icon-')); + if (!cfName) return; + const fn = cfName.split('-')[1]; + if (supportedFeatures.has(fn)) { + this.workflowCfg.enabledFeatures.push(fn); + this.workflowCfg.featureCfg.push(cf.closest('li')); + } else if (fn.includes('error')) { + this.workflowCfg.errors[fn] = cf.closest('li').innerText; + } + }); + } +} + + +export default async function init(el, project = 'unity', unityLibs = '/unitylibs', unityVersion = 'v1', langRegion = 'us', langCode = 'en') { + const uv = new URLSearchParams(window.location.search).get('unityversion') || unityVersion; const { imsClientId } = getConfig(); if (imsClientId) unityConfig.apiKey = imsClientId; setUnityLibs(unityLibs, project); - const [targetBlock, unityWidget] = await getTargetArea(el); - if (!targetBlock) return; - const [wfName, wfDetail] = getWorkFlowInformation(el); - if (!wfName || !wfDetail) return; - const enabledFeatures = getEnabledFeatures(el, wfDetail); - if (!enabledFeatures) return; - const wfConfig = { - unityEl: el, - targetEl: targetBlock, - unityWidget, - wfName, - wfDetail, - enabledFeatures, - uploadState: { }, - ...unityConfig, - }; - await initWorkflow(wfConfig); + switch (uv) { + case 'v1': + const [targetBlock, unityWidget] = await getTargetArea(el); + if (!targetBlock) return; + const [wfName, wfDetail] = getWorkFlowInformation(el); + if (!wfName || !wfDetail) return; + const enabledFeatures = getEnabledFeatures(el, wfDetail); + if (!enabledFeatures) return; + const wfConfig = { + unityEl: el, + targetEl: targetBlock, + unityWidget, + wfName, + wfDetail, + enabledFeatures, + uploadState: { }, + ...unityConfig, + }; + await initWorkflow(wfConfig); + break; + case 'v2': + await new WfInitiator().init(el, project, unityLibs, langRegion, langCode); + break; + default: + break; + } } diff --git a/unitylibs/scripts/errors.js b/unitylibs/scripts/errors.js new file mode 100644 index 0000000..148da01 --- /dev/null +++ b/unitylibs/scripts/errors.js @@ -0,0 +1,45 @@ +import { getConfig } from './utils.js'; + +function createErrorMap(errorList) { + const errorMap = {}; + if (Array.isArray(errorList)) { + errorList.forEach((error) => { + if (error['title.single.code']) { + errorMap[error['title.single.code']] = error['title.single.message']; + } + if (error['title.multi.code']) { + errorMap[error['title.multi.code']] = error['title.multi.message']; + } + if (error['body.single.code']) { + errorMap[error['body.single.code']] = error['body.single.message']; + } + if (error['body.multi.code']) { + errorMap[error['body.multi.code']] = error['body.multi.message']; + } + }); + } + return errorMap; +} + +async function loadErrorMessages(verb) { + const { locale } = getConfig(); + const baseUrl = 'https://main--unity--adobecom.hlx.live'; + const errorFile = locale.prefix && locale.prefix !== '/' + ? `${baseUrl}/${locale.prefix}/configs/errors/${verb}.json` + : `${baseUrl}/unity/configs/errors/${verb}.json`; + const errorRes = await fetch(errorFile); + if (!errorRes.ok) { + throw new Error('Failed to fetch error messages.'); + } + const errorJson = await errorRes.json(); + window.uem = createErrorMap(errorJson?.data); +} + +export default async function getError(verb, code) { + try { + if (!window.uem) await loadErrorMessages(verb); + return window.uem?.[code]; + } catch (e) { + return ''; + } +} diff --git a/unitylibs/scripts/utils.js b/unitylibs/scripts/utils.js index 6e102e2..643bb23 100644 --- a/unitylibs/scripts/utils.js +++ b/unitylibs/scripts/utils.js @@ -29,8 +29,12 @@ export function decorateArea(area = document) {} const miloLibs = setLibs('/libs'); -const { createTag, getConfig, loadStyle } = await import(`${miloLibs}/utils/utils.js`); -export { createTag, loadStyle, getConfig }; +const { + createTag, getConfig, loadStyle, loadLink, loadScript, localizeLink, loadArea, +} = await import(`${miloLibs}/utils/utils.js`); +export { + createTag, loadStyle, getConfig, loadLink, loadScript, localizeLink, loadArea, +}; const { decorateDefaultLinkAnalytics } = await import(`${miloLibs}/martech/attributes.js`); export { decorateDefaultLinkAnalytics }; @@ -71,6 +75,22 @@ export async function loadSvg(src) { } } +export async function loadSvgs(svgs) { + const promiseArr = []; + [...svgs].forEach((svg) => { + promiseArr.push( + fetch(svg.src) + .then((res) => { + if (res.ok) return res.text(); + else throw new Error('Could not fetch SVG'); + }) + .then((txt) => { svg.parentElement.innerHTML = txt; }) + .catch((e) => { svg.remove(); }), + ); + }); + await Promise.all(promiseArr); +} + export function loadImg(img) { return new Promise((res) => { img.loading = 'eager'; @@ -104,6 +124,22 @@ export async function createActionBtn(btnCfg, btnClass, iconAsImg = false, swapO return actionBtn; } +export async function priorityLoad(parr) { + const promiseArr = []; + parr.forEach((p) => { + if (p.endsWith('.js')) { + const pr = loadScript(p, 'module', { mode: 'async' }); + promiseArr.push(pr); + } else if (p.endsWith('.css')) { + const pr = new Promise((res) => { loadLink(p, { rel: 'stylesheet', callback: res }); }); + promiseArr.push(pr); + } else { + promiseArr.push(fetch(p)); + } + }); + await Promise.all(promiseArr); +} + async function createErrorToast() { const [alertImg, closeImg] = await Promise.all([ fetch(`${getUnityLibs()}/img/icons/alert.svg`).then((res) => res.text()), @@ -179,6 +215,9 @@ export const unityConfig = (() => { apiKey: 'leo', refreshWidgetEvent: 'unity:refresh-widget', interactiveSwitchEvent: 'unity:interactive-switch', + trackAnalyticsEvent: 'unity:track-analytics', + errorToastEvent: 'unity:show-error-toast', + surfaceId: 'unity', }; const cfg = { prod: {