From d0ef459f6b34ae096859f3d22fe21059d7ef45b8 Mon Sep 17 00:00:00 2001 From: Asad Saleem Date: Mon, 4 May 2026 12:44:53 +0500 Subject: [PATCH 1/2] iframe implementation --- .../editors/cloudinary/imageForm.js | 24 +- .../editors/cloudinary/imageForm.json | 8 +- .../editors/cloudinary/studioWidget.js | 8 + .../editors/cloudinary/studioWidget.json | 14 + .../editors/cloudinary/videoForm.js | 33 +- .../editors/cloudinary/videoForm.json | 12 +- .../editors/cloudinary/advancedVideoForm.js | 106 ++++- .../editors/cloudinary/imageFormWidget.js | 340 ++++++++++++++ .../editors/cloudinary/studioWidget.css | 13 + .../editors/cloudinary/studioWidget.js | 173 +++++++ .../editors/cloudinary/videoFormWidget.css | 291 ++++++++++++ .../editors/cloudinary/videoFormWidget.js | 436 ++++++++++++++++++ .../components/assets/mediaLibrary.js | 131 +++++- .../components/assets/mediaLibraryVideo.js | 183 +++++++- .../static/default/js/cloudinaryVideos.js | 37 +- 15 files changed, 1752 insertions(+), 57 deletions(-) create mode 100644 cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.js create mode 100644 cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.json create mode 100644 cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/imageFormWidget.js create mode 100644 cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.css create mode 100644 cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.js create mode 100644 cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.css create mode 100644 cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.js diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.js b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.js index f6a97ce..e44d3e5 100644 --- a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.js +++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.js @@ -3,24 +3,16 @@ var PageMgr = require('dw/experience/PageMgr'); var HashMap = require('dw/util/HashMap'); var cloudinaryApi = require('*/cartridge/scripts/cloudinary/cloudinaryApi'); -var URLUtils = require('dw/web/URLUtils'); -var URLAction = require('dw/web/URLAction'); -var CSRF = require('dw/web/CSRFProtection'); module.exports.init = function (editor) { - var conf = new HashMap(); - var csrf = new HashMap(); - csrf.put(CSRF.getTokenName(), CSRF.generateToken()); - var linkUrlAct = URLUtils.abs('Links-url').toString(); - editor.configuration.put('linkBuilderUrl', linkUrlAct); - editor.configuration.put('csrf', csrf); - conf.put('type', 'image'); editor.configuration.put('cloudName', cloudinaryApi.data.getCloudName()); editor.configuration.put('cname', cloudinaryApi.data.getCloudinaryCNAME()); - editor.configuration.put('globalTrans', cloudinaryApi.globalTransform()); - editor.configuration.put('iFrameEnv', cloudinaryApi.data.getIframeEnv()); - var videoSelector = PageMgr.getCustomEditor('cloudinary.mediaSelector', conf); - var adv = PageMgr.getCustomEditor('cloudinary.advancedImageForm', conf); - editor.dependencies.put('advBreakout', adv); - editor.dependencies.put('breakout', videoSelector); + + var conf = new HashMap(); + conf.put('type', 'image'); + var mediaPicker = PageMgr.getCustomEditor('cloudinary.mediaSelector', conf); + editor.dependencies.put('mediaPicker', mediaPicker); + + var studioWidget = PageMgr.getCustomEditor('cloudinary.studioWidget', new HashMap()); + editor.dependencies.put('studioWidget', studioWidget); }; diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.json b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.json index 0176510..63399f6 100644 --- a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.json +++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/imageForm.json @@ -1,15 +1,13 @@ - { "name": "Cloudinary Image Config Form", "description": "Configure Image", "resources": { "scripts": [ - "https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.2.10/iframeResizer.min.js", - "/experience/editors/cloudinary/utils.js", - "/experience/editors/cloudinary/imageForm.js" + "https://media-library.cloudinary.com/global/all.js", + "/experience/editors/cloudinary/imageFormWidget.js" ], "styles": [ - "/experience/editors/cloudinary/form.css" + "/experience/editors/cloudinary/videoFormWidget.css" ] } } diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.js b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.js new file mode 100644 index 0000000..0e49c2d --- /dev/null +++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.js @@ -0,0 +1,8 @@ +'use strict'; + +var cloudinaryApi = require('*/cartridge/scripts/cloudinary/cloudinaryApi'); + +module.exports.init = function (editor) { + editor.configuration.put('cloudName', cloudinaryApi.data.getCloudName()); + editor.configuration.put('apiKey', cloudinaryApi.data.getAPIKey()); +}; diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.json b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.json new file mode 100644 index 0000000..ccc8fa2 --- /dev/null +++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/studioWidget.json @@ -0,0 +1,14 @@ +{ + "name": "Cloudinary Studio Widget", + "description": "Advanced image editor via Cloudinary Studio Widget", + "resources": { + "scripts": [ + "https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.2.10/iframeResizer.contentWindow.min.js", + "https://studio-widget.cloudinary.com/latest/all.js", + "/experience/editors/cloudinary/studioWidget.js" + ], + "styles": [ + "/experience/editors/cloudinary/studioWidget.css" + ] + } +} diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.js b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.js index ec1cb4a..567f502 100644 --- a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.js +++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.js @@ -5,13 +5,32 @@ var HashMap = require('dw/util/HashMap'); var cloudinaryApi = require('*/cartridge/scripts/cloudinary/cloudinaryApi'); module.exports.init = function (editor) { - var conf = new HashMap(); - conf.put('type', 'video'); editor.configuration.put('cloudName', cloudinaryApi.data.getCloudName()); editor.configuration.put('cname', cloudinaryApi.data.getCloudinaryCNAME()); - var videoSelector = PageMgr.getCustomEditor('cloudinary.mediaSelector', conf); - var adv = PageMgr.getCustomEditor('cloudinary.advancedVideoForm', conf); - editor.dependencies.put('advBreakout', adv); - editor.dependencies.put('breakout', videoSelector); - editor.configuration.put('iFrameEnv', cloudinaryApi.data.getIframeEnv()); + + // Pass default player option values from site preference to the widget + var currentSite = require('dw/system/Site').getCurrent(); + var playerOptionsRaw = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerVideoPlayerOptions'); + var playerOptions = { autoplay: false, muted: false, loop: false, controls: true }; + if (playerOptionsRaw) { + try { + var parsed = JSON.parse(playerOptionsRaw); + var keys = ['autoplay', 'muted', 'loop', 'controls']; + for (var i = 0; i < keys.length; i++) { + var k = keys[i]; + if (k in parsed) { + playerOptions[k] = !!parsed[k]; + } + } + } catch (e) { /* keep defaults on parse error */ } + } + editor.configuration.put('playerOptions', JSON.stringify(playerOptions)); + + var conf = new HashMap(); + conf.put('type', 'video'); + var mediaPicker = PageMgr.getCustomEditor('cloudinary.mediaSelector', conf); + editor.dependencies.put('mediaPicker', mediaPicker); + + var advancedConfig = PageMgr.getCustomEditor('cloudinary.advancedVideoForm', new HashMap()); + editor.dependencies.put('advancedConfig', advancedConfig); }; diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json index fc1b510..aaa505e 100644 --- a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json +++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json @@ -1,15 +1,13 @@ - { - "name": "Cloudinary Image Config Form", - "description": "Configure Image", + "name": "Cloudinary Video Form", + "description": "Configure Image / Video with per-form-factor selection", "resources": { "scripts": [ - "https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.2.10/iframeResizer.min.js", - "/experience/editors/cloudinary/utils.js", - "/experience/editors/cloudinary/videoForm.js" + "https://media-library.cloudinary.com/global/all.js", + "/experience/editors/cloudinary/videoFormWidget.js" ], "styles": [ - "/experience/editors/cloudinary/form.css" + "/experience/editors/cloudinary/videoFormWidget.css" ] } } diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/advancedVideoForm.js b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/advancedVideoForm.js index 49dc0ad..31479b2 100644 --- a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/advancedVideoForm.js +++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/advancedVideoForm.js @@ -1,7 +1,89 @@ +/** + * When the parent editor uses the new per-form-factor format + * ({ formValues: { desktop, mobile, tablet }, posterMode, playerOptions, transformationOverride }) + * the iframe still expects formValues.video.asset.public_id to render its preview. + * + * cldUtils.cleanValue (called inside dehydrate) strips every top-level key except + * "formValues" and "breakpoints", so anything outside formValues is lost. + * We therefore map the resolved asset back into formValues.video.asset which is + * exactly what the iframe reads. + */ +function normalizeValueForIframe(value) { + if (!value || !value.formValues) return value; + var fv = value.formValues; + + // Already in the old format – nothing to do + if (fv.video && fv.video.asset) return value; + + // Resolve current asset from the new per-form-factor format + var entry = fv.desktop || fv.tablet || fv.mobile; + if (!entry || !entry.asset) return value; + + var asset = entry.asset; + + // If a full Advanced config was previously saved, restore it directly. + // This preserves every overlay field (font family, size, position, colour, + // text content, image overlay, player customisations, etc.) so the iframe + // can repopulate its UI exactly as the user left it. + if (value.advancedConfig) { + var restored = JSON.parse(JSON.stringify(value.advancedConfig)); + + // Always sync the asset to the current selection in case the user + // changed the video between Advanced sessions. + if (!restored.formValues) restored.formValues = {}; + if (!restored.formValues.video) restored.formValues.video = {}; + restored.formValues.video.asset = { + public_id: asset.public_id, + format: asset.format || '', + derived: asset.derived || [] + }; + + // Ensure formValues.video.transStr is populated for the iframe preview. + // New saves already have it set (injected in the 'done' handler). + // For old saves (made before that fix), playerConf was stripped by SFCC + // storage so it won't be present here — fall back to the widget-level + // transformationOverride which is preserved in the parent value. + if (!restored.formValues.video.transStr) { + if (restored.playerConf) { + try { + var pc = JSON.parse(restored.playerConf); + if (pc.transStr) { + restored.formValues.video.transStr = pc.transStr; + } + } catch (e) { /* keep empty if playerConf is unparseable */ } + } + if (!restored.formValues.video.transStr && value.transformationOverride) { + restored.formValues.video.transStr = value.transformationOverride; + } + } + + return restored; + } + + // First-time open – no previous Advanced config, start from scratch. + return { + formValues: { + video: { + asset: { + public_id: asset.public_id, + format: asset.format || '', + derived: asset.derived || [] + }, + transStr: value.transformationOverride || '' + } + } + }; +} + (() => { subscribe('sfcc:ready', async ({ value, config }) => { + console.log('[CLD AdvancedVideo] sfcc:ready raw value:', JSON.stringify(value)); let iFrame = document.createElement('iframe'); - let val = encodeURIComponent(JSON.stringify(cldUtils.dehydrate(value))); + let iframeValue = normalizeValueForIframe(value); + console.log('[CLD AdvancedVideo] iframeValue (before dehydrate):', JSON.stringify(iframeValue)); + let dehydrated = cldUtils.dehydrate(iframeValue); + console.log('[CLD AdvancedVideo] dehydrated (goes into URL):', JSON.stringify(dehydrated)); + let val = encodeURIComponent(JSON.stringify(dehydrated)); iFrame.src = config.iFrameEnv + '/video?cloudName=' + config.cloudName + '&value=' + val; iFrame.id = 'video-form'; iFrame.setAttribute('frameborder', 0); @@ -14,7 +96,9 @@ let ifrm = document.querySelector('iframe'); window.addEventListener('message', (event) => { if (event.origin === config.iFrameEnv) { - handleIframeMessage(event.data, ifrm, value, config); + // Pass iframeValue (old-format shape) so the 'ready' handler + // posts back data the iframe can actually parse for its UI. + handleIframeMessage(event.data, ifrm, iframeValue, config); } }); parentIFrame.getPageInfo((i) => { @@ -45,11 +129,29 @@ const handleIframeMessage = (message, ifrm, value = null) => { break; case 'ready': value.origin = 'ready'; + console.log('[CLD AdvancedVideo] posting ready value to iframe:', JSON.stringify(value)); ifrm.contentWindow.postMessage(value, '*'); break; case 'done': delete message.action; var val = Object.assign({}, message); + // playerConf.transStr is the authoritative transformation string. + // formValues.video.transStr is always empty in the iframe's done + // payload. SFCC strips every top-level key except formValues/ + // breakpoints before delivering the value to the parent breakout + // callback, so playerConf itself disappears after storage. + // Inject transStr into formValues.video NOW, while playerConf is + // still available locally, so it survives the round-trip. + if (val.playerConf) { + try { + var doneConf = JSON.parse(val.playerConf); + if (doneConf.transStr) { + if (!val.formValues) val.formValues = {}; + if (!val.formValues.video) val.formValues.video = {}; + val.formValues.video.transStr = doneConf.transStr; + } + } catch (e) { /* keep as-is on parse error */ } + } emit({ type: 'sfcc:value', payload: val diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/imageFormWidget.js b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/imageFormWidget.js new file mode 100644 index 0000000..6e0e40f --- /dev/null +++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/imageFormWidget.js @@ -0,0 +1,340 @@ +(() => { + var state = { + formValues: { mobile: null, tablet: null, desktop: null }, + transformationOverrides: { mobile: '', tablet: '', desktop: '' }, + activeFormFactor: 'desktop', + config: null, + ml: null + }; + + var FORM_FACTORS = ['mobile', 'tablet', 'desktop']; + var TAB_ORDER = ['desktop', 'mobile', 'tablet']; + + // ── Inheritance ────────────────────────────────────────────────────── + + function resolveAsset(formFactor) { + if (state.formValues[formFactor]) return state.formValues[formFactor]; + for (var i = 0; i < FORM_FACTORS.length; i++) { + if (state.formValues[FORM_FACTORS[i]]) return state.formValues[FORM_FACTORS[i]]; + } + return null; + } + + function isInherited(formFactor) { + return !state.formValues[formFactor] && !!resolveAsset(formFactor); + } + + // ── URL builders ───────────────────────────────────────────────────── + + function buildDeliveryUrl(asset) { + if (!asset || !asset.public_id) return ''; + var cname = state.config.cname; + var base = cname + ? 'https://' + cname.replace(/^https?:\/\//, '') + : 'https://res.cloudinary.com/' + state.config.cloudName; + var ext = asset.format ? '.' + asset.format : ''; + return base + '/' + (asset.resource_type || 'image') + '/upload/' + asset.public_id + ext; + } + + function buildThumbnailUrl(asset) { + if (!asset || !asset.public_id) return ''; + var cloudName = asset.cloudName || state.config.cloudName; + var publicId = asset.public_id.replace(/\.[^/.]+$/, ''); + return 'https://res.cloudinary.com/' + cloudName + + '/image/upload/w_400,h_160,c_fill,q_auto,f_jpg/' + publicId + '.jpg'; + } + + // ── SFCC emit ──────────────────────────────────────────────────────── + + function emitToSFCC() { + var hasAny = FORM_FACTORS.some(function (ff) { return !!state.formValues[ff]; }); + emit({ type: 'sfcc:valid', payload: { valid: hasAny } }); + emit({ + type: 'sfcc:value', + payload: hasAny ? { + formValues: state.formValues, + transformationOverrides: state.transformationOverrides + } : null + }); + } + + // ── Viewport → form factor ─────────────────────────────────────────── + + function toFormFactor(viewport) { + if (!viewport) return 'desktop'; + if (viewport.breakpoint) { + var bp = viewport.breakpoint.toLowerCase(); + if (bp === 'mobile' || bp === 'tablet' || bp === 'desktop') return bp; + } + var w = viewport.width || 1024; + if (w <= 767) return 'mobile'; + if (w <= 1023) return 'tablet'; + return 'desktop'; + } + + // ── Initial value ──────────────────────────────────────────────────── + + function parseInitialValue(value) { + if (!value) return; + if (!value.formValues) return; + var fv = value.formValues; + + // New per-form-factor format + if (fv.mobile !== undefined || fv.tablet !== undefined || fv.desktop !== undefined) { + state.formValues.mobile = fv.mobile || null; + state.formValues.tablet = fv.tablet || null; + state.formValues.desktop = fv.desktop || null; + + // Restore per-device overrides (new map format) + if (value.transformationOverrides) { + FORM_FACTORS.forEach(function (ff) { + state.transformationOverrides[ff] = value.transformationOverrides[ff] || ''; + }); + return; + } + + // Backward compat: single global transformationOverride → copy to all entries + var legacyOverride = value.transformationOverride || + (value.studioConfig && + value.studioConfig.transformation && + value.studioConfig.transformation !== '[]' + ? value.studioConfig.transformation : ''); + + if (legacyOverride) { + FORM_FACTORS.forEach(function (ff) { + if (state.formValues[ff]) { + state.transformationOverrides[ff] = legacyOverride; + } + }); + } + return; + } + + // Legacy image.asset format → mobile slot + if (fv.image && fv.image.asset) { + var asset = Object.assign({}, fv.image.asset, { cloudName: state.config.cloudName }); + state.formValues.mobile = { asset: asset, url: buildDeliveryUrl(asset) }; + } + } + + // ── Cloudinary MLW – SFCC breakout ─────────────────────────────────── + + function openMediaPicker() { + emit( + { + type: 'sfcc:breakout', + payload: { id: 'mediaPicker', title: 'Cloudinary Image' } + }, + function (data) { + if (data && data.value) { + var asset = Object.assign({}, data.value, { cloudName: state.config.cloudName }); + var url = buildDeliveryUrl(asset); + state.formValues[state.activeFormFactor] = { asset: asset, url: url }; + render(); + emitToSFCC(); + } + } + ); + } + + // ── Render ─────────────────────────────────────────────────────────── + + function render() { + var root = document.getElementById('cld-widget-root'); + if (!root) return; + root.innerHTML = buildHTML(); + bindEvents(); + } + + function buildHTML() { + return buildTabsHTML() + buildPickerHTML() + buildFileRowHTML() + buildAdvancedSectionHTML(); + } + + function buildTabsHTML() { + var html = '
'; + TAB_ORDER.forEach(function (ff) { + var isActive = ff === state.activeFormFactor; + var hasSelection = !!state.formValues[ff]; + var label = ff.charAt(0).toUpperCase() + ff.slice(1); + html += ''; + }); + html += '
'; + return html; + } + + function buildPickerHTML() { + var entry = resolveAsset(state.activeFormFactor); + var asset = entry ? entry.asset : null; + var hasAsset = !!asset; + var inherited = isInherited(state.activeFormFactor); + + return ''; + } + + function buildFileRowHTML() { + var entry = resolveAsset(state.activeFormFactor); + var asset = entry ? entry.asset : null; + var publicId = asset ? asset.public_id : ''; + + return '
' + + '' + + '' + + (publicId + ? '' + : '') + + '
'; + } + + function buildAdvancedSectionHTML() { + var override = state.transformationOverrides[state.activeFormFactor] || ''; + var hasOverride = !!override; + + return '
' + + '' + + '
' + + '' + + '' + + '
' + + '
'; + } + + // ── Studio Widget breakout ──────────────────────────────────────────── + + function openStudioWidget() { + var ff = state.activeFormFactor; + emit( + { + type: 'sfcc:breakout', + payload: { id: 'studioWidget', title: 'Cloudinary Studio Widget' } + }, + function (data) { + console.log('[imageFormWidget] studioWidget callback:', JSON.stringify(data)); + var val = data && data.value; + if (!val) return; + + var studioResult = (val.formValues && val.formValues.studioResult) + || val.studioResult + || null; + if (!studioResult) return; + + var trans = studioResult.transformation; + var override = (trans && trans !== '[]') ? trans : ''; + + state.transformationOverrides[ff] = override; + render(); + emitToSFCC(); + } + ); + } + + // ── Event binding ──────────────────────────────────────────────────── + + function bindEvents() { + document.querySelectorAll('.cld-ff-tab').forEach(function (btn) { + btn.addEventListener('click', function () { + state.activeFormFactor = btn.getAttribute('data-ff'); + render(); + }); + }); + + var pickerBtn = document.getElementById('cld-picker-btn'); + if (pickerBtn) pickerBtn.addEventListener('click', openMediaPicker); + + var browseBtn = document.getElementById('cld-browse-btn'); + if (browseBtn) browseBtn.addEventListener('click', openMediaPicker); + + var advBtn = document.getElementById('cld-advanced-btn'); + if (advBtn) { + advBtn.addEventListener('click', function () { + if (!advBtn.disabled) openStudioWidget(); + }); + } + + // Transformation override textarea — stored per active form factor + var transInput = document.getElementById('cld-trans-override'); + if (transInput) { + transInput.addEventListener('input', function () { + var val = transInput.value.trim(); + state.transformationOverrides[state.activeFormFactor] = val; + var btn = document.getElementById('cld-advanced-btn'); + if (btn) { + btn.disabled = !!val; + btn.setAttribute('aria-disabled', String(!!val)); + } + emitToSFCC(); + }); + } + + var clearBtn = document.getElementById('cld-clear-btn'); + if (clearBtn) { + clearBtn.addEventListener('click', function () { + if (state.formValues[state.activeFormFactor]) { + state.formValues[state.activeFormFactor] = null; + } else { + for (var i = 0; i < FORM_FACTORS.length; i++) { + if (state.formValues[FORM_FACTORS[i]]) { + state.formValues[FORM_FACTORS[i]] = null; + break; + } + } + } + render(); + emitToSFCC(); + }); + } + } + + // ── Bootstrap ──────────────────────────────────────────────────────── + + subscribe('sfcc:ready', function (opts) { + state.config = opts.config; + state.activeFormFactor = toFormFactor(opts.viewport); + parseInitialValue(opts.value); + + var root = document.createElement('div'); + root.id = 'cld-widget-root'; + document.body.appendChild(root); + render(); + emitToSFCC(); + }); + + subscribe('sfcc:viewport', function (viewport) { + state.activeFormFactor = toFormFactor(viewport); + render(); + }); +})(); diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.css b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.css new file mode 100644 index 0000000..75be694 --- /dev/null +++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.css @@ -0,0 +1,13 @@ +html, +body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow: hidden; +} + +#cld-studio-container { + width: 100%; + overflow: hidden; +} diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.js b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.js new file mode 100644 index 0000000..d62478f --- /dev/null +++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/studioWidget.js @@ -0,0 +1,173 @@ +/** + * studioWidget.js – SFCC Page Designer breakout editor + * + * Uses the official Cloudinary Studio Widget JS SDK: + * https://studio-widget.cloudinary.com/latest/all.js + */ + +(() => { + subscribe('sfcc:ready', function ({ value, config }) { + // Capture emit at subscribe time — stable reference across async callbacks + var _emit = emit; + + // Container the SDK will mount the widget into + var container = document.createElement('div'); + container.id = 'cld-studio-container'; + document.body.appendChild(container); + + // Size the container to fill the modal viewport + parentIFrame.getPageInfo(function (info) { + var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + var chrome = 55 + 55 + (4 * rem); + var h = Math.max(Math.round(info.clientHeight - chrome), 400); + + container.style.width = '100%'; + container.style.height = h + 'px'; + parentIFrame.size(h); + + initWidget(_emit); + }); + + // ------------------------------------------------------------------ + // Widget initialisation + // ------------------------------------------------------------------ + function initWidget(emitFn) { + var widget = window.cloudinary.studioWidget({ + cloudName: config.cloudName, + apiKey: config.apiKey, + appendTo: '#cld-studio-container' + }); + + var publicId = getPublicId(value); + if (publicId) { + widget.update({ publicIds: [publicId] }); + } + + widget.show(); + + // Destroy the widget cleanly when SFCC closes the breakout modal + window.addEventListener('pagehide', function () { + try { widget.destroy(); } catch (e) {} + }); + + // ── insert ──────────────────────────────────────────────────── + widget.on('insert', function (payload) { + console.log('[CLD Studio] raw insert payload:', payload); + + try { + var imageUrl = ''; + var publicId = ''; + var trans = '[]'; + + if (typeof payload === 'string') { + // SDK delivers the final delivery URL as a plain string + imageUrl = payload; + var parsed = parseCloudinaryUrl(payload); + publicId = parsed.publicId; + trans = parsed.transformation; + } else { + // Future-proof: handle object payload + var asset = payload; + if (payload && Array.isArray(payload.assets) && payload.assets.length) { + asset = payload.assets[0]; + } else if (Array.isArray(payload) && payload.length) { + asset = payload[0]; + } + imageUrl = asset.url || asset.imageUrl || asset.secure_url || ''; + publicId = asset.public_id || asset.publicId || ''; + trans = asset.transformation || asset.eager_transformation || '[]'; + if (typeof trans !== 'string') { + try { trans = JSON.stringify(trans); } catch (e) { trans = '[]'; } + } + } + + var result = { + formValues: { + studioResult: { + imageUrl: imageUrl, + transformation: trans, + public_id: publicId, + isTransformationOverride: true + } + } + }; + + emitFn({ type: 'sfcc:value', payload: result }); + + // Close the breakout modal by programmatically clicking + // SFCC's "Apply" button in the parent frame + setTimeout(function () { + try { + var buttons = window.parent.document.querySelectorAll('button'); + for (var b = 0; b < buttons.length; b++) { + if (buttons[b].textContent.trim() === 'Apply') { + buttons[b].click(); + break; + } + } + } catch (e) { /* cross-origin guard */ } + }, 50); + + } catch (err) { + console.error('[CLD Studio] insert handler error:', err); + } + }); + + widget.on('close', function () { + console.log('[CLD Studio] widget closed by user'); + }); + } + }); + + function getPublicId(value) { + if (!value || !value.formValues) return ''; + var fv = value.formValues; + var entry = fv.desktop || fv.tablet || fv.mobile; + if (entry && entry.asset && entry.asset.public_id) return entry.asset.public_id; + if (fv.image && fv.image.asset && fv.image.asset.public_id) return fv.image.asset.public_id; + return ''; + } + + /** + * Parse a Cloudinary delivery URL into its transformation string and public_id. + */ + function parseCloudinaryUrl(url) { + var UPLOAD = '/upload/'; + var idx = url.indexOf(UPLOAD); + if (idx === -1) return { publicId: '', transformation: '[]' }; + + var afterUpload = url.substring(idx + UPLOAD.length).split('?')[0]; + var segments = afterUpload.split('/'); + + var transParts = []; + var pidParts = []; + var inPid = false; + + for (var i = 0; i < segments.length; i++) { + var seg = segments[i]; + if (!inPid && isTransformSegment(seg)) { + transParts.push(seg); + } else { + inPid = true; + pidParts.push(seg); + } + } + + // Strip file extension from the last public_id segment + var last = pidParts[pidParts.length - 1] || ''; + var dotIdx = last.lastIndexOf('.'); + if (dotIdx !== -1) pidParts[pidParts.length - 1] = last.substring(0, dotIdx); + + return { + publicId: pidParts.join('/'), + transformation: transParts.length ? transParts.join('/') : '[]' + }; + } + + function isTransformSegment(seg) { + var parts = seg.split(','); + return parts.every(function (p) { + return /^[a-z]{1,3}_/.test(p) || /^[a-z]{1,3}[A-Z]/.test(p); + }); + } +})(); diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.css b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.css new file mode 100644 index 0000000..6a04d12 --- /dev/null +++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.css @@ -0,0 +1,291 @@ +/* ── Root ──────────────────────────────────────────────────────────────── */ +#cld-widget-root { + font-family: "Salesforce Sans", Arial, sans-serif; + font-size: 0.8125rem; + line-height: 1.5; + color: #080707; + box-sizing: border-box; +} + +/* ── Form Factor Tabs ──────────────────────────────────────────────────── */ +.cld-ff-tabs { + display: flex; + padding: 0 8px; +} + +.cld-ff-tab { + display: flex; + align-items: center; + gap: 5px; + padding: 7px 14px; + margin-bottom: 4px; + border: none; + border-bottom: 2px solid transparent; + background: none; + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + color: #706e6b; + cursor: pointer; + white-space: nowrap; + transition: color 0.12s, border-color 0.12s; +} + +.cld-ff-tab:first-child { + padding-left: 4px; +} + +.cld-ff-tab:hover { color: #0070d2; } + +.cld-ff-tab.active { + color: #0070d2; + border-bottom-color: #0070d2; + font-weight: 700; +} + +.cld-ff-tab-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: #4bca81; + flex-shrink: 0; +} + +/* ── Media Picker Button ───────────────────────────────────────────────── */ +.cld-picker { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: calc(100% - 16px); + height: 200px; + margin: 0 8px 0; + padding: 0; + border: none; + border-radius: 0; + background-color: #1a1a1a; + cursor: pointer; + overflow: hidden; + transition: opacity 0.15s; +} + +.cld-picker:hover { opacity: 0.9; } +.cld-picker:focus-visible { outline: 2px solid #0070d2; outline-offset: -2px; } + +/* Empty state — dashed border hint */ +.cld-picker:not(.has-asset) { + border: 2px dashed #c9c7c5; + background-color: #fafaf9; + margin: 8px 0 0; +} + +.cld-picker-thumb { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.cld-picker-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 0.75rem; + font-weight: 600; + opacity: 0; + transition: opacity 0.15s; + pointer-events: none; +} + +.cld-picker:not(.has-asset) .cld-picker-overlay, +.cld-picker:hover .cld-picker-overlay, +.cld-picker:focus-visible .cld-picker-overlay { + opacity: 1; +} + +.cld-picker-badge { + position: absolute; + top: 6px; + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 3px; + pointer-events: none; +} + +.cld-picker-badge--inherited { + right: 6px; + background: rgba(0, 0, 0, 0.55); + color: #fff; +} + +.cld-picker-badge--type { + left: 6px; + background: rgba(0, 112, 210, 0.85); + color: #fff; +} + +/* ── File row ──────────────────────────────────────────────────────────── */ +.cld-file-row { + display: flex; + align-items: center; + gap: 4px; + padding: 8px; +} + +.cld-file-input { + flex: 1; + min-width: 0; + padding: 5px 8px; + border: 1px solid #dddbda; + border-radius: 4px; + background-color: #f3f2f2; + font-family: "Salesforce Sans", Arial, sans-serif; + font-size: 0.75rem; + color: #3e3e3c; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: default; +} + +.cld-file-btn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid #dddbda; + border-radius: 4px; + background: #fff; + color: #706e6b; + cursor: pointer; + transition: border-color 0.12s, color 0.12s; +} + +.cld-file-btn:hover { + border-color: #0070d2; + color: #0070d2; +} + +.cld-file-btn--clear:hover { + border-color: #c23934; + color: #c23934; +} + +/* ── Poster image & Player options sections ────────────────────────────── */ +.cld-section { + padding: 10px 8px 4px; + border-bottom: 1px solid #dddbda; +} + +.cld-section-title { + font-size: 0.75rem; + font-weight: 700; + color: #080707; + margin-bottom: 8px; +} + +.cld-radio-label, +.cld-checkbox-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8125rem; + color: #3e3e3c; + cursor: pointer; + padding: 3px 0; + user-select: none; +} + +.cld-radio-label input[type="radio"], +.cld-checkbox-label input[type="checkbox"] { + margin: 0; + cursor: pointer; + flex-shrink: 0; +} + +/* ── Advanced section ──────────────────────────────────────────────────── */ +.cld-section--advanced { + border-bottom: none; +} + +.cld-advanced-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 16px; + margin-bottom: 12px; + border: 1px solid #dddbda; + border-radius: 4px; + background: #fff; + color: #0070d2; + font-family: inherit; + font-size: 0.8125rem; + font-weight: 400; + line-height: 1; + cursor: pointer; + transition: border-color 0.12s; +} + +.cld-advanced-btn:hover:not(:disabled) { + border-color: #0070d2; +} + +.cld-advanced-btn:disabled, +.cld-advanced-btn[aria-disabled="true"] { + color: #c9c7c5; + cursor: not-allowed; + background: #fff; +} + +.cld-adv-applied { + display: inline-block; + margin-bottom: 8px; + padding: 2px 8px; + border-radius: 3px; + background: #e3f3e3; + color: #2e7d32; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.03em; +} + +.cld-adv-override { + display: flex; + flex-direction: column; + gap: 5px; +} + +.cld-adv-override-label { + font-size: 0.75rem; + font-weight: 700; + color: #080707; +} + +.cld-adv-override-input { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; + border: 1px solid #dddbda; + border-radius: 4px; + background: #f3f2f2; + font-family: "Salesforce Sans", Arial, sans-serif; + font-size: 0.8125rem; + color: #3e3e3c; + resize: vertical; + min-height: 70px; +} + diff --git a/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.js b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.js new file mode 100644 index 0000000..8f73320 --- /dev/null +++ b/cartridges/bm_cloudinary_pd/cartridge/static/default/experience/editors/cloudinary/videoFormWidget.js @@ -0,0 +1,436 @@ +(() => { + var state = { + formValues: { mobile: null, tablet: null, desktop: null }, + activeFormFactor: 'desktop', + config: null, + ml: null, + posterMode: 'auto', // 'auto' | 'first_frame' + playerOptions: { autoplay: false, muted: false, loop: false, controls: true }, + transformationOverride: '', // manual URL-syntax override; mutually exclusive with Advanced + advancedConfig: null // full 'done' payload from the Advanced breakout + }; + + var FORM_FACTORS = ['mobile', 'tablet', 'desktop']; // inheritance fallback order + var TAB_ORDER = ['desktop', 'mobile', 'tablet']; // display order in the tab bar + + // ── Inheritance ────────────────────────────────────────────────────── + + function resolveAsset(formFactor) { + if (state.formValues[formFactor]) return state.formValues[formFactor]; + for (var i = 0; i < FORM_FACTORS.length; i++) { + if (state.formValues[FORM_FACTORS[i]]) return state.formValues[FORM_FACTORS[i]]; + } + return null; + } + + function isInherited(formFactor) { + return !state.formValues[formFactor] && !!resolveAsset(formFactor); + } + + // ── URL builders ───────────────────────────────────────────────────── + + function buildDeliveryUrl(asset) { + if (!asset || !asset.public_id) return ''; + var cname = state.config.cname; + var base = cname + ? 'https://' + cname.replace(/^https?:\/\//, '') + : 'https://res.cloudinary.com/' + state.config.cloudName; + var ext = asset.format ? '.' + asset.format : ''; + return base + '/' + (asset.resource_type || 'image') + '/upload/' + asset.public_id + ext; + } + + function buildThumbnailUrl(asset) { + if (!asset || !asset.public_id) return ''; + var cloudName = asset.cloudName || state.config.cloudName; + var publicId = asset.public_id.replace(/\.[^/.]+$/, ''); + var resourceType = asset.resource_type === 'video' ? 'video' : 'image'; + return 'https://res.cloudinary.com/' + cloudName + + '/' + resourceType + '/upload/w_400,h_160,c_fill,q_auto,f_jpg/' + publicId + '.jpg'; + } + + // ── SFCC emit ──────────────────────────────────────────────────────── + + function emitToSFCC() { + var hasAny = FORM_FACTORS.some(function (ff) { return !!state.formValues[ff]; }); + emit({ type: 'sfcc:valid', payload: { valid: hasAny } }); + emit({ + type: 'sfcc:value', + payload: hasAny ? { + formValues: state.formValues, + posterMode: state.posterMode, + playerOptions: state.playerOptions, + transformationOverride: state.transformationOverride, + advancedConfig: state.advancedConfig + } : null + }); + } + + // ── Viewport → form factor ─────────────────────────────────────────── + + function toFormFactor(viewport) { + if (!viewport) return 'desktop'; + if (viewport.breakpoint) { + var bp = viewport.breakpoint.toLowerCase(); + if (bp === 'mobile' || bp === 'tablet' || bp === 'desktop') return bp; + } + var w = viewport.width || 1024; + if (w <= 767) return 'mobile'; + if (w <= 1023) return 'tablet'; + return 'desktop'; + } + + // ── Initial value ──────────────────────────────────────────────────── + + function parseInitialValue(value) { + if (!value) return; + + // Restore poster mode, player options and transformation override if previously saved + if (value.posterMode) { + state.posterMode = value.posterMode; + } + if (value.playerOptions) { + state.playerOptions = Object.assign({}, state.playerOptions, value.playerOptions); + } + if (value.transformationOverride) { + state.transformationOverride = value.transformationOverride; + } + if (value.advancedConfig) { + state.advancedConfig = value.advancedConfig; + } + + if (!value.formValues) return; + var fv = value.formValues; + + // New per-form-factor format + if (fv.mobile !== undefined || fv.tablet !== undefined || fv.desktop !== undefined) { + state.formValues.mobile = fv.mobile || null; + state.formValues.tablet = fv.tablet || null; + state.formValues.desktop = fv.desktop || null; + return; + } + + // Legacy format: migrate video.asset → mobile slot + if (fv.video && fv.video.asset) { + var asset = Object.assign({}, fv.video.asset, { cloudName: state.config.cloudName }); + state.formValues.mobile = { asset: asset, url: buildDeliveryUrl(asset) }; + } + } + + // ── Cloudinary MLW – SFCC breakout ─────────────────────────────────── + + function openMediaPicker() { + emit( + { + type: 'sfcc:breakout', + payload: { id: 'mediaPicker', title: 'Cloudinary Video' } + }, + function (data) { + if (data && data.value) { + var asset = Object.assign({}, data.value, { cloudName: state.config.cloudName }); + var url = buildDeliveryUrl(asset); + state.formValues[state.activeFormFactor] = { asset: asset, url: url }; + render(); + emitToSFCC(); + } + } + ); + } + + // ── Render ─────────────────────────────────────────────────────────── + + function render() { + var root = document.getElementById('cld-widget-root'); + if (!root) return; + root.innerHTML = buildHTML(); + bindEvents(); + } + + function buildHTML() { + return ( + buildTabsHTML() + + buildPickerHTML() + + buildFileRowHTML() + + buildPosterSectionHTML() + + buildPlayerOptionsSectionHTML() + + buildAdvancedSectionHTML() + ); + } + + function buildPosterSectionHTML() { + var isFirstFrame = state.posterMode === 'first_frame'; + return '
' + + '
Poster image
' + + '' + + '' + + '
'; + } + + function buildPlayerOptionsSectionHTML() { + var opts = state.playerOptions; + var items = [ + { key: 'autoplay', label: 'Autoplay' }, + { key: 'muted', label: 'Start muted' }, + { key: 'loop', label: 'Loop' }, + { key: 'controls', label: 'Show controls' } + ]; + var html = '
' + + '
Player options
'; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + html += ''; + } + html += '
'; + return html; + } + + function buildTabsHTML() { + var html = '
'; + TAB_ORDER.forEach(function (ff) { + var isActive = ff === state.activeFormFactor; + var hasSelection = !!state.formValues[ff]; + var label = ff.charAt(0).toUpperCase() + ff.slice(1); + html += ''; + }); + html += '
'; + return html; + } + + function buildPickerHTML() { + var entry = resolveAsset(state.activeFormFactor); + var asset = entry ? entry.asset : null; + var hasAsset = !!asset; + var inherited = isInherited(state.activeFormFactor); + var isVideo = asset && asset.resource_type === 'video'; + + return ''; + } + + function buildFileRowHTML() { + var entry = resolveAsset(state.activeFormFactor); + var asset = entry ? entry.asset : null; + var publicId = asset ? asset.public_id : ''; + var inherited = isInherited(state.activeFormFactor); + + return '
' + + '' + + '' + + (publicId + ? '' + : '') + + '
'; + } + + function buildAdvancedSectionHTML() { + var hasOverride = !!state.transformationOverride; + return '
' + + '' + + '
' + + '' + + '' + + '
' + + '
'; + } + + function openAdvancedConfig() { + // Build a minimal value object the advancedVideoForm iframe can use + var resolved = resolveAsset(state.activeFormFactor); + var asset = resolved ? resolved.asset : null; + var breakoutValue = { + cloudName: state.config.cloudName, + publicId: asset ? asset.public_id : '', + transStr: state.transformationOverride || '' + }; + emit( + { + type: 'sfcc:breakout', + payload: { id: 'advancedConfig', title: 'Cloudinary Video Advanced Configuration', value: breakoutValue } + }, + function (data) { + console.log('[CLD Widget] Advanced breakout callback data:', JSON.stringify(data)); + if (data && data.value) { + var returned = data.value; + console.log('[CLD Widget] Advanced returned value:', JSON.stringify(returned)); + + // Store the full payload so the iframe can restore all overlay + // settings (font family, size, position, etc.) on re-open. + state.advancedConfig = returned; + + // Extract the transformation string for server-side application. + // Priority: formValues.video.transStr (injected in 'done' handler, + // survives SFCC storage) > playerConf.transStr (still present in + // the raw payload before SFCC strips it) > top-level transStr. + var transStr = ''; + if (returned.formValues && returned.formValues.video && returned.formValues.video.transStr) { + transStr = returned.formValues.video.transStr; + } else if (returned.playerConf) { + try { transStr = JSON.parse(returned.playerConf).transStr || ''; } catch (e) { /* ignore */ } + } else if (typeof returned.transStr === 'string') { + transStr = returned.transStr; + } + state.transformationOverride = transStr; + render(); + emitToSFCC(); + } + } + ); + } + + // ── Event binding ──────────────────────────────────────────────────── + + function bindEvents() { + // Tab clicks + document.querySelectorAll('.cld-ff-tab').forEach(function (btn) { + btn.addEventListener('click', function () { + state.activeFormFactor = btn.getAttribute('data-ff'); + render(); + }); + }); + + // Picker button (thumbnail area) + var pickerBtn = document.getElementById('cld-picker-btn'); + if (pickerBtn) { + pickerBtn.addEventListener('click', openMediaPicker); + } + + // Browse button (folder icon) + var browseBtn = document.getElementById('cld-browse-btn'); + if (browseBtn) { + browseBtn.addEventListener('click', openMediaPicker); + } + + // Advanced button + var advBtn = document.getElementById('cld-advanced-btn'); + if (advBtn) { + advBtn.addEventListener('click', function () { + if (!advBtn.disabled) openAdvancedConfig(); + }); + } + + // Transformation override textarea — disables Advanced while it has a value + var transInput = document.getElementById('cld-trans-override'); + if (transInput) { + transInput.addEventListener('input', function () { + state.transformationOverride = transInput.value.trim(); + var btn = document.getElementById('cld-advanced-btn'); + if (btn) { + btn.disabled = !!state.transformationOverride; + btn.setAttribute('aria-disabled', String(!!state.transformationOverride)); + } + emitToSFCC(); + }); + } + + // Poster image radios + document.querySelectorAll('input[name="cld-poster"]').forEach(function (radio) { + radio.addEventListener('change', function () { + state.posterMode = radio.value; + emitToSFCC(); + }); + }); + + // Player option checkboxes + document.querySelectorAll('input[data-opt]').forEach(function (cb) { + cb.addEventListener('change', function () { + state.playerOptions[cb.getAttribute('data-opt')] = cb.checked; + emitToSFCC(); + }); + }); + + // Clear button (trash icon) + var clearBtn = document.getElementById('cld-clear-btn'); + if (clearBtn) { + clearBtn.addEventListener('click', function () { + // If this slot has its own value, clear it directly. + // If it's inherited, clear the source slot instead. + if (state.formValues[state.activeFormFactor]) { + state.formValues[state.activeFormFactor] = null; + } else { + for (var i = 0; i < FORM_FACTORS.length; i++) { + if (state.formValues[FORM_FACTORS[i]]) { + state.formValues[FORM_FACTORS[i]] = null; + break; + } + } + } + render(); + emitToSFCC(); + }); + } + } + + // ── Bootstrap ──────────────────────────────────────────────────────── + + subscribe('sfcc:ready', function (opts) { + var value = opts.value; + var config = opts.config; + var viewport = opts.viewport; + + state.config = config; + state.activeFormFactor = toFormFactor(viewport); + + // Seed player option defaults from site preference (passed via editor.configuration) + if (config && config.playerOptions) { + try { + var prefDefaults = JSON.parse(config.playerOptions); + state.playerOptions = Object.assign({}, state.playerOptions, prefDefaults); + } catch (e) { /* keep hard-coded defaults */ } + } + + parseInitialValue(value); + + var root = document.createElement('div'); + root.id = 'cld-widget-root'; + document.body.appendChild(root); + render(); + emitToSFCC(); + }); + + subscribe('sfcc:viewport', function (viewport) { + state.activeFormFactor = toFormFactor(viewport); + render(); + }); +})(); diff --git a/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibrary.js b/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibrary.js index b902ec3..afdb09e 100644 --- a/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibrary.js +++ b/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibrary.js @@ -1,5 +1,48 @@ 'use strict'; +/** + * Returns true when the value was saved by the new per-form-factor widget. + * @param {Object} val SFCC value + * @returns {boolean} + */ +function isNewFormat(val) { + if (!val || !val.formValues) return false; + var fv = val.formValues; + return fv.mobile !== undefined || fv.tablet !== undefined || fv.desktop !== undefined; +} + +/** + * Resolves the active form-factor based on the current request device. + * @returns {'mobile'|'tablet'|'desktop'} + */ +function getActiveFormFactor() { + try { + var device = request.getHttpHeaders().get('cloudinary-form-factor'); + if (device) return device; + if (request.device) { + if (request.device.mobile) return 'mobile'; + if (request.device.tablet) return 'tablet'; + } + } catch (e) { // eslint-disable-line no-empty-blocks + } + return 'desktop'; +} + +/** + * Resolves the { asset, url } entry for the current device using the + * Mobile → Tablet → Desktop inheritance fallback. + * @param {Object} formValues + * @returns {{ asset: Object, url: string }|null} + */ +function resolveEntry(formValues) { + var order = ['mobile', 'tablet', 'desktop']; + var ff = getActiveFormFactor(); + if (formValues[ff]) return formValues[ff]; + for (var i = 0; i < order.length; i++) { + if (formValues[order[i]]) return formValues[order[i]]; + } + return null; +} /** * Replaces the global transformations so if they change @@ -79,8 +122,94 @@ module.exports.preRender = function (context, editorId) { var URLUtils = require('dw/web/URLUtils'); var constants = require('~/cartridge/experience/utils/cloudinaryPDConstants').cloudinaryPDConstants; var currentSite = require('dw/system/Site').getCurrent(); - + var viewmodel = {}; + var val = context.content[editorId]; + + if (empty(val)) return viewmodel; + + // ── New per-form-factor format ──────────────────────────────────────── + if (isNewFormat(val)) { + var entry = resolveEntry(val.formValues); + if (!entry || !entry.asset) return viewmodel; + + var asset = entry.asset; + var publicId = asset.public_id; + var cloudName = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCloudName'); + var cname = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCNAME'); + + var baseUrl = (cname && cname !== 'res.cloudinary.com') + ? 'https://' + cname.replace(/^https?:\/\//, '') + : 'https://res.cloudinary.com/' + cloudName; + + // Transformation override: fall back through save-format generations + var legacyStudioTrans = val.studioConfig && + val.studioConfig.transformation && + val.studioConfig.transformation !== '[]' + ? val.studioConfig.transformation : ''; + var transformationOverride = val.transformationOverride + || legacyStudioTrans + || ''; + var isOverride = !!transformationOverride; + + // Build transformation string for the delivery URL + var transPart = ''; + var transformationArr = []; + if (isOverride) { + transPart = transformationOverride; + transformationArr = [{ raw_transformation: transformationOverride }]; + } else { + // Apply global image transformations from site preferences + var globalObj = {}; + var dprPref = currentSite.getCustomPreferenceValue('CloudinaryImageTransformationsDPR'); + var fmtPref = currentSite.getCustomPreferenceValue('CloudinaryImageTransformationsFormat'); + var qualPref = currentSite.getCustomPreferenceValue('CloudinaryImageTransformationsQuality'); + var rawPref = currentSite.getCustomPreferenceValue('CloudinaryImageTransformations'); + + if (dprPref && dprPref.getValue() !== 'none') { + globalObj.dpr = dprPref.getValue(); + } + if (fmtPref && fmtPref.getValue() !== 'none') { + globalObj.fetchFormat = fmtPref.getValue(); + } + if (qualPref && qualPref.getValue() !== 'none') { + globalObj.quality = qualPref.getValue(); + } + if (rawPref) { + globalObj.raw_transformation = rawPref; + } + + // Build URL-syntax string for the delivery URL + var urlParts = []; + if (globalObj.dpr) urlParts.push('dpr_' + globalObj.dpr); + if (globalObj.fetchFormat) urlParts.push('f_' + globalObj.fetchFormat); + if (globalObj.quality) urlParts.push('q_' + globalObj.quality); + if (rawPref) urlParts.push(rawPref); + transPart = urlParts.join(','); + transformationArr = [globalObj]; + } + + var ext = asset.format ? '.' + asset.format : ''; + var imageUrl = baseUrl + '/image/upload/' + (transPart ? transPart + '/' : '') + publicId + ext; + + viewmodel.id = idSafeString(publicId.replace(/\//g, '-') + randomString(12)); + viewmodel.publicId = publicId; + viewmodel.cloudName = cloudName; + if (cname && cname !== 'res.cloudinary.com') { + viewmodel.cname = cname; + } + viewmodel.CloudinaryPageDesignerCNAME = constants.SITE_PREFS.CloudinaryPageDesignerCNAME; + + viewmodel.src = imageUrl + constants.CLD_TRACKING_PARAM; + viewmodel.placeholder = imageUrl + constants.CLD_TRACKING_PARAM; + viewmodel.transformation = JSON.stringify(transformationArr); + viewmodel.isTransformationOverride = isOverride; + viewmodel.srcset = generateBreakPoints(viewmodel); + + return viewmodel; + } + + // ── Legacy format ───────────────────────────────────────────────────── if (context.content[editorId] && context.content[editorId].imageUrl) { var cname = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCNAME'); viewmodel.id = idSafeString(context.content[editorId].public_id + randomString(12)); diff --git a/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibraryVideo.js b/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibraryVideo.js index 99fe538..1bbcdf4 100644 --- a/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibraryVideo.js +++ b/cartridges/int_cloudinary_pd/cartridge/experience/components/assets/mediaLibraryVideo.js @@ -167,6 +167,50 @@ function hasVideo(val) { return val.formValues && val.formValues.video && val.formValues.video.asset; } +/** + * Returns true when the value was saved by the new per-form-factor widget. + * @param {Object} val SFCC value + * @returns {boolean} + */ +function isNewFormat(val) { + if (!val || !val.formValues) return false; + var fv = val.formValues; + return fv.mobile !== undefined || fv.tablet !== undefined || fv.desktop !== undefined; +} + +/** + * Resolves the active form-factor string based on the current request device. + * @returns {'mobile'|'tablet'|'desktop'} + */ +function getActiveFormFactor() { + try { + var device = request.getHttpHeaders().get('cloudinary-form-factor'); + if (device) return device; + if (request.device) { + if (request.device.mobile) return 'mobile'; + if (request.device.tablet) return 'tablet'; + } + } catch (e) { // eslint-disable-line no-empty-blocks + } + return 'desktop'; +} + +/** + * Resolves the { asset, url } entry for the current device, applying the + * Mobile → Tablet → Desktop inheritance fallback. + * @param {Object} formValues + * @returns {{ asset: Object, url: string }|null} + */ +function resolveEntry(formValues) { + var order = ['mobile', 'tablet', 'desktop']; + var ff = getActiveFormFactor(); + if (formValues[ff]) return formValues[ff]; + for (var i = 0; i < order.length; i++) { + if (formValues[order[i]]) return formValues[order[i]]; + } + return null; +} + /** * Get cloudinary video transformations. * @@ -257,13 +301,16 @@ function mergePlayerConfig(videoPlayerOptions, configurations, overrideGlobalCon return mergedConfigs; } -module.exports.preRender = function (context, editorId) { - var currentSite = require('dw/system/Site').getCurrent(); - var constants = require('~/cartridge/experience/utils/cloudinaryPDConstants').cloudinaryPDConstants; - - var val = context.content[editorId]; - var viewmodel = {}; - if (!empty(val) && !val.playerConf.empty && hasVideo(val)) { +/** + * Handles pre-render for the legacy playerConf format + * @param {Object} context - component context + * @param {Object} val - the editor value + * @param {Object} viewmodel - the viewmodel to populate + * @param {Object} currentSite - the current SFCC site + * @param {Object} constants - cloudinary PD constants + */ +function preRenderLegacy(context, val, viewmodel, currentSite, constants) { + if (!val.playerConf.empty && hasVideo(val)) { var cname = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCNAME'); var conf = JSON.parse(val.playerConf); var publicId = conf.publicId; @@ -272,18 +319,18 @@ module.exports.preRender = function (context, editorId) { conf.sourceType = format; } if (cname !== 'res.cloudinary.com') { - viewmodel.cname = cname; + viewmodel.cname = cname; // eslint-disable-line no-param-reassign } conf = videoPlayerConfigs(conf); conf.playerConfig.posterOptions = {}; - const queryParams = {}; + var queryParams = {}; queryParams[constants.CLD_TRACKING_PARAM.slice(1).split('=')[0]] = constants.CLD_TRACKING_PARAM.slice(1).split('=')[1]; conf.sourceConfig.queryParams = queryParams; - viewmodel.cloudName = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCloudName'); - viewmodel.public_id = publicId; - viewmodel.id = idSafeString(randomString(16)); - const videoPosterTrans = getCloudinaryVideoTransformation(context); - const videoPlayerOptions = getContentVideoPlayerOptions(); + viewmodel.cloudName = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCloudName'); // eslint-disable-line no-param-reassign + viewmodel.public_id = publicId; // eslint-disable-line no-param-reassign + viewmodel.id = idSafeString(randomString(16)); // eslint-disable-line no-param-reassign + var videoPosterTrans = getCloudinaryVideoTransformation(context); + var videoPlayerOptions = getContentVideoPlayerOptions(); if (videoPosterTrans) { conf.playerConfig.posterOptions.transformation = videoPosterTrans; delete conf.sourceConfig.poster; @@ -291,10 +338,112 @@ module.exports.preRender = function (context, editorId) { if ('videoAspectRatio' in context.content) { conf.playerConfig.aspectRatio = context.content.videoAspectRatio; } - const mergedConfig = mergePlayerConfig(videoPlayerOptions, conf.playerConfig, context.content.overrideGlobalConfigs); - var widgetOptions = { playerConfig: mergedConfig, sourceConfig: conf.sourceConfig }; - viewmodel.widgetOptions = JSON.stringify(widgetOptions); + var mergedConfig = mergePlayerConfig(videoPlayerOptions, conf.playerConfig, context.content.overrideGlobalConfigs); + viewmodel.widgetOptions = JSON.stringify({ playerConfig: mergedConfig, sourceConfig: conf.sourceConfig }); // eslint-disable-line no-param-reassign } +} + +module.exports.preRender = function (context, editorId) { + var currentSite = require('dw/system/Site').getCurrent(); + var constants = require('~/cartridge/experience/utils/cloudinaryPDConstants').cloudinaryPDConstants; + + var val = context.content[editorId]; + var viewmodel = {}; + + if (empty(val)) return viewmodel; + + // ── New per-form-factor format ──────────────────────────────────────── + if (isNewFormat(val)) { + var cname = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCNAME'); + if (cname !== 'res.cloudinary.com') { + viewmodel.cname = cname; + } + + var videoPosterTrans = getCloudinaryVideoTransformation(context); + var videoPlayerOptions = getContentVideoPlayerOptions(); + + // Per-component transformation override (raw URL-syntax string) + var transformationOverride = val.transformationOverride || ''; + + // Per-component poster mode: 'first_frame' appends a so_0 transformation + var posterMode = val.posterMode || 'auto'; + var effectivePosterTrans = (posterMode === 'first_frame') + ? videoPosterTrans.concat([{ raw_transformation: 'so_0' }]) + : videoPosterTrans; + + // Per-component player options saved by the widget + var componentPlayerOpts = val.playerOptions || {}; + var playerOptKeys = ['autoplay', 'muted', 'loop', 'controls']; + + var ffOrder = ['mobile', 'tablet', 'desktop']; + var ffOptions = {}; + + for (var fi = 0; fi < ffOrder.length; fi++) { + var ff = ffOrder[fi]; + var ffEntry = val.formValues[ff]; + if (!ffEntry || !ffEntry.asset) continue; + + var ffAsset = ffEntry.asset; + var ffConf = { + publicId: ffAsset.public_id, + transStr: transformationOverride, + isTransformationOverride: !!transformationOverride, + playerConfig: {}, + sourceConfig: { transformation: transformationOverride ? [{ raw_transformation: transformationOverride }] : [] } + }; + + var ffFormat = currentSite.getCustomPreferenceValue('CloudinaryVideoFormat'); + if (ffFormat !== null && ffFormat.value !== 'none') { + ffConf.sourceType = ffFormat; + } + + ffConf = videoPlayerConfigs(ffConf); + ffConf.playerConfig.posterOptions = {}; + + var ffQueryParams = {}; + ffQueryParams[constants.CLD_TRACKING_PARAM.slice(1).split('=')[0]] = constants.CLD_TRACKING_PARAM.slice(1).split('=')[1]; + ffConf.sourceConfig.queryParams = ffQueryParams; + + if (effectivePosterTrans && effectivePosterTrans.length) { + ffConf.playerConfig.posterOptions.transformation = effectivePosterTrans; + } + if ('videoAspectRatio' in context.content) { + ffConf.playerConfig.aspectRatio = context.content.videoAspectRatio; + } + + // Apply per-component player options (autoplay, muted, loop, controls) + for (var pi = 0; pi < playerOptKeys.length; pi++) { + var pk = playerOptKeys[pi]; + if (pk in componentPlayerOpts) { + ffConf.playerConfig[pk] = !!componentPlayerOpts[pk]; + } + } + + var ffMergedConfig = mergePlayerConfig(videoPlayerOptions, ffConf.playerConfig, context.content.overrideGlobalConfigs); + ffOptions[ff] = { + public_id: ffAsset.public_id, + widgetOptions: JSON.stringify({ playerConfig: ffMergedConfig, sourceConfig: ffConf.sourceConfig }) + }; + } + + // Check at least one form factor has an asset + var hasAny = ffOptions.mobile || ffOptions.tablet || ffOptions.desktop; + if (!hasAny) return viewmodel; + + // Server-side default: prefer desktop → tablet → mobile (client-side JS will override) + var defaultOpt = ffOptions.desktop || ffOptions.tablet || ffOptions.mobile; + + viewmodel.cloudName = currentSite.getCustomPreferenceValue('CloudinaryPageDesignerCloudName'); + viewmodel.public_id = defaultOpt.public_id; + viewmodel.id = idSafeString(randomString(16)); + viewmodel.widgetOptions = defaultOpt.widgetOptions; + viewmodel.formFactorOptions = JSON.stringify(ffOptions); + + return viewmodel; + } + + // ── Legacy format ───────────────────────────────────────────────────── + preRenderLegacy(context, val, viewmodel, currentSite, constants); return viewmodel; }; diff --git a/cartridges/int_cloudinary_pd/cartridge/static/default/js/cloudinaryVideos.js b/cartridges/int_cloudinary_pd/cartridge/static/default/js/cloudinaryVideos.js index eb1ac7b..531bd4a 100644 --- a/cartridges/int_cloudinary_pd/cartridge/static/default/js/cloudinaryVideos.js +++ b/cartridges/int_cloudinary_pd/cartridge/static/default/js/cloudinaryVideos.js @@ -1,3 +1,35 @@ +/** + * Resolves the correct widgetOptions and public_id for the current viewport + * when per-form-factor options are present. + * Breakpoints match the editor widget: mobile ≤ 767, tablet ≤ 1023, desktop > 1023. + * Falls back Mobile → Tablet → Desktop (matching editor inheritance order). + */ +function resolvePlayerOptions(player) { + var widgetOptions = player.widgetOptions; + var publicId = player.public_id; + + if (player.formFactorOptions) { + try { + var ffOptions = JSON.parse(player.formFactorOptions); + var w = window.innerWidth; + var ff = w <= 767 ? 'mobile' : (w <= 1023 ? 'tablet' : 'desktop'); + // Apply same inheritance fallback as the editor (Mobile → Tablet → Desktop) + var selected = ffOptions[ff] || + ffOptions['mobile'] || + ffOptions['tablet'] || + ffOptions['desktop']; + if (selected) { + widgetOptions = selected.widgetOptions; + publicId = selected.public_id; + } + } catch (e) { + // fall through to defaults already set above + } + } + + return { widgetOptions: widgetOptions, publicId: publicId }; +} + function initializeCloudinaryPlayers() { let conf = { cloud_name: window.cloudName @@ -10,9 +42,10 @@ function initializeCloudinaryPlayers() { window.players.forEach(player => { if (player && player.widgetOptions) { - const pCnf = JSON.parse(player.widgetOptions); + const resolved = resolvePlayerOptions(player); + const pCnf = JSON.parse(resolved.widgetOptions); const p = cld.videoPlayer(player.id, pCnf.playerConfig); - p.source(player.public_id, pCnf.sourceConfig); + p.source(resolved.publicId, pCnf.sourceConfig); p.on('error', function (e) { const error = e.Player.videojs.error(); if (error && error.code === 10) { From 814ba65e1142a484b90e4c78caf45eb872548267 Mon Sep 17 00:00:00 2001 From: Asad Saleem Date: Mon, 4 May 2026 12:47:20 +0500 Subject: [PATCH 2/2] iframe implementation --- .../cartridge/experience/editors/cloudinary/videoForm.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json index aaa505e..46d8f9d 100644 --- a/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json +++ b/cartridges/bm_cloudinary_pd/cartridge/experience/editors/cloudinary/videoForm.json @@ -1,6 +1,6 @@ { - "name": "Cloudinary Video Form", - "description": "Configure Image / Video with per-form-factor selection", + "name": "Cloudinary Video Config Form", + "description": "Configure Video", "resources": { "scripts": [ "https://media-library.cloudinary.com/global/all.js",