diff --git a/test/core/workflow/workflow-upload/action-binder.test.js b/test/core/workflow/workflow-upload/action-binder.test.js index 6af3f09f2..28b5b6c18 100644 --- a/test/core/workflow/workflow-upload/action-binder.test.js +++ b/test/core/workflow/workflow-upload/action-binder.test.js @@ -463,7 +463,7 @@ describe('Unity Upload Block', () => { actionBinder.serviceHandler = { showErrorToast: () => {} }; const invalidFile = new File(['test content'], 'test.txt', { type: 'text/plain' }); - await actionBinder.uploadImage([invalidFile]); + await actionBinder.uploadFile([invalidFile]); }); it('should show error for file size exceeding limit', async () => { @@ -484,7 +484,7 @@ describe('Unity Upload Block', () => { actionBinder.serviceHandler = { showErrorToast: () => {} }; const largeFile = new File(['x'.repeat(2000)], 'large.jpg', { type: 'image/jpeg' }); - await actionBinder.uploadImage([largeFile]); + await actionBinder.uploadFile([largeFile]); }); it('should show error for wrong number of files', async () => { @@ -496,13 +496,13 @@ describe('Unity Upload Block', () => { new File(['test1'], 'test1.jpg', { type: 'image/jpeg' }), new File(['test2'], 'test2.jpg', { type: 'image/jpeg' }), ]; - await actionBinder.uploadImage(files); + await actionBinder.uploadFile(files); }); it('should handle null files', async () => { const actionBinder = new ActionBinder(unityEl, workflowCfg, unityEl, [unityEl]); - await actionBinder.uploadImage(null); + await actionBinder.uploadFile(null); }); }); @@ -978,7 +978,7 @@ describe('Unity Upload Block', () => { const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }); try { - await actionBinder.uploadImage([file]); + await actionBinder.uploadFile([file]); expect.fail('Should have thrown an error due to missing URL'); } catch (error) { expect(error.message).to.equal('Error connecting to App'); @@ -1169,13 +1169,13 @@ describe('Unity Upload Block', () => { expect(files).to.have.length(0); }); - it('should handle uploadImage with null files', async () => { + it('should handle uploadFile with null files', async () => { const actionBinder = new ActionBinder(unityEl, workflowCfg, unityEl, [unityEl]); - await actionBinder.uploadImage(null); + await actionBinder.uploadFile(null); }); - it('should handle uploadImage with wrong number of files', async () => { + it('should handle uploadFile with wrong number of files', async () => { const actionBinder = new ActionBinder(unityEl, workflowCfg, unityEl, [unityEl]); actionBinder.serviceHandler = { showErrorToast: () => {} }; @@ -1185,20 +1185,20 @@ describe('Unity Upload Block', () => { new File(['test2'], 'test2.jpg', { type: 'image/jpeg' }), ]; - await actionBinder.uploadImage(files); + await actionBinder.uploadFile(files); }); - it('should handle uploadImage with invalid file type', async () => { + it('should handle uploadFile with invalid file type', async () => { const actionBinder = new ActionBinder(unityEl, workflowCfg, unityEl, [unityEl]); actionBinder.serviceHandler = { showErrorToast: () => {} }; const files = [new File(['test'], 'test.txt', { type: 'text/plain' })]; - await actionBinder.uploadImage(files); + await actionBinder.uploadFile(files); }); - it('should handle uploadImage with file size exceeding limit', async () => { + it('should handle uploadFile with file size exceeding limit', async () => { const testWorkflowCfg = { productName: 'test-product', targetCfg: { @@ -1217,10 +1217,10 @@ describe('Unity Upload Block', () => { const files = [new File(['x'.repeat(2000)], 'test.jpg', { type: 'image/jpeg' })]; - await actionBinder.uploadImage(files); + await actionBinder.uploadFile(files); }); - it('should handle uploadImage with PSW feature enabled', async () => { + it('should handle uploadFile with PSW feature enabled', async () => { const testWorkflowCfg = { ...workflowCfg, pswFeature: true, @@ -1256,7 +1256,7 @@ describe('Unity Upload Block', () => { actionBinder.continueInApp = async () => Promise.resolve(); const file = new File(['test'], 'test.jpg', { type: 'image/jpeg' }); - await actionBinder.uploadImage([file]); + await actionBinder.uploadFile([file]); window.fetch = originalFetch; actionBinder.checkImageDimensions = originalCheckImageDimensions; diff --git a/unitylibs/core/widgets/prompt-bar-style/prompt-bar-style.css b/unitylibs/core/widgets/prompt-bar-style/prompt-bar-style.css index c743bedaa..b7dd5bcb7 100644 --- a/unitylibs/core/widgets/prompt-bar-style/prompt-bar-style.css +++ b/unitylibs/core/widgets/prompt-bar-style/prompt-bar-style.css @@ -1333,4 +1333,4 @@ font-size: 64px; letter-spacing: -1.92px; } -} +} \ No newline at end of file diff --git a/unitylibs/core/widgets/prompt-bar-upload/prompt-bar-upload.css b/unitylibs/core/widgets/prompt-bar-upload/prompt-bar-upload.css new file mode 100644 index 000000000..b29feb7c7 --- /dev/null +++ b/unitylibs/core/widgets/prompt-bar-upload/prompt-bar-upload.css @@ -0,0 +1,918 @@ +.upload-marquee.unity-enabled .interactive-area { + display: inherit; + background: none; +} + +.unity-prompt-bar-upload.unity-enabled { +width: 100%; +max-width: 1000px; +margin-top: 24px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area { +width: 727px; +background: #222 !important; +border: none !important; +box-shadow: none !important; +border-radius: 20px !important; +padding: 8px !important; +box-sizing: border-box !important; +max-width: 727px; +} + +@media screen and (min-width: 1200px) { +.unity-prompt-bar-upload.unity-enabled .interactive-area { + padding: 14px !important; +} +} + +.ex-unity-wrap.pbu-widget .pbu-legal-foot { +font-size: 14px; +width: fit-content; +max-width: 727px; +box-sizing: border-box; +font-family: "Adobe Clean", adobe-clean, "Adobe Clean Serif", sans-serif; +font-size: 13px; +font-weight: 400; +line-height: 1.45; +color: #f8f8f8; +text-align: start; +margin-top: 12px; +} + +.ex-unity-wrap.pbu-widget .pbu-legal-foot a { +color: #1473E6; +text-decoration: none; +} + +.ex-unity-wrap.pbu-widget .pbu-legal-foot a:hover { +text-decoration: underline; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-main { +position: relative; +padding: 16px; +border-radius: 10px; +background: #1B1B1B; +display: flex; +flex-direction: row; +align-items: flex-start; +gap: 16px; +width: 100%; +box-sizing: border-box; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-left-section { +display: flex; +flex-direction: column; +align-items: flex-start; +gap: 17px; +flex-shrink: 0; +border-right: 1px solid rgb(255 255 255 / 12%); +padding-right: 16px; +box-sizing: border-box; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-right-section { +display: flex; +flex-direction: column; +align-items: stretch; +gap: 0; +flex: 1; +min-width: 0; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-right-section .pbu-prompt-bar-container { +display: flex; +flex-direction: column; +gap: 0; +width: 100%; +min-width: 0; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-main .alert-holder { +position: absolute; +inset: 0; +width: 100%; +height: 100%; +display: none; +align-items: center; +justify-content: center; +padding: 12px; +box-sizing: border-box; +z-index: 350; +border-radius: 10px; +background: rgb(0 0 0 / 60%); +pointer-events: none; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-main .alert-holder.show { +display: flex; +pointer-events: auto; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-main .alert-holder .alert-toast { +position: relative; +left: auto; +right: auto; +top: auto; +bottom: auto; +margin: 0 auto; +width: min(339px, 100%); +max-width: 100%; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-right-section .unity-slf-prompt-label { +margin-bottom: 0px; +padding: 0; +display: block; +flex-shrink: 0; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-right-section .inp-field { +flex: 1; +min-height: 80px; +resize: none; +width: 100%; +box-sizing: border-box; +padding-bottom: 10px; +margin: 0 0 12px 0; +border: none; +background: transparent; +outline: none; +font-size: var(--type-body-s-size, 15px); +line-height: 1.45; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-controls-footer { +display: flex; +flex-direction: row; +align-items: center; +gap: 8px; +width: 100%; +box-sizing: border-box; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .action-container { +display: flex !important; +flex-wrap: wrap; +align-items: center; +gap: 8px; +justify-content: flex-start; +flex: 1; +margin-top: 0 !important; +min-width: 0; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .act-wrap { +display: flex !important; +align-items: center; +gap: 10px; +justify-content: flex-end; +margin-top: 0 !important; +flex-shrink: 0; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .action-container:empty ~ .act-wrap { +flex: 1; +justify-content: flex-end; +} + +.unity-prompt-bar-upload.unity-enabled .unity-slf-copy-label { +color: #d1d1d1; +font-family: "Adobe Clean", adobe-clean, "Adobe Clean Serif", sans-serif; +font-size: 14px; +font-weight: 400; +line-height: 18px; +} + +.unity-prompt-bar-upload.unity-enabled .unity-slf-prompt-label { +display: block; +margin-bottom: 8px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .inp-field, +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .inp-field::placeholder { +color: #f8f8f8 !important; +margin-bottom: 18px; +font-family: "Adobe Clean", adobe-clean, "Adobe Clean Serif", sans-serif; +font-size: 16px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.light .inp-field, +.unity-prompt-bar-upload.unity-enabled .interactive-area.light .inp-field::placeholder { +color: #292929; +} + +@keyframes pbu-model-fade-in { +0% { opacity: 0; } +100% { opacity: 1; } +} + +@keyframes pbu-model-move-down { +0% { + transform: translateY(33px); + opacity: 0; + display: none; +} +100% { + transform: translateY(40px); + opacity: 1; +} +} + +@keyframes pbu-model-move-up { +0% { + transform: translateY(40px); + opacity: 1; +} +100% { + transform: translateY(33px); + opacity: 0; + display: none; +} +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .selected-model img, +.unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list .verb-link img { +width: 22px; +height: 22px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .selected-model .menu-icon, +.unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list .verb-link .selected-icon { +font-size: 0; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .selected-model .menu-icon, +.unity-prompt-bar-upload.unity-enabled .interactive-area .selected-model .menu-icon svg, +.unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list .verb-link .selected-icon, +.unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list .verb-link .selected-icon svg { +width: 12px; +height: 12px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .models-container, +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .models-container { +display: flex; +flex: 0 1 auto; +max-width: none; +width: auto; +position: relative; +align-items: center; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .pbu-controls-footer .models-container { +max-width: none; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.light .verb-list .verb-link, +.unity-prompt-bar-upload.unity-enabled .interactive-area.light .verb-list .verb-link .model-name { +color: #292929; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .models-container:not(.pbu-aspect-models) .verb-list { +min-width: 270px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .models-container .menu-icon { +position: relative; +top: 1px; +display: flex; +align-items: center; +justify-content: center; +flex-shrink: 0; +transition: transform 0.15s ease-in; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .models-container.show-menu .menu-icon { +transform: rotate(-180deg); +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .selected-model { +display: inline-flex; +align-items: center; +gap: 8px; +justify-content: flex-start; +padding: 8px 12px; +min-height: 32px; +width: auto; +min-width: 27px; +max-width: 200px; +box-sizing: border-box; +cursor: pointer; +border: none; +font-family: "Adobe Clean", adobe-clean, "Adobe Clean Serif", sans-serif; +font-size: 14px; +font-weight: 400; +line-height: 1.2; +text-transform: none; +white-space: nowrap; +background: #353535; +color: #fff; +border-radius: 10px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .selected-model:hover { +background: #434343; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .selected-model:focus-visible { +outline: 2px solid #2680eb; +outline-offset: 2px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .selected-model .model-name { +color: #fff; +overflow: hidden; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .selected-model .menu-icon svg, +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .models-container .menu-icon svg { +filter: brightness(0) invert(1); +opacity: 0.95; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .selected-model img { +width: 20px; +height: 20px; +border-radius: 6px; +flex-shrink: 0; +object-fit: cover; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .pbu-aspect-models .selected-model:not(:has(img))::before { +content: ''; +display: block; +width: 22px; +height: 12px; +box-sizing: border-box; +border: 1.5px solid rgb(255 255 255 / 92%); +border-radius: 2px; +flex-shrink: 0; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.light .selected-model { +display: inline-flex; +align-items: center; +gap: 8px; +justify-content: flex-start; +padding: 8px 12px; +min-height: 40px; +width: auto; +border: none; +border-radius: 10px; +background: rgb(0 0 0 / 8%); +color: #292929; +font-family: "Adobe Clean", adobe-clean, "Adobe Clean Serif", sans-serif; +font-size: 14px; +font-weight: 400; +cursor: pointer; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.light .selected-model .model-name { +color: #292929; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.light .selected-model .menu-icon svg, +.unity-prompt-bar-upload.unity-enabled .interactive-area.light .models-container .menu-icon svg { +filter: brightness(0); +opacity: 0.85; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.light .pbu-aspect-models .selected-model:not(:has(img))::before { +content: ''; +display: block; +width: 22px; +height: 12px; +box-sizing: border-box; +border: 1.5px solid rgb(0 0 0 / 55%); +border-radius: 2px; +flex-shrink: 0; +} + +:root:has(meta[name="theme"][content="max25"], .theme-two) .unity-prompt-bar-upload.unity-enabled .interactive-area.dark .selected-model, +:root:has(meta[name="theme"][content="max25"], .theme-two) .unity-prompt-bar-upload.unity-enabled .interactive-area.light .selected-model { +border-radius: 12px; +min-height: 36px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list { +padding: 18px; +list-style: none; +box-shadow: 0 0 10px #0000001c; +border-radius: 10px; +background: rgb(255 255 255 / 100%); +color: #292929; +margin: 0; +min-width: 110px; +animation: pbu-model-move-up 0.2s ease forwards; +position: absolute; +top: 0; +left: 0; +z-index: 1; +} + +:root:has(meta[name="theme"][content="max25"], .theme-two) .unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list { +border-radius: 14px; +} + +[lang="ja-JP"] .unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list, +[lang="ko-KR"] .unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list { +margin-top: 6px; +} + +[dir="rtl"] .unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list { +left: unset; +right: 0; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .models-container.show-menu .verb-list { +display: block; +animation: pbu-model-move-down 0.4s cubic-bezier(0.5, 1.8, 0.3, 0.8) forwards; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list .verb-link { +display: flex; +align-items: center; +gap: 10px; +padding: 10px; +padding-inline-start: 25px; +text-transform: capitalize; +text-decoration: none; +text-align: start; +position: relative; +opacity: 0; +animation: pbu-model-fade-in 0.5s ease forwards; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .models-container .verb-list, +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .verbs-container .verb-list { +background: #000; +color: #f8f8f8; +box-shadow: 0 8px 32px rgb(0 0 0 / 55%); +border: 1px solid rgb(255 255 255 / 10%); +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .verb-list .verb-link, +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .verb-list .verb-link .model-name { +color: #f8f8f8; +text-transform: none; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .verb-list .verb-link:hover, +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .verb-list .verb-link:focus-visible { +background: transparent; +border-radius: 0; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .verb-list .verb-item.selected .verb-link { +background: transparent; +border-radius: 0; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .verb-list .verb-link .selected-icon { +flex-shrink: 0; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area.dark .verb-list .verb-link .selected-icon svg { +width: 12px; +height: 12px; +display: block; +} + +:root:has(meta[name="theme"][content="max25"], .theme-two) .unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list .verb-link { +font-size: 14px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list .model-link { +font-size: 14px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list .verb-item .selected-icon { +display: none; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list .verb-item.selected .selected-icon { +display: block; +position: absolute; +top: 50%; +left: 3px; +transform: translateY(-50%); +} + +[dir="rtl"] .unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list .verb-item.selected .selected-icon { +right: 3px; +left: unset; +} + +@media (max-width: 1024px) { +.unity-prompt-bar-upload.unity-enabled .interactive-area .models-container.show-menu .verb-list { + animation: pbu-model-move-down 0.4s cubic-bezier(0.5, 1.8, 0.3, 0.8) forwards; +} +} + +@media screen and (max-width: 599px) { +.unity-prompt-bar-upload.unity-enabled .interactive-area .models-container { + width: auto; +} + +[dir="rtl"] .unity-prompt-bar-upload.unity-enabled .interactive-area .models-container.show-menu .verb-list { + left: unset; +} + +:root:has(meta[name="theme"][content="max25"], .theme-two) .unity-prompt-bar-upload.unity-enabled .interactive-area .verb-list { + top: unset; + left: unset; +} + +:root:has(meta[name="theme"][content="max25"], .theme-two) .unity-prompt-bar-upload.unity-enabled .interactive-area .models-container .verb-list { + left: 0; + width: 100%; + box-sizing: border-box; + margin-top: 30px; +} + +.unity-prompt-bar-upload.unity-enabled .interactive-area .selected-model .model-name { + display: none; +} + +:root:has(meta[name="theme"][content="max25"], .theme-two) .unity-prompt-bar-upload.unity-enabled .interactive-area .models-container { + position: static; +} +} + +.unity-prompt-bar-upload.unity-enabled .act-wrap .pbu-more-btn { +background: transparent; +border: none; +padding: 7px 12px; +gap: 6px; +color: #f8f8f8; +} + +.unity-prompt-bar-upload.unity-enabled .act-wrap .pbu-more-btn .btn-ico { +display: flex; +align-items: center; +flex-shrink: 0; +} + +.unity-prompt-bar-upload.unity-enabled .act-wrap .pbu-more-btn .btn-ico svg { +width: 20px; +height: 20px; +} + +.unity-prompt-bar-upload.unity-enabled .act-wrap .pbu-more-btn .btn-txt { +color: #f8f8f8; +font-size: 15px; +font-weight: 400; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .models-container .verb-list, +.unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .verbs-container .verb-list { +top: auto; +bottom: 100%; +transform: none; +animation: none; +margin-bottom: 4px; +z-index: 10; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .models-container, +.unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .verbs-container { +position: relative; +z-index: 1; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .models-container.show-menu, +.unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .verbs-container.show-menu { +z-index: 300; +} + +.pbu-drop-zone-wrap { +position: relative; +width: 123px; +height: 123px; +min-height: 123px; +flex-shrink: 0; +box-sizing: border-box; +} + +.pbu-drop-zone-wrap .drop-zone { +margin-top: 0px; +width: 123px; +height: 123px; +border-radius: 13.667px; +border: 2px dashed rgba(198, 198, 198, 0.50); +padding: 0; +background: none; +display: flex; +flex-direction: column; +align-items: center; +justify-content: center; +gap: 8px; +box-sizing: border-box; +} + +.pbu-drop-zone-wrap .drop-zone:hover, +.pbu-drop-zone-wrap .drop-zone.drag-over { +border-color: rgb(255 255 255 / 55%); +background: rgb(255 255 255 / 4%); +} + +.pbu-drop-zone-wrap .drop-zone.drag-over { +border-color: #4069FD; +border: 2px solid; +background: rgb(64 105 253 / 14%); +} + +.pbu-drop-zone-wrap .drop-zone.hidden { +display: none; +} + +.pbu-drop-content { +display: flex; +flex-direction: column; +align-items: center; +gap: 6px; +pointer-events: none; +} + +.pbu-upload-svg { +width: 28px; +height: 28px; +color: rgb(255 255 255 / 65%); +} + +.pbu-upload-text { +font-size: 13px; +line-height: 1.35; +color: rgb(255 255 255 / 70%); +} + +.pbu-legal-text { +font-size: 10px; +color: rgb(255 255 255 / 45%); +margin: 0; +} + +.pbu-preview { +position: absolute; +top: 0; +left: 0; +width: 123px; +height: 123px; +box-sizing: border-box; +border-radius: 13.667px; +overflow: hidden; +border: 2px solid #2680eb; +background: rgb(0 0 0 / 20%); +} + +.pbu-preview.hidden { +display: none; +} + +.pbu-preview-img { +display: block; +width: 100%; +height: 100%; +object-fit: cover; +border-radius: 13.667px; +border: 2px solid #4069FD; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-delete-btn { +border-radius: 16px; +background: rgb(255 255 255 / 94%); +box-shadow: 0 2px 8px 0 rgb(0 0 0 / 16%); +display: flex; +justify-content: center; +align-items: center; +position: absolute; +top: 50%; +left: 50%; +right: auto; +transform: translate(-50%, -50%); +width: 32px; +height: 32px; +padding: 0; +border: none; +cursor: pointer; +opacity: 0; +transition: opacity 0.2s; +z-index: 2; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-delete-btn svg { +display: block; +width: 18px; +height: 18px; +} + +.pbu-drop-zone-wrap .pbu-select-spinner { +position: absolute; +top: 0; +left: 0; +width: 123px; +height: 123px; +box-sizing: border-box; +border-radius: 13.667px; +display: flex; +align-items: center; +justify-content: center; +background: rgb(0 0 0 / 35%); +z-index: 3; +} + +.pbu-drop-zone-wrap.pbu-select-processing .drop-zone { +background: #000; +border: 2px solid #4069FD; +border-style: solid; +} + +.pbu-drop-zone-wrap.pbu-select-processing .pbu-drop-content { +visibility: hidden; +pointer-events: none; +} + +.pbu-drop-zone-wrap.pbu-select-processing .pbu-select-spinner { +background: transparent; +} + +.pbu-drop-zone-wrap .pbu-select-spinner.hidden { +display: none; +} + +.pbu-select-spinner-ring { +width: 32px; +height: 32px; +border: 3px solid rgb(255 255 255 / 35%); +border-top-color: #fff; +border-radius: 50%; +animation: pbu-spin 0.7s linear infinite; +} + +.unity-prompt-bar-upload.unity-enabled .pbu-preview:hover .pbu-delete-btn, +.unity-prompt-bar-upload.unity-enabled .pbu-delete-btn:focus-visible { +opacity: 1; +} + +.pbu-spinner { +position: absolute; +inset: 0; +display: flex; +align-items: center; +justify-content: center; +background: rgb(0 0 0 / 35%); +} + +.pbu-spinner.hidden { +display: none; +} + +.pbu-spinner::after { +content: ''; +width: 32px; +height: 32px; +border: 3px solid rgb(255 255 255 / 35%); +border-top-color: #fff; +border-radius: 50%; +animation: pbu-spin 0.7s linear infinite; +} + +@keyframes pbu-spin { +to { transform: rotate(360deg); } +} + +.action-container > a.unity-act-btn.pbu-more-btn.more-btn { +display: flex; +gap: 6px; +text-decoration: none; +} + +.action-container > a.unity-act-btn.pbu-more-btn.more-btn .btn-ico { +height: 20px; +width: 20px;; +padding: 6px 0px 6px 10px; +align-self: center; +} + +.action-container > a.unity-act-btn.pbu-more-btn.more-btn .btn-ico svg { +width: 20px; +height: 20px; +} +.action-container > a.unity-act-btn.pbu-more-btn.more-btn .btn-txt { +color: #C6C6C6; +} + +.pbu-main .pbu-controls-footer .act-wrap a.gen-btn { +border-radius: 25px; +background: linear-gradient(90deg, #D73220 0%, #D92361 33%, #7155FA 100%); +border: none; +padding: 10px 20px 10px 18px; +gap: 8px; +text-decoration: none; +display: flex; +align-items: center; +} + +.pbu-main .pbu-controls-footer .act-wrap a.gen-btn .btn-ico { +height: fit-content; +display: flex; +} + +.pbu-main .pbu-controls-footer .act-wrap a.gen-btn .btn-ico img { +width: 22px; +height: 22px; +} + +.pbu-main .pbu-controls-footer .act-wrap a.gen-btn .btn-txt{ +color: var(--color-white); +font-size: 16px; +font-weight: 700; +line-height: normal; +} + +@media screen and (max-width: 599px) { + .unity-prompt-bar-upload.unity-enabled .interactive-area { + width: 100%; + max-width: 100%; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-main { + flex-direction: column; + gap: 0; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-left-section { + width: 100%; + max-width: 100%; + border-right: none; + padding-right: 0; + padding-bottom: 16px; + margin-bottom: 0; + border-bottom: 1px solid rgb(255 255 255 / 12%); + } + + .unity-prompt-bar-upload.unity-enabled .pbu-drop-zone-wrap { + width: 100%; + height: 160px; + min-height: 160px; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-drop-zone-wrap .drop-zone { + width: 100%; + height: 160px; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-preview { + width: 100%; + height: 160px; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-drop-zone-wrap .pbu-select-spinner { + width: 100%; + height: 160px; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-right-section { + width: 100%; + padding-top: 12px; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-controls-footer { + flex-direction: row; + align-items: center; + gap: 8px; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .action-container { + flex: 0 0 auto; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .act-wrap { + flex: 1; + justify-content: flex-end; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-controls-footer .act-wrap .gen-btn { + flex: 1; + justify-content: center; + } + + .unity-prompt-bar-upload.unity-enabled .interactive-area .pbu-controls-footer .pbu-aspect-models { + display: none; + } + + .unity-prompt-bar-upload.unity-enabled .pbu-more-btn .btn-txt { + display: none; + } + + .unity-prompt-bar-upload.unity-enabled .interactive-area.dark .selected-model, + .unity-prompt-bar-upload.unity-enabled .interactive-area.dark .selected-model:hover, + .unity-prompt-bar-upload.unity-enabled .interactive-area.light .selected-model, + .unity-prompt-bar-upload.unity-enabled .interactive-area.light .selected-model:hover { + background: transparent; + padding: 4px 6px; + } +} diff --git a/unitylibs/core/widgets/prompt-bar-upload/prompt-bar-upload.js b/unitylibs/core/widgets/prompt-bar-upload/prompt-bar-upload.js new file mode 100644 index 000000000..4389b5f93 --- /dev/null +++ b/unitylibs/core/widgets/prompt-bar-upload/prompt-bar-upload.js @@ -0,0 +1,607 @@ +/* eslint-disable no-await-in-loop */ + +import { createTag, getUnityLibs } from '../../../scripts/utils.js'; + +function placeholderText(root, iconClass) { + const icon = root.querySelector(`.${iconClass}`) || root.querySelector(`[class*="${iconClass}"]`); + if (!icon) return ''; + return (icon.closest('li')?.innerText || '').replace(/\s+/g, ' ').trim(); +} + +function labelForField(root, iconClass, fallback) { + return placeholderText(root, iconClass) || fallback; +} + +function extractLegalFootFromAuthoring(root) { + const marker = root.querySelector('[class*="icon-legal-terms"]'); + if (!marker) return null; + const li = marker.closest('li'); + const foot = createTag('div', { class: 'pbu-legal-foot' }); + if (li?.parentElement) { + while (li.firstChild) foot.append(li.firstChild); + li.remove(); + return foot; + } + foot.append(marker.cloneNode(true)); + marker.remove(); + return foot; +} + +function svgIcon(href) { + return ``; +} + +function syncDropdownSelection(list, activeLink) { + list.querySelectorAll('li').forEach((li) => { + const a = li.querySelector('a'); + const isActive = a === activeLink; + li.classList.toggle('selected', isActive); + a?.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); +} + +function closeDropdown(container, triggerBtn, list) { + container.classList.remove('show-menu'); + list.setAttribute('style', 'display: none;'); + triggerBtn.setAttribute('aria-expanded', 'false'); +} + +function buildDropdownShell({ label, menuId, extraClass = '', imgEl = null, ariaLabelledBy = null }) { + const container = createTag('div', { + class: `models-container${extraClass ? ` ${extraClass}` : ''}`, + 'aria-label': label, + }); + + const nameContainer = createTag('span', { class: 'model-name' }); + const menuIcon = createTag('span', { class: 'menu-icon' }, svgIcon('#unity-chevron-icon')); + + const triggerBtn = createTag('button', { + type: 'button', + class: 'selected-model', + 'aria-expanded': 'false', + 'aria-controls': menuId, + 'aria-haspopup': 'listbox', + role: 'combobox', + }); + if (imgEl) triggerBtn.append(imgEl, nameContainer, menuIcon); + else triggerBtn.append(nameContainer, menuIcon); + + const listAttrs = { class: 'verb-list', id: menuId, role: 'listbox' }; + if (ariaLabelledBy) listAttrs['aria-labelledby'] = ariaLabelledBy; + const list = createTag('ul', listAttrs); + list.setAttribute('style', 'display: none;'); + + container.append(triggerBtn, list); + return { + container, triggerBtn, nameContainer, menuIcon, list, + }; +} + +function attachDropdownBehavior(container, triggerBtn, list) { + const getOptions = () => [...list.querySelectorAll('a.model-link')]; + const focusSelectedOrFirst = () => { + const options = getOptions(); + if (!options.length) return; + const selected = options.find((option) => option.getAttribute('aria-selected') === 'true'); + (selected || options[0])?.focus(); + }; + + triggerBtn.addEventListener('click', (e) => { + e.stopPropagation(); + document.querySelectorAll('.models-container.show-menu').forEach((other) => { + if (other === container) return; + other.classList.remove('show-menu'); + other.querySelector(':scope > .verb-list')?.setAttribute('style', 'display: none;'); + other.querySelector('.selected-model')?.setAttribute('aria-expanded', 'false'); + }); + const isOpen = container.classList.toggle('show-menu'); + if (isOpen) list.removeAttribute('style'); + else list.setAttribute('style', 'display: none;'); + triggerBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + }); + + triggerBtn.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + closeDropdown(container, triggerBtn, list); + triggerBtn.focus(); + return; + } + + if (!['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(e.key)) return; + e.preventDefault(); + const isOpen = container.classList.contains('show-menu'); + if (!isOpen) { + container.classList.add('show-menu'); + list.removeAttribute('style'); + triggerBtn.setAttribute('aria-expanded', 'true'); + } + focusSelectedOrFirst(); + }); + + list.addEventListener('keydown', (e) => { + const options = getOptions(); + if (!options.length) return; + const idx = options.findIndex((option) => option === document.activeElement); + if (e.key === 'Tab') { + if (idx < 0) return; + const atStart = idx === 0; + const atEnd = idx === options.length - 1; + if ((e.shiftKey && atStart) || (!e.shiftKey && atEnd)) { + closeDropdown(container, triggerBtn, list); + } + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + closeDropdown(container, triggerBtn, list); + triggerBtn.focus(); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = idx < 0 ? 0 : (idx + 1) % options.length; + options[next]?.focus(); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + const next = idx < 0 ? options.length - 1 : (idx - 1 + options.length) % options.length; + options[next]?.focus(); + return; + } + if (e.key === 'Home') { + e.preventDefault(); + options[0]?.focus(); + return; + } + if (e.key === 'End') { + e.preventDefault(); + options[options.length - 1]?.focus(); + return; + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const active = idx >= 0 ? options[idx] : options[0]; + active?.click(); + } + }); + + document.addEventListener('click', (e) => { + if (!container.contains(/** @type {Node} */ (e.target))) { + closeDropdown(container, triggerBtn, list); + } + }); +} + +export default class PromptBarUploadWidget { + constructor(target, el, workflowCfg, spriteCon) { + this.target = target; + this.el = el; + this.workflowCfg = workflowCfg; + this.spriteCon = spriteCon; + this.widgetWrap = null; + this.actionMap = {}; + this.models = null; + this.aspectRatioMap = {}; + this.sizeMap = {}; + this.selectedModelId = ''; + this.selectedAspectRatio = ''; + this.lanaOptions = { sampleRate: 1, tags: 'Unity-FF-PBU' }; + this.showAspectRatio = false; + this.showMore = false; + this.actionContainerEl = null; + } + + async loadModels() { + const { origin } = window.location; + const baseUrl = (origin.includes('.aem.') || origin.includes('.hlx.')) + ? `https://main--unity--adobecom.${origin.includes('.hlx.') ? 'hlx' : 'aem'}.live` + : origin; + const res = await fetch(`${baseUrl}/unity/configs/prompt/model-picker-video.json`); + if (!res.ok) throw new Error('Failed to fetch video models.'); + const json = await res.json(); + this.models = json?.content?.data || []; + this.buildAspectRatioMap(); + } + + buildAspectRatioMap() { + this.aspectRatioMap = {}; + this.sizeMap = {}; + const parseList = (str) => { + const s = String(str); + try { + const parsed = JSON.parse(s); + if (Array.isArray(parsed)) return parsed.map(String); + } catch { /* fall through to comma-split */ } + return s.split(',').map((v) => v.trim()).filter(Boolean); + }; + (this.models || []).forEach((item) => { + const raw = item['aspect-ratio']; + if (!item.id || !raw) return; + const ratios = parseList(raw); + this.aspectRatioMap[item.id] = ratios; + const widths = item.width != null ? parseList(item.width) : []; + const heights = item.height != null ? parseList(item.height) : []; + this.sizeMap[item.id] = ratios.map((_, i) => ({ + width: Number(widths[i]) || null, + height: Number(heights[i]) || null, + })); + }); + } + + getAspectRatiosForModel(modelId) { + return this.aspectRatioMap[modelId] || []; + } + + getSizeForAspectRatio(modelId, ratio) { + const sizes = this.sizeMap[modelId] || []; + const ratios = this.aspectRatioMap[modelId] || []; + const idx = ratios.indexOf(ratio); + return idx !== -1 ? sizes[idx] : null; + } + + readFeatureFlags() { + this.showAspectRatio = !!this.el.querySelector('[class*="icon-show-aspect-ratio"]'); + this.showMore = !!this.el.querySelector('[class*="icon-show-more"]'); + } + + buildModelPicker() { + if (!this.models?.length) return null; + const defaultModel = this.models.find((m) => m.default === 'true' || m.default === true) || this.models[0]; + this.selectedModelId = defaultModel?.id || ''; + + const imgEl = defaultModel?.icon ? createTag('img', { src: defaultModel.icon, alt: '' }) : null; + const { container, triggerBtn, nameContainer, list } = buildDropdownShell({ + label: 'Model options', + menuId: 'pbu-model-menu', + imgEl, + ariaLabelledBy: 'listbox-label', + }); + nameContainer.textContent = (defaultModel?.name || '').trim(); + + this.models.forEach((model, idx) => { + const selectedIcon = createTag('span', { class: 'selected-icon' }, svgIcon('#unity-checkmark-icon')); + const nameSpan = createTag('span', { class: 'model-name' }, (model.name || model.id || '').trim()); + const link = createTag('a', { + href: '#', + class: 'verb-link model-link', + 'data-model-id': model.id, + 'data-model-name': (model.name || '').trim(), + 'data-model-icon': model.icon || '', + ...(model.version != null && model.version !== '' ? { 'data-model-version': String(model.version) } : {}), + 'aria-selected': idx === 0 ? 'true' : 'false', + role: 'option', + }); + link.append(selectedIcon); + if (model.icon) link.append(createTag('img', { src: model.icon, alt: '' })); + link.append(nameSpan); + const li = createTag('li', { class: `verb-item${idx === 0 ? ' selected' : ''}`, role: 'presentation' }); + li.append(link); + list.append(li); + }); + + list.addEventListener('click', (e) => { + const link = e.target.closest('a.model-link'); + if (!link) return; + e.preventDefault(); + e.stopPropagation(); + const modelId = link.getAttribute('data-model-id') || ''; + const modelName = link.getAttribute('data-model-name') || ''; + const modelIcon = link.getAttribute('data-model-icon') || ''; + const modelVersion = link.getAttribute('data-model-version') || ''; + this.selectedModelId = modelId; + nameContainer.textContent = modelName; + const triggerIcon = triggerBtn.querySelector(':scope > img'); + if (modelIcon) { + if (triggerIcon) { + triggerIcon.setAttribute('src', modelIcon); + } else { + triggerBtn.prepend(createTag('img', { src: modelIcon, alt: '' })); + } + } else if (triggerIcon) { + triggerIcon.remove(); + } + this.widgetWrap?.setAttribute('data-selected-model-id', modelId); + this.widgetWrap?.setAttribute('data-selected-model-name', modelName); + if (modelVersion) this.widgetWrap?.setAttribute('data-selected-model-version', modelVersion); + else this.widgetWrap?.removeAttribute('data-selected-model-version'); + syncDropdownSelection(list, link); + closeDropdown(container, triggerBtn, list); + if (this.showAspectRatio) this.updateAspectRatioOptions(modelId); + }); + + triggerBtn.addEventListener('click', () => triggerBtn.dispatchEvent(new CustomEvent('pbu-model-dropdown-open', { bubbles: true }))); + attachDropdownBehavior(container, triggerBtn, list); + this.widgetWrap?.setAttribute('data-selected-model-id', this.selectedModelId); + this.widgetWrap?.setAttribute('data-selected-model-name', (defaultModel?.name || '').trim()); + if (defaultModel?.version != null && defaultModel.version !== '') { + this.widgetWrap?.setAttribute('data-selected-model-version', String(defaultModel.version)); + } else { + this.widgetWrap?.removeAttribute('data-selected-model-version'); + } + return container; + } + + setSelectedAspectRatio(modelId, ratio) { + this.selectedAspectRatio = ratio; + this.widgetWrap?.setAttribute('data-selected-aspect-ratio', ratio); + const size = this.getSizeForAspectRatio(modelId, ratio); + if (size?.width) this.widgetWrap?.setAttribute('data-selected-width', size.width); + else this.widgetWrap?.removeAttribute('data-selected-width'); + if (size?.height) this.widgetWrap?.setAttribute('data-selected-height', size.height); + else this.widgetWrap?.removeAttribute('data-selected-height'); + } + + syncDefaultAttributes() { + if (!this.widgetWrap || !this.selectedModelId) return; + const defaultModel = this.models?.find((m) => m.id === this.selectedModelId); + this.widgetWrap.setAttribute('data-selected-model-id', this.selectedModelId); + this.widgetWrap.setAttribute('data-selected-model-name', (defaultModel?.name || '').trim()); + if (defaultModel?.version != null && defaultModel.version !== '') { + this.widgetWrap.setAttribute('data-selected-model-version', String(defaultModel.version)); + } else { + this.widgetWrap.removeAttribute('data-selected-model-version'); + } + if (this.selectedAspectRatio) { + this.setSelectedAspectRatio(this.selectedModelId, this.selectedAspectRatio); + } + } + + buildAspectRatioDropdown(modelId) { + const ratios = this.getAspectRatiosForModel(modelId); + if (!ratios.length) return null; + this.setSelectedAspectRatio(modelId, ratios[0]); + + const { container, triggerBtn, nameContainer, list } = buildDropdownShell({ + label: 'Aspect ratio', + menuId: 'pbu-aspect-menu', + extraClass: 'pbu-aspect-models', + }); + nameContainer.textContent = ratios[0]; + + ratios.forEach((ratio, idx) => { + const selectedIcon = createTag('span', { class: 'selected-icon' }, svgIcon('#unity-checkmark-icon')); + const link = createTag('a', { + href: '#', + class: 'verb-link model-link', + 'data-ratio': ratio, + 'aria-selected': idx === 0 ? 'true' : 'false', + role: 'option', + }); + link.append(selectedIcon, createTag('span', { class: 'model-name' }, ratio)); + const li = createTag('li', { class: `verb-item${idx === 0 ? ' selected' : ''}`, role: 'presentation' }); + li.append(link); + list.append(li); + }); + + list.addEventListener('click', (e) => { + const link = e.target.closest('a.model-link'); + if (!link) return; + e.preventDefault(); + e.stopPropagation(); + const ratio = link.getAttribute('data-ratio') || ''; + nameContainer.textContent = ratio; + this.setSelectedAspectRatio(modelId, ratio); + syncDropdownSelection(list, link); + closeDropdown(container, triggerBtn, list); + }); + + triggerBtn.addEventListener('click', () => triggerBtn.dispatchEvent(new CustomEvent('pbu-ratio-dropdown-open', { bubbles: true }))); + attachDropdownBehavior(container, triggerBtn, list); + return container; + } + + updateAspectRatioOptions(modelId) { + const ac = this.actionContainerEl ?? this.widgetWrap?.querySelector('.action-container'); + ac?.querySelector('.pbu-aspect-models')?.remove(); + const picker = this.buildAspectRatioDropdown(modelId); + if (!picker || !ac) return; + const modelPicker = ac.querySelector('.models-container:not(.pbu-aspect-models)'); + if (modelPicker) modelPicker.after(picker); + else ac.append(picker); + } + + buildLeftSection() { + const leftSectionLabel = placeholderText(this.el, 'icon-dropzone-label'); + const uploadLabel = createTag('div', { class: 'unity-slf-copy-label pbu-upload-heading' }, leftSectionLabel); + const { wrap: dropZoneWrap, ...dropZoneRefs } = this.buildDropZone(); + const leftSection = createTag('div', { class: 'pbu-left-section' }); + leftSection.append(uploadLabel, dropZoneWrap); + return { leftSection, dropZoneRefs }; + } + + buildRightSection() { + const promptHeading = placeholderText(this.el, 'icon-placeholder-prompt') + || labelForField(this.el, 'icon-label-prompt', 'Prompt'); + const promptLabel = createTag('label', { + for: 'pbuPromptInput', + class: 'unity-slf-copy-label unity-slf-prompt-label', + }, promptHeading); + + const promptTextarea = this.buildPromptTextarea(); + + const actionContainer = createTag('div', { class: 'action-container' }); + this.actionContainerEl = actionContainer; + + if (this.models?.length) { + const mp = this.buildModelPicker(); + if (mp) actionContainer.append(mp); + } + if (this.showAspectRatio && this.selectedModelId) { + const ar = this.buildAspectRatioDropdown(this.selectedModelId); + if (ar) actionContainer.append(ar); + } + if (this.showMore) { + const moreBtn = this.buildMoreButton(); + if (moreBtn) actionContainer.append(moreBtn); + } + + const actWrap = createTag('div', { class: 'act-wrap' }); + actWrap.append(this.buildGenerateButton()); + + const controlsFooter = createTag('div', { class: 'pbu-controls-footer' }); + controlsFooter.append(actionContainer, actWrap); + + const promptBarContainer = createTag('div', { class: 'pbu-prompt-bar-container' }); + promptBarContainer.append(promptLabel, promptTextarea, controlsFooter); + + const rightSection = createTag('div', { class: 'pbu-right-section' }); + rightSection.append(promptBarContainer); + return rightSection; + } + + buildDropZone() { + const allowedFileTypes = this.workflowCfg?.targetCfg?.limits?.allowedFileTypes; + const fileInput = createTag('input', { + type: 'file', + id: 'file-upload', + accept: allowedFileTypes.join(','), + hidden: '', + 'aria-hidden': 'true', + }); + + const dropContent = createTag('div', { class: 'pbu-drop-content' }); + dropContent.append(createTag('img', { loading: 'lazy', src: `${getUnityLibs()}/img/icons/upload.svg` })); + const dropZone = createTag('div', { + class: 'drop-zone', + role: 'button', + tabindex: '0', + 'aria-label': 'Upload image', + }); + dropZone.append(fileInput, dropContent); + dropZone.addEventListener('keydown', (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + fileInput.click(); + }); + const selectSpinner = createTag('div', { class: 'pbu-select-spinner hidden', 'aria-hidden': 'true', role: 'status' }); + selectSpinner.append(createTag('div', { class: 'pbu-select-spinner-ring' })); + + const previewImg = createTag('img', { class: 'pbu-preview-img', alt: 'Selected image preview' }); + const deleteBtn = createTag('button', { type: 'button', class: 'pbu-delete-btn', 'aria-label': 'Remove image' }); + deleteBtn.innerHTML = svgIcon('#unity-trash-icon'); + const uploadSpinner = createTag('div', { class: 'pbu-spinner hidden', 'aria-label': 'Uploading', role: 'status' }); + const preview = createTag('div', { class: 'pbu-preview hidden', 'aria-hidden': 'true' }); + preview.append(previewImg, deleteBtn, uploadSpinner); + + const wrap = createTag('div', { class: 'pbu-drop-zone-wrap' }); + wrap.append(dropZone, selectSpinner, preview); + return { wrap, dropZone, preview, previewImg, deleteBtn,}; + } + + buildPromptTextarea() { + const defaultPrompt = placeholderText(this.el, 'icon-default-prompt') || ''; + const maxCharLimit = this.workflowCfg?.targetCfg?.limits?.['max-char-limit'] ?? 750; + const textarea = createTag('textarea', { + id: 'pbuPromptInput', + class: 'inp-field', + rows: '1', + maxlength: String(maxCharLimit), + 'aria-label': defaultPrompt, + 'aria-autocomplete': 'list', + }); + textarea.value = defaultPrompt; + textarea.addEventListener('input', () => textarea.dispatchEvent(new CustomEvent('pbu-enter-prompt', { bubbles: true })), { once: true }); + return textarea; + } + + buildGenerateButton() { + const generateLi = this.el.querySelector('[class*="icon-generate"]')?.closest('li'); + const genBtnText = (generateLi?.innerText).trim().split('\n')[0] || 'Generate'; + const img = generateLi?.querySelector('img[src*=".svg"]'); + const btn = createTag('a', { href: '#', class: 'unity-act-btn gen-btn', 'daa-ll': 'Generate-video', 'aria-label': genBtnText }); + if (img) { + img.setAttribute('alt', 'Generate video'); + btn.append(createTag('div', { class: 'btn-ico' }, img)); + } + if (genBtnText) btn.append(createTag('div', { class: 'btn-txt' }, genBtnText.split('\n')[0])); + return btn; + } + + buildMoreButton() { + if (!this.showMore) return null; + const moreLi = this.el.querySelector('[class*="icon-more"]')?.closest('li'); + const txt = (moreLi?.innerText || 'More').trim().split('\n')[0] || 'More'; + const btn = createTag('a', { href: '#', class: 'unity-act-btn pbu-more-btn more-btn', 'aria-label': txt }); + btn.append( + createTag('span', { class: 'btn-ico' }, svgIcon('#unity-more-icon')), + createTag('div', { class: 'btn-txt' }, txt), + ); + btn.addEventListener('click', () => btn.dispatchEvent(new CustomEvent('pbu-more-click', { bubbles: true }))); + return btn; + } + + addWidget() { + const interactArea = this.target?.querySelector('.copy'); + const { target: anchorSelector, insert } = this.workflowCfg.targetCfg || {}; + const para = anchorSelector ? interactArea?.querySelector(anchorSelector) : null; + if (para && insert === 'before') para.before(this.widgetWrap); + else if (para) para.after(this.widgetWrap); + else interactArea?.appendChild(this.widgetWrap); + } + + + wireImagePreview({ dropZone, preview, previewImg, deleteBtn }) { + const showPreview = (file) => { + const url = URL.createObjectURL(file); + previewImg.src = url; + previewImg.onload = () => URL.revokeObjectURL(url); + dropZone.classList.add('hidden'); + dropZone.setAttribute('aria-hidden', 'true'); + preview.classList.remove('hidden'); + preview.removeAttribute('aria-hidden'); + }; + + const showDropZone = () => { + dropZone.classList.remove('hidden'); + dropZone.removeAttribute('aria-hidden'); + preview.classList.add('hidden'); + preview.setAttribute('aria-hidden', 'true'); + previewImg.src = ''; + }; + + this.widgetWrap?.addEventListener('pbu-image-selected', (e) => showPreview(e.detail.file)); + deleteBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + showDropZone(); + this.widgetWrap?.dispatchEvent(new CustomEvent('pbu-delete-image')); + }); + } + + async initWidget() { + this.readFeatureFlags(); + + try { + await this.loadModels(); + } catch (e) { + window.lana?.log(`Message: Failed to load video models, Error: ${e}`, this.lanaOptions); + } + + const { leftSection, dropZoneRefs } = this.buildLeftSection(); + const rightSection = this.buildRightSection(); + const main = createTag('div', { class: 'pbu-main' }); + main.append(leftSection, rightSection); + const skin = this.el.classList.contains('light') ? 'light' : 'dark'; + const interactiveShell = createTag('div', { class: `interactive-area ${skin}` }); + interactiveShell.append(main); + const root = createTag('div', { class: 'unity-prompt-bar-upload unity-enabled' }); + root.append(interactiveShell); + const holder = createTag('div', { class: 'unity-pbu-config-holder unity-slf-sr-only' }); + holder.setAttribute('aria-hidden', 'true'); + while (this.el.firstChild) holder.append(this.el.firstChild); + this.el.append(holder); + this.el.classList.add('unity-prompt-bar-upload-host'); + const unitySprite = createTag('div', { class: 'unity-sprite-container' }); + unitySprite.innerHTML = this.spriteCon || ''; + const legalFoot = extractLegalFootFromAuthoring(this.el); + this.widgetWrap = createTag('div', { class: 'ex-unity-wrap verb-options pbu-widget' }); + this.widgetWrap.append(unitySprite, root); + if (legalFoot) this.widgetWrap.append(legalFoot); + this.syncDefaultAttributes(); + + this.addWidget(); + this.wireImagePreview(dropZoneRefs); + return this.workflowCfg.targetCfg.actionMap; + } +} diff --git a/unitylibs/core/workflow/workflow-prompt-bar-upload/action-binder.js b/unitylibs/core/workflow/workflow-prompt-bar-upload/action-binder.js new file mode 100644 index 000000000..3b43cbced --- /dev/null +++ b/unitylibs/core/workflow/workflow-prompt-bar-upload/action-binder.js @@ -0,0 +1,733 @@ +/* eslint-disable max-len */ +/* eslint-disable max-classes-per-file */ +/* eslint-disable no-await-in-loop */ +/* eslint-disable class-methods-use-this */ +/* eslint-disable no-restricted-syntax */ + +import { + unityConfig, + getUnityLibs, + priorityLoad, + createTag, + getLocale, + getLibs, + getHeaders, + getApiCallOptions, + sendAnalyticsEvent, +} from '../../../scripts/utils.js'; + +function normalizeToArray(value) { + if (value == null) return []; + if (Array.isArray(value)) return value.filter(Boolean); + if (typeof value.forEach === 'function' && typeof value.length === 'number') { + try { return [...value]; } catch { return [value]; } + } + return [value]; +} + +class ServiceHandler { + constructor(renderWidget = false, canvasArea = null, unityEl = null, workflowCfg = {}, getAdditionalHeaders = null) { + this.renderWidget = renderWidget; + this.canvasArea = canvasArea; + this.unityEl = unityEl; + this.workflowCfg = workflowCfg; + this.getAdditionalHeaders = getAdditionalHeaders; + } + + async postCallToService(api, options, failOnError = true) { + const postOpts = { + method: 'POST', + headers: await getHeaders(unityConfig.apiKey, this.getAdditionalHeaders?.() || {}), + ...options, + }; + let response; + try { + response = await fetch(api, postOpts); + } catch (e) { + if (e instanceof TypeError) { + const error = new Error(`Network error. URL: ${api}; Error message: ${e.message}`); + error.status = 0; + throw error; + } + throw e; + } + if (failOnError && response.status !== 200) { + const error = new Error('Operation failed'); + error.status = response.status; + throw error; + } + if (!failOnError) return response; + return response.json(); + } + + showErrorToast(errorCallbackOptions, error, lanaOptions, errorType = 'server') { + sendAnalyticsEvent(new CustomEvent(`Upload ${errorType} error|UnityWidget|${errorCallbackOptions.errorCode || ''}|${JSON.stringify(errorCallbackOptions.fileMetaData) || ''}`)); + if (!errorCallbackOptions.errorToastEl) return; + const msg = this.unityEl.querySelector(errorCallbackOptions.errorType)?.closest('li')?.textContent?.trim(); + this.canvasArea.forEach((element) => { + element.style.pointerEvents = 'none'; + const errorToast = element.querySelector('.alert-holder'); + if (!errorToast) return; + const closeBtn = errorToast.querySelector('.alert-close'); + if (closeBtn) closeBtn.style.pointerEvents = 'auto'; + const alertText = errorToast.querySelector('.alert-text p'); + if (!alertText) return; + alertText.innerText = msg; + errorToast.classList.add('show'); + }); + window.lana?.log(`Message: ${msg}, Error: ${error || ''}`, lanaOptions); + } +} + +export default class ActionBinder { + constructor(unityEl, workflowCfg, block, canvasArea, actionMap = {}) { + this.unityEl = unityEl; + this.workflowCfg = workflowCfg; + this.block = block; + this.canvasArea = canvasArea; + this.actionMap = actionMap; + this.errorToastEl = null; + this.transitionScreen = null; + this.LOADER_LIMIT = 95; + this.serviceHandler = null; + this.uploadAbortController = null; + this.assetId = null; + this.pendingFile = null; + this.filesData = {}; + this.sendAnalyticsToSplunk = null; + this.analyticsModule = null; + this.promiseStack = []; + this.desktop = false; + this.toastCanvasAreas = normalizeToArray(canvasArea); + this.apiConfig = this.getApiConfig(); + this.verb = this.getVerbFromDom(); + this.initActionListeners = this.initActionListeners.bind(this); + const searchRoot = canvasArea || block; + this.widgetWrap = searchRoot?.querySelector?.('.ex-unity-wrap') ?? searchRoot; + this.inputField = searchRoot?.querySelector?.('.inp-field'); + this.limits = workflowCfg.targetCfg?.limits || {}; + const productTag = workflowCfg.targetCfg?.[`productTag-${workflowCfg.productName?.toLowerCase()}`] || 'FF'; + this.lanaOptions = { sampleRate: 1, tags: `Unity-${productTag}-PBU` }; + + } + + getApiConfig() { + unityConfig.endPoint = { + assetUpload: `${unityConfig.apiEndPoint}/asset`, + acmpCheck: `${unityConfig.apiEndPoint}/asset/finalize`, + }; + return unityConfig; + } + + getAdditionalHeaders() { + const baseAction = this.workflowCfg?.supportedFeatures?.values()?.next()?.value; + const xUnityAction = this.verb ? `${baseAction}-${this.verb}` : baseAction; + return { + 'x-unity-product': this.workflowCfg?.productName, + 'x-unity-action': xUnityAction, + }; + } + + getVerbFromDom() { + const verbEl = this.unityEl?.querySelector('[class*="icon-operation-"]'); + if (verbEl) { + const verbClass = Array.from(verbEl.classList).find((cls) => cls.startsWith('icon-operation-')); + const fromDom = verbClass?.slice('icon-operation-'.length); + if (fromDom) return fromDom; + } + return this.workflowCfg?.enabledFeatures?.[0]; + } + + async initAnalytics() { + if (this.analyticsModule) return; + this.analyticsModule = await import(`${getUnityLibs()}/scripts/analytics.js`); + if (this.workflowCfg.targetCfg?.sendSplunkAnalytics) { + this.sendAnalyticsToSplunk = this.analyticsModule.default; + } + } + + logAnalytics(eventName, data) { + this.sendAnalyticsToSplunk?.( + eventName, + this.workflowCfg.productName, + { ...data, operation: this.verb }, + `${unityConfig.apiEndPoint}/log`, + true, + ); + } + + resetUploadedAssetState({ dropPendingImage = false } = {}) { + this.uploadAbortController?.abort(); + this.uploadAbortController = null; + this.assetId = null; + if (dropPendingImage) { + this.pendingFile = null; + this.filesData = {}; + } + } + + async createErrorToast() { + try { + 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 { decorateDefaultLinkAnalytics } = await import(`${getLibs()}/martech/attributes.js`); + this.toastCanvasAreas.forEach((canvasEl) => { + const mount = canvasEl.querySelector('.pbu-main') || canvasEl; + const alertText = createTag('div', { class: 'alert-text' }, createTag('p', {}, 'Alert Text')); + const alertIcon = createTag('div', { class: 'alert-icon' }); + alertIcon.innerHTML = alertImg; + alertIcon.append(alertText); + const alertClose = createTag('a', { class: 'alert-close', href: '#' }); + alertClose.innerHTML = closeImg; + alertClose.append(createTag('span', { class: 'alert-close-text' }, 'Close error toast')); + const alertContent = createTag('div', { class: 'alert-content' }); + alertContent.append(alertIcon, alertClose); + const alertToast = createTag('div', { class: 'alert-toast' }, alertContent); + const errholder = createTag('div', { class: 'alert-holder' }, alertToast); + alertClose.addEventListener('click', (e) => { + this.preventDefault(e); + errholder.classList.remove('show'); + canvasEl.style.pointerEvents = 'auto'; + }); + decorateDefaultLinkAnalytics(errholder); + mount.append(errholder); + }); + return this.toastCanvasAreas[0]?.querySelector('.pbu-main .alert-holder') + || this.toastCanvasAreas[0]?.querySelector('.alert-holder'); + } catch (e) { + window.lana?.log(`Message: Error creating error toast, Error: ${e}`, this.lanaOptions); + return null; + } + } + + extractFiles(e) { + const files = []; + if (e.dataTransfer?.items) { + [...e.dataTransfer.items].forEach((item) => { if (item.kind === 'file') files.push(item.getAsFile()); }); + } else if (e.target?.files) { + [...e.target.files].forEach((file) => files.push(file)); + } + return files; + } + + handleClientError(errorTypeSelector, errorCode, message = '') { + this.serviceHandler.showErrorToast( + { + errorToastEl: this.errorToastEl, + errorType: errorTypeSelector, + errorCode, + fileMetaData: this.filesData, + }, + message, + this.lanaOptions, + 'client', + ); + this.logAnalytics('Upload client error|UnityWidget', { errorData: { code: errorCode }, fileMetaData: this.filesData }); + } + + setSelectSpinnerVisible(visible) { + const wrap = this.widgetWrap?.querySelector('.pbu-drop-zone-wrap'); + const el = wrap?.querySelector('.pbu-select-spinner'); + if (!el) return; + el.classList.toggle('hidden', !visible); + el.setAttribute('aria-hidden', visible ? 'false' : 'true'); + wrap?.classList.toggle('pbu-select-processing', !!visible); + } + + async validateAndStoreFile(files) { + this.setSelectSpinnerVisible(true); + try { + if (!files?.length) return false; + if (files.length > (this.limits.maxNumFiles || 1)) { + this.handleClientError('.icon-error-filecount', 'error-filecount'); + return false; + } + const file = files[0]; + this.filesData = { count: files.length, size: file.size, type: file.type }; + if (this.limits.allowedFileTypes && !this.limits.allowedFileTypes.includes(file.type)) { + this.handleClientError('.icon-error-filetype', 'error-filetype'); + return false; + } + if (this.limits.maxFileSize && file.size > this.limits.maxFileSize) { + this.handleClientError('.icon-error-filesize', 'error-filesize'); + return false; + } + this.resetUploadedAssetState(); + this.pendingFile = file; + this.widgetWrap?.dispatchEvent(new CustomEvent('pbu-image-selected', { detail: { file } })); + return true; + } finally { + this.setSelectSpinnerVisible(false); + } + } + + async uploadImgToUnity(storageUrl, _id, blobData, fileType, signal) { + const uploadOptions = { + method: 'PUT', + headers: { 'Content-Type': fileType }, + body: blobData, + ...(signal && { signal }), + }; + let response; + try { + response = await fetch(storageUrl, uploadOptions); + } catch (e) { + if (e instanceof TypeError) { + const error = new Error(`Network error. URL: ${storageUrl}; Error message: ${e.message}`); + error.status = 0; + throw error; + } + throw e; + } + if (response.status !== 200) { + const error = new Error('Failed to upload image to Unity'); + error.status = response.status; + throw error; + } + } + + async uploadAsset(file) { + const assetDetails = { + targetProduct: this.workflowCfg.productName, + name: file.name, + size: file.size, + format: file.type, + }; + this.uploadAbortController = new AbortController(); + const { signal } = this.uploadAbortController; + try { + const resJson = await this.serviceHandler.postCallToService( + this.apiConfig.endPoint.assetUpload, + { body: JSON.stringify(assetDetails) }, + ); + const { id, href, blocksize, uploadUrls } = resJson; + this.assetId = id; + this.logAnalytics('Asset Created|UnityWidget', { assetId: this.assetId }); + const { default: UploadHandler } = await import(`${getUnityLibs()}/core/workflow/workflow-upload/upload-handler.js`); + const uploadHandler = new UploadHandler(this, this.serviceHandler); + if (blocksize && uploadUrls && Array.isArray(uploadUrls)) { + const { failedChunks, attemptMap } = await uploadHandler.uploadChunksToUnity(uploadUrls, file, blocksize, signal); + if (failedChunks?.size > 0) { + if (signal.aborted) return false; + const error = new Error(`One or more chunks failed for asset: ${id}`); + error.status = 504; + this.logAnalytics('Chunked Upload Failed|UnityWidget', { + assetId: this.assetId, + failedChunks: failedChunks.size, + maxRetryCount: Math.max(...Array.from(attemptMap.values())), + }); + throw error; + } + await uploadHandler.scanImgForSafetyWithRetry(this.assetId, signal); + const { createChunkAnalyticsData } = await import(`${getUnityLibs()}/utils/chunkingUtils.js`); + const totalChunks = Math.ceil(file.size / blocksize); + this.logAnalytics( + 'Chunked Upload Completed|UnityWidget', + createChunkAnalyticsData('Chunked Upload Completed|UnityWidget', { + assetId: this.assetId, + chunkCount: totalChunks, + totalFileSize: file.size, + fileType: file.type, + }), + ); + } else { + await this.uploadImgToUnity(href, id, file, file.type, signal); + await uploadHandler.scanImgForSafetyWithRetry(this.assetId, signal); + this.logAnalytics('Upload Completed|UnityWidget', { assetId: this.assetId }); + } + return true; + } catch (e) { + if (signal.aborted || e.name === 'AbortError') { + window.lana?.log(`Message: Upload aborted, Error: ${e.message}`, this.lanaOptions); + return false; + } + this.serviceHandler.showErrorToast({ errorToastEl: this.errorToastEl, errorType: '.icon-error-request' }, e, this.lanaOptions); + this.logAnalytics('Upload server error|UnityWidget', { + errorData: { code: 'error-request', subCode: `uploadAsset ${e.status}`, desc: e.message }, + assetId: this.assetId, + }); + return false; + } + } + + validateInput(query) { + const maxCharLimit = this.limits?.['max-char-limit'] ?? 750; + if (query.length > maxCharLimit) { + this.handleClientError('.icon-error-max-length', 'max-prompt-characters-exceeded', 'Prompt too long'); + this.logAnalytics('generate', { errorData: { code: 'max-prompt-characters-exceeded' } }); + return false; + } + return true; + } + + async ensureTransitionScreen() { + if (!this.transitionScreen) { + const { default: TransitionScreen } = await import(`${getUnityLibs()}/scripts/transition-screen.js`); + this.transitionScreen = new TransitionScreen(null, this.initActionListeners, this.LOADER_LIMIT, this.workflowCfg, this.desktop); + } + if (!this.transitionScreen.splashScreenEl) { + await this.transitionScreen.loadSplashFragment(); + } + } + + async handleGenerate(connectorGenerate = true) { + this.promiseStack = []; + if (!this.analyticsModule) await this.initAnalytics(); + const pbuEvents = this.analyticsModule.PROMPT_BAR_EVENTS; + const query = this.inputField?.value?.trim() || ''; + if (!this.validateInput(query)) return; + + const selectedModelId = this.widgetWrap?.getAttribute('data-selected-model-id') || ''; + const selectedAspectRatio = this.widgetWrap?.getAttribute('data-selected-aspect-ratio') || ''; + const selectedModelName = this.widgetWrap?.getAttribute('data-selected-model-name') || selectedModelId; + const ctaEventName = connectorGenerate ? pbuEvents.GENERATE_CTA : pbuEvents.MORE; + sendAnalyticsEvent(new CustomEvent(pbuEvents.UPLOAD_STARTED)); + sendAnalyticsEvent(new CustomEvent(ctaEventName)); + if (selectedModelName) sendAnalyticsEvent(new CustomEvent(pbuEvents.generateModel(selectedModelName))); + if (selectedAspectRatio) sendAnalyticsEvent(new CustomEvent(pbuEvents.ratioSelect(selectedAspectRatio))); + this.logAnalytics(pbuEvents.UPLOAD_STARTED, { fileMetaData: this.filesData }); + this.logAnalytics(ctaEventName, { + ...(selectedModelName && { + modelGenEventName: pbuEvents.generateModel(selectedModelName), + }), + assetId: this.assetId, + aspectRatio: selectedAspectRatio, + hasImage: !!this.pendingFile, + }); + const searchRoot = this.canvasArea || this.block; + const interactiveShell = searchRoot?.querySelector?.('.interactive-area'); + this.workflowCfg.theme = interactiveShell?.classList.contains('dark') ? 'dark' : null; + + await this.ensureTransitionScreen(); + await this.transitionScreen.showSplashScreen(true); + + if (this.pendingFile && !this.assetId) { + const uploadOk = await this.uploadAsset(this.pendingFile); + if (!uploadOk) { + await this.transitionScreen.showSplashScreen(); + return; + } + } + await this.continueInApp(query, selectedModelId, selectedAspectRatio, connectorGenerate); + } + + + async continueInApp(query, modelId, aspectRatio, connectorGenerate = true) { + const { getCgenQueryParams } = await import(`${getUnityLibs()}/utils/cgen-utils.js`); + const queryParams = getCgenQueryParams(this.unityEl); + const modelVersion = this.widgetWrap?.getAttribute('data-selected-model-version') || ''; + const selectedWidth = Number(this.widgetWrap?.getAttribute('data-selected-width')) || null; + const selectedHeight = Number(this.widgetWrap?.getAttribute('data-selected-height')) || null; + const size = (selectedWidth && selectedHeight) ? { width: selectedWidth, height: selectedHeight } : null; + + const connectorBody = { + targetProduct: this.workflowCfg.productName, + ...(this.assetId && { assetId: this.assetId }), + ...(query && { query }), + payload: { + workflow: this.workflowCfg.supportedFeatures.values().next().value, + verb: this.verb, + action: 'asset-upload', + locale: getLocale(), + additionalQueryParams: queryParams, + size, + ...(modelId && { modelId }), + ...(modelVersion && { modelVersion }), + ...(aspectRatio && { aspectRatio }), + generate: connectorGenerate, + }, + }; + try { + const headerExtras = this.getAdditionalHeaders(); + const postOpts = await getApiCallOptions( + 'POST', + unityConfig.apiKey, + headerExtras, + { body: JSON.stringify(connectorBody) }, + ); + const { default: NetworkUtils } = await import(`${getUnityLibs()}/utils/NetworkUtils.js`); + const networkUtils = new NetworkUtils(); + const { url } = await networkUtils.fetchFromService( + this.apiConfig.connectorApiEndPoint, + postOpts, + async (response) => { + if (response.status !== 200) { + const error = new Error('Connector call failed'); + error.status = response.status; + throw error; + } + return response.json(); + }, + ); + this.logAnalytics('Generate Complete|UnityWidget', { assetId: this.assetId }); + this.LOADER_LIMIT = 100; + if (this.transitionScreen?.splashScreenEl) { + this.transitionScreen.LOADER_LIMIT = 100; + this.transitionScreen.updateProgressBar(this.transitionScreen.splashScreenEl, 100); + } + if (url) window.location.href = url; + } catch (err) { + if (err.message === 'Operation termination requested.') return; + await this.transitionScreen?.showSplashScreen(); + this.serviceHandler.showErrorToast({ errorToastEl: this.errorToastEl, errorType: '.icon-error-request' }, err, this.lanaOptions); + this.logAnalytics('Generate Error|UnityWidget', { + errorData: { code: 'request-failed', subCode: err.status, desc: err.message }, + assetId: this.assetId, + }); + window.lana?.log(`Message: Connector call failed, Error: ${err}`, this.lanaOptions); + } + } + + async handlePreloads() { + const parr = []; + if (this.workflowCfg.targetCfg?.showSplashScreen) { + parr.push(`${getUnityLibs()}/core/styles/splash-screen.css`); + } + if (parr.length) await priorityLoad(parr); + } + + isStringActionMap(actMap) { + return actMap && typeof actMap === 'object' && Object.keys(actMap).length > 0 + && Object.values(actMap).every((v) => typeof v === 'string'); + } + + async cancelUploadOperation() { + try { + this.uploadAbortController?.abort(); + this.uploadAbortController = null; + sendAnalyticsEvent(new CustomEvent('Cancel|UnityWidget')); + this.logAnalytics('Cancel|UnityWidget', { assetId: this.assetId }); + await this.ensureTransitionScreen(); + await this.transitionScreen.showSplashScreen(); + const e = new Error('Operation termination requested.'); + const cancelPromise = Promise.reject(e); + cancelPromise.catch(() => {}); + this.promiseStack.unshift(cancelPromise); + } catch (error) { + await this.transitionScreen?.showSplashScreen(); + window.lana?.log(`Message: Error cancelling upload operation, Error: ${error}`, this.lanaOptions); + throw error; + } + } + + async executeActionMaps(value) { + await this.handlePreloads(); + if (!this.errorToastEl) this.errorToastEl = await this.createErrorToast(); + if (value === 'interrupt') await this.cancelUploadOperation(); + } + + async bindStringActionMap(b, actMap) { + const actions = { + A: (el, key) => { + el.addEventListener('click', async (e) => { + const action = actMap[key]; + if (action !== 'redirect') e.preventDefault(); + await this.executeActionMaps(action); + }); + }, + DIV: (el, key) => { + el.addEventListener('drop', async (e) => { + sendAnalyticsEvent(new CustomEvent('Drag and drop|UnityWidget')); + this.preventDefault(e); + const extracted = this.extractFiles(e); + this.filesData = { count: extracted.length, size: extracted[0]?.size, type: extracted[0]?.type }; + this.logAnalytics('Drag and drop|UnityWidget', { assetId: this.assetId, fileMetaData: this.filesData }); + await this.executeActionMaps(actMap[key], extracted); + }); + el.addEventListener('click', () => { + sendAnalyticsEvent(new CustomEvent('Click Drag and drop|UnityWidget')); + }); + }, + INPUT: (el, key) => { + el.addEventListener('click', () => { + this.toastCanvasAreas.forEach((element) => { + const errHolder = element.querySelector('.alert-holder'); + if (errHolder?.classList.contains('show')) { + element.style.pointerEvents = 'auto'; + errHolder.classList.remove('show'); + } + }); + }); + el.addEventListener('change', async (e) => { + const extracted = this.extractFiles(e); + this.filesData = { count: extracted.length, size: extracted[0]?.size, type: extracted[0]?.type }; + this.logAnalytics('Click Drag and drop|UnityWidget', { assetId: this.assetId, fileMetaData: this.filesData }); + await this.executeActionMaps(actMap[key], extracted); + e.target.value = ''; + }); + }, + }; + for (const [key] of Object.entries(actMap)) { + const elements = b.querySelectorAll(key); + if (elements?.length) { + elements.forEach((el) => { + const actionType = el.nodeName; + if (actions[actionType]) actions[actionType](el, key); + }); + } + } + } + + setupServiceHandler() { + this.serviceHandler = new ServiceHandler( + this.workflowCfg.targetCfg?.renderWidget, + this.toastCanvasAreas, + this.unityEl, + this.workflowCfg, + this.getAdditionalHeaders.bind(this), + ); + } + + async initActionListeners(b = this.block, actMap = this.actionMap) { + const searchRoot = this.canvasArea || this.block; + this.widgetWrap = searchRoot?.querySelector?.('.ex-unity-wrap') || this.widgetWrap; + + this.setupServiceHandler(); + await this.initAnalytics(); + + if (this.isStringActionMap(actMap)) { + await this.bindStringActionMap(b, actMap); + return; + } + if (!this.errorToastEl) this.errorToastEl = await this.createErrorToast(); + await this.handlePreloads(); + this.inputField = this.widgetWrap?.querySelector('#pbuPromptInput') + || this.widgetWrap?.querySelector('.inp-field') + || this.inputField; + for (const [selector, actionsList] of Object.entries(actMap)) { + const elements = (this.widgetWrap || searchRoot)?.querySelectorAll(selector); + if (!elements?.length) continue; + elements.forEach((el) => { + if (el.dataset.pbuBound) return; + el.dataset.pbuBound = 'true'; + this.bindElement(el, actionsList); + }); + } + this.inputField?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + (this.widgetWrap || searchRoot)?.querySelector('.gen-btn')?.click(); + } + }); + + const pbuEvents = this.analyticsModule.PROMPT_BAR_EVENTS; + this.bindWidgetInteractionEvent('pbu-enter-prompt', pbuEvents.ENTER_PROMPT, 'enter-prompt'); + this.bindWidgetInteractionEvent('pbu-model-dropdown-open', pbuEvents.MODEL_SELECT_DROPDOWN, 'open'); + this.bindWidgetInteractionEvent('pbu-ratio-dropdown-open', pbuEvents.RATIO_DROPDOWN, 'open'); + this.widgetWrap?.addEventListener('pbu-delete-image', () => this.resetUploadedAssetState({ dropPendingImage: true })); + this.bindOuterMarqueeDropTarget(); + } + + bindOuterMarqueeDropTarget() { + const outerMarquee = this.block?.querySelector('.upload-marquee-layout') || this.block; + const dropZone = this.widgetWrap?.querySelector('.drop-zone'); + if (!outerMarquee || outerMarquee.dataset.pbuOuterDropBound === 'true') return; + outerMarquee.dataset.pbuOuterDropBound = 'true'; + + let dragDepth = 0; + const hasFilePayload = (e) => !!e?.dataTransfer?.types + && Array.from(e.dataTransfer.types).includes('Files'); + const setDropzoneHighlight = (isOn) => dropZone?.classList.toggle('drag-over', !!isOn); + + outerMarquee.addEventListener('dragenter', (e) => { + if (!hasFilePayload(e)) return; + e.preventDefault(); + dragDepth += 1; + setDropzoneHighlight(true); + }); + + outerMarquee.addEventListener('dragover', (e) => { + if (!hasFilePayload(e)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + setDropzoneHighlight(true); + }); + + outerMarquee.addEventListener('dragleave', (e) => { + if (!hasFilePayload(e)) return; + e.preventDefault(); + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) setDropzoneHighlight(false); + }); + + outerMarquee.addEventListener('drop', async (e) => { + if (!hasFilePayload(e)) return; + e.preventDefault(); + dragDepth = 0; + setDropzoneHighlight(false); + sendAnalyticsEvent(new CustomEvent('Drag and drop|UnityWidget')); + const files = this.extractFiles(e); + await this.executeAction('file-selected', outerMarquee, files); + }); + } + + bindElement(el, actionsList) { + const actions = Array.isArray(actionsList) ? actionsList : [actionsList]; + const primaryAction = actions[0]?.actionType; + + switch (el.nodeName) { + case 'A': + case 'BUTTON': + el.addEventListener('click', async (e) => { + e.preventDefault(); + await this.executeAction(primaryAction, el); + }); + break; + case 'DIV': + el.addEventListener('dragover', (e) => { e.preventDefault(); el.classList.add('drag-over'); }); + el.addEventListener('dragleave', () => el.classList.remove('drag-over')); + el.addEventListener('drop', async (e) => { + e.preventDefault(); + el.classList.remove('drag-over'); + sendAnalyticsEvent(new CustomEvent('Drag and drop|UnityWidget')); + const files = this.extractFiles(e); + await this.executeAction(primaryAction, el, files); + }); + el.addEventListener('click', () => { this.block?.querySelector('#file-upload')?.click(); }); + break; + case 'INPUT': + el.addEventListener('change', async (e) => { + const files = this.extractFiles(e); + await this.executeAction(primaryAction, el, files); + e.target.value = ''; + }); + break; + default: + break; + } + } + + async executeAction(actionType, el, files) { + try { + switch (actionType) { + case 'generate': + await this.handleGenerate(true); + break; + case 'more': + await this.handleGenerate(false); + break; + case 'file-selected': + await this.validateAndStoreFile(files); + break; + default: + break; + } + } catch (err) { + window.lana?.log(`Message: Action "${actionType}" failed, Error: ${err}`, this.lanaOptions); + } + } + + bindWidgetInteractionEvent(domEventName, analyticsEventName, action) { + this.widgetWrap?.addEventListener(domEventName, () => { + sendAnalyticsEvent(new CustomEvent(analyticsEventName)); + this.logAnalytics(analyticsEventName, { action }); + }); + } + + preventDefault(e) { + e.preventDefault(); + e.stopPropagation(); + } +} diff --git a/unitylibs/core/workflow/workflow-prompt-bar-upload/sprite.svg b/unitylibs/core/workflow/workflow-prompt-bar-upload/sprite.svg new file mode 100644 index 000000000..b196e215e --- /dev/null +++ b/unitylibs/core/workflow/workflow-prompt-bar-upload/sprite.svg @@ -0,0 +1,46 @@ + diff --git a/unitylibs/core/workflow/workflow-prompt-bar-upload/target-config.json b/unitylibs/core/workflow/workflow-prompt-bar-upload/target-config.json new file mode 100644 index 000000000..cc5190022 --- /dev/null +++ b/unitylibs/core/workflow/workflow-prompt-bar-upload/target-config.json @@ -0,0 +1,31 @@ +{ + "_defaults": { + "renderWidget": true, + "showSplashScreen": true, + "splashScreenConfig": { + "fragmentLink-firefly": "/cc-shared/fragments/products/firefly/unity/splash-page", + "fragmentLink-firefly-dark": "/cc-shared/fragments/products/firefly/unity/splash-page-dark", + "splashScreenParent": "body" + }, + "limits": { + "maxNumFiles": 1, + "allowedFileTypes": ["image/jpeg", "image/png", "image/jpg"], + "maxFileSize": 50000000, + "max-char-limit": 1024 + }, + "productTag-firefly": "FF", + "sendSplunkAnalytics": true, + "actionMap": { + ".gen-btn": [{ "actionType": "generate" }], + ".more-btn": [{ "actionType": "more" }], + ".drop-zone": [{ "actionType": "file-selected" }], + "#file-upload": [{ "actionType": "file-selected" }] + } + }, + "upload-marquee": { + "selector": ".copy", + "source": ".copy", + "target": ".upload-marquee-prompt-container", + "insert": "after" + } +} diff --git a/unitylibs/core/workflow/workflow-upload/action-binder.js b/unitylibs/core/workflow/workflow-upload/action-binder.js index d74726f91..ae9c1620f 100644 --- a/unitylibs/core/workflow/workflow-upload/action-binder.js +++ b/unitylibs/core/workflow/workflow-upload/action-binder.js @@ -84,9 +84,7 @@ export default class ActionBinder { this.splashScreenEl = null; this.transitionScreen = null; this.LOADER_LIMIT = 95; - const commonLimits = workflowCfg.targetCfg.limits || {}; - const productLimits = workflowCfg.targetCfg[`limits-${workflowCfg.productName.toLowerCase()}`] || {}; - this.limits = { ...commonLimits, ...productLimits }; + this.limits = ActionBinder.resolveLimits(workflowCfg); this.promiseStack = []; this.initActionListeners = this.initActionListeners.bind(this); const productTag = workflowCfg.targetCfg[`productTag-${workflowCfg.productName.toLowerCase()}`] || 'UNKNOWN'; @@ -99,6 +97,18 @@ export default class ActionBinder { this.uploadAbortController = null; } + static resolveLimits(workflowCfg) { + const targetCfg = workflowCfg.targetCfg || {}; + const commonLimits = targetCfg.limits || {}; + const productLimits = targetCfg[`limits-${workflowCfg.productName?.toLowerCase()}`] || {}; + const featureLimits = Array.from(workflowCfg.supportedFeatures || []).reduce((acc, feature) => ({ + ...acc, + ...(targetCfg[`limits-${feature}`] || {}), + }), {}); + const hasFeatureLimits = Object.keys(featureLimits).length > 0; + return { ...commonLimits, ...(hasFeatureLimits ? featureLimits : productLimits) }; + } + getApiConfig() { unityConfig.endPoint = { assetUpload: `${unityConfig.apiEndPoint}/asset`, @@ -403,6 +413,21 @@ export default class ActionBinder { return { width, height }; } + async checkVideoDuration(file) { + const { getVideoDuration } = await import(`${getUnityLibs()}/utils/FileUtils.js`); + const duration = await getVideoDuration(file); + this.filesData = { ...this.filesData, duration }; + if (this.limits.minDuration && duration < this.limits.minDuration) { + this.handleClientUploadError('.icon-error-videominduration', 'error-minVideoduration', 'Video is too short'); + throw new Error('Video is too short'); + } + if (this.limits.maxDuration && duration > this.limits.maxDuration) { + this.handleClientUploadError('.icon-error-videomaxduration', 'error-maxVideoduration', 'Video is too long'); + throw new Error('Video is too long'); + } + return duration; + } + async initAnalytics() { if (!this.sendAnalyticsToSplunk && this.workflowCfg.targetCfg.sendSplunkAnalytics) { this.sendAnalyticsToSplunk = (await import(`${getUnityLibs()}/scripts/analytics.js`)).default; @@ -420,14 +445,14 @@ export default class ActionBinder { this.logAnalyticsinSplunk('Upload client error|UnityWidget', { errorData: { code: errorCode }, fileMetaData: this.filesData, action: 'upload' }); } - async uploadImage(files) { + async uploadFile(files) { if (!files) return; const file = files[0]; if (this.limits.maxNumFiles !== files.length) { this.handleClientUploadError('.icon-error-filecount', 'error-filecount'); return; } - if (!this.limits.allowedFileTypes.includes(file.type)) { + if (!this.limits.allowedFileTypes?.includes(file.type)) { this.handleClientUploadError('.icon-error-filetype', 'error-filetype'); return; } @@ -435,8 +460,12 @@ export default class ActionBinder { this.handleClientUploadError('.icon-error-filesize', 'error-filesize'); return; } - try { await this.checkImageDimensions(file); } catch (error) { - window.lana?.log(`Message: Error checking image dimensions, Error: ${error}`, this.lanaOptions); + const isVideo = file.type.startsWith('video/'); + try { + if (isVideo) { await this.checkVideoDuration(file);} + else { await this.checkImageDimensions(file);} + } catch (error) { + window.lana?.log(`Message: Error checking file constraints, Error: ${error}`, this.lanaOptions); return; } sendAnalyticsEvent(new CustomEvent('Uploading Started|UnityWidget')); @@ -475,7 +504,7 @@ export default class ActionBinder { switch (value) { case 'upload': this.promiseStack = []; - await this.uploadImage(files); + await this.uploadFile(files); break; case 'interrupt': await this.cancelUploadOperation(); @@ -519,6 +548,9 @@ export default class ActionBinder { }); }, INPUT: (el, key) => { + if (this.limits.allowedFileTypes?.length) { + el.setAttribute('accept', this.limits.allowedFileTypes.join(',')); + } el.addEventListener('click', () => { this.canvasArea.forEach((element) => { const errHolder = element.querySelector('.alert-holder'); diff --git a/unitylibs/core/workflow/workflow-upload/target-config.json b/unitylibs/core/workflow/workflow-upload/target-config.json index 0648b0df9..9f6326f80 100644 --- a/unitylibs/core/workflow/workflow-upload/target-config.json +++ b/unitylibs/core/workflow/workflow-upload/target-config.json @@ -28,6 +28,12 @@ "minHeight": 512, "minWidth": 512 }, + "limits-upload-video": { + "allowedFileTypes": ["video/mp4", "video/quicktime"], + "maxFileSize": 200000000, + "minDuration": 5, + "maxDuration": 20 + }, "showSplashScreen": true, "splashScreenConfig": { "fragmentLink-photoshop": "/cc-shared/fragments/products/photoshop/unity/splash-page/splashscreen", diff --git a/unitylibs/core/workflow/workflow.js b/unitylibs/core/workflow/workflow.js index 1a726663d..8c6b9a24c 100644 --- a/unitylibs/core/workflow/workflow.js +++ b/unitylibs/core/workflow/workflow.js @@ -26,29 +26,20 @@ class WfInitiator { return cls ? cls.replace(/^widget-/, '') : 'prompt-bar'; } - static getWidgetRegistry() { - const widgetBase = `${getUnityLibs()}/core/widgets`; - return { - 'prompt-bar': [`${widgetBase}/prompt-bar/prompt-bar.js`, `${widgetBase}/prompt-bar/prompt-bar.css`], - 'prompt-bar-style': [ - `${widgetBase}/prompt-bar-style/prompt-bar-style.js`, - `${widgetBase}/prompt-bar-style/prompt-bar-style.css`, - ], - }; + static widgetPathsForName(name) { + const widgetBase = `${getUnityLibs()}/core/widgets/${name}`; + return [`${widgetBase}/${name}.js`, `${widgetBase}/${name}.css`]; } getWidgetPaths() { this.widgetName = this.getWidgetNameFromClass(); - const registry = WfInitiator.getWidgetRegistry(); - return registry[this.widgetName] || registry['prompt-bar']; + return WfInitiator.widgetPathsForName(this.widgetName); } static getWidgetPathsFromEl(el) { - const registry = WfInitiator.getWidgetRegistry(); - if (!el) return registry['prompt-bar']; - const cls = [...el.classList].find((c) => c.startsWith('widget-')); - const rawName = cls ? cls.replace(/^widget-/, '') : 'prompt-bar'; - return registry[rawName] || registry['prompt-bar']; + const cls = el && [...el.classList].find((c) => c.startsWith('widget-')); + const name = cls ? cls.replace(/^widget-/, '') : 'prompt-bar'; + return WfInitiator.widgetPathsForName(name); } async priorityLibFetch(workflowName) { @@ -67,6 +58,10 @@ class WfInitiator { ], 'workflow-ai': [...bundledWidgetAssets], 'workflow-firefly': fireflyShared, + 'workflow-prompt-bar-upload': [ + `${baseWfPath}/sprite.svg`, + ...this.getWidgetPaths(), + ], }; const commonResources = [ `${baseWfPath}/target-config.json`, @@ -101,7 +96,7 @@ class WfInitiator { if (this.targetConfig.renderWidget) { const widgetPath = (this.workflowCfg.name === 'workflow-photoshop' || this.workflowCfg.name === 'workflow-ai') ? `${getUnityLibs()}/core/workflow/${this.workflowCfg.name}/widget.js` - : WfInitiator.getWidgetRegistry()[this.widgetName][0]; + : WfInitiator.widgetPathsForName(this.widgetName)[0]; const { default: UnityWidget } = await import(widgetPath); const spriteContent = await spriteSvg.text(); unityWidgetObject = new UnityWidget( @@ -267,6 +262,10 @@ class WfInitiator { sfList: new Set([feature]), psw, }, + 'workflow-prompt-bar-upload': { + productName: product || 'Firefly', + sfList: new Set([feature || 'image-to-video']), + }, 'workflow-firefly': { productName: 'Firefly', sfList: new Set(['text-to-mage']), diff --git a/unitylibs/img/icons/upload.svg b/unitylibs/img/icons/upload.svg new file mode 100755 index 000000000..bdec86ab3 --- /dev/null +++ b/unitylibs/img/icons/upload.svg @@ -0,0 +1,4 @@ + + + + diff --git a/unitylibs/scripts/analytics.js b/unitylibs/scripts/analytics.js index 628fd0d2c..f982ddf34 100644 --- a/unitylibs/scripts/analytics.js +++ b/unitylibs/scripts/analytics.js @@ -1,10 +1,18 @@ -export const PROMPT_WITH_STYLE_EVENTS = { +export const PROMPT_BAR_EVENTS = { ENTER_PROMPT: 'Enter Prompt|UnityWidget', MODEL_SELECT_DROPDOWN: 'Model Select Dropdown|UnityWidget', GENERATE_CTA: 'Click on Generate CTA|UnityWidget', MODULE_PICKER: 'Module Picker Select Dropdown|UnityWidget', + RATIO_DROPDOWN: 'Ratio Dropdown Select|UnityWidget', + MORE: 'More|UnityWidget', + UPLOAD_STARTED: 'Uploading started|UnityWidget', + UPLOAD_ERROR: 'Upload error|UnityWidget', + generateModel: (modelName) => `Generate ${modelName}|UnityWidget`, + ratioSelect: (ratio) => `${ratio}|UnityWidget`, }; +export const PROMPT_WITH_STYLE_EVENTS = PROMPT_BAR_EVENTS; + export function styleSelectionGenerateEventName(styleIndexOneBased) { return `Style ${styleIndexOneBased}|UnityWidget`; } @@ -28,7 +36,7 @@ function getSessionID() { function createPayloadForSplunk(metaData) { const { eventName, product, errorData, redirectUrl, assetId, statusCode, verb, action, workflowStep, fileMetaData, operation, - styleEventName, modelGenEventName, + styleEventName, modelGenEventName, aspectRatio, hasImage, } = metaData; return { event: { @@ -45,6 +53,8 @@ function createPayloadForSplunk(metaData) { ...(fileMetaData && { fileMetaData }), ...(styleEventName && { style: styleEventName }), ...(modelGenEventName && { model: modelGenEventName }), + ...(aspectRatio && { aspectRatio }), + ...(hasImage !== undefined && hasImage !== null && { hasImage }), }, source: { user_agent: navigator.userAgent, diff --git a/unitylibs/scripts/transition-screen.js b/unitylibs/scripts/transition-screen.js index 800b16428..2f9c83d61 100644 --- a/unitylibs/scripts/transition-screen.js +++ b/unitylibs/scripts/transition-screen.js @@ -83,7 +83,7 @@ export default class TransitionScreen { return splashScreenConfig[`fragmentLink-${matchedDomain}`]; } const productName = this.workflowCfg.productName.toLowerCase(); - if (this.workflowCfg.name === 'workflow-upload') { + if (this.workflowCfg.name === 'workflow-upload' || this.workflowCfg.name === 'workflow-prompt-bar-upload') { const { theme } = this.workflowCfg; const themedKey = theme ? `fragmentLink-${productName}-${theme}` : null; if (themedKey && splashScreenConfig[themedKey]) return splashScreenConfig[themedKey]; @@ -113,7 +113,9 @@ export default class TransitionScreen { async loadSplashFragment() { if (!this.workflowCfg.targetCfg.showSplashScreen) return; + if (this.splashScreenEl) return; const fragmentLink = this.getFragmentLink(); + if (!fragmentLink) return; this.splashFragmentLink = localizeLink(`${window.location.origin}${fragmentLink}`); const resp = await fetch(`${this.splashFragmentLink}.plain.html`); const html = await resp.text(); diff --git a/unitylibs/utils/FileUtils.js b/unitylibs/utils/FileUtils.js index f89762154..8917cd8c0 100644 --- a/unitylibs/utils/FileUtils.js +++ b/unitylibs/utils/FileUtils.js @@ -24,6 +24,23 @@ export function getMimeType(fileName) { return extToTypeMap[getExtension(fileName)]; } +export function getVideoDuration(file) { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const video = document.createElement('video'); + video.preload = 'metadata'; + video.onloadedmetadata = () => { + URL.revokeObjectURL(url); + resolve(video.duration); + }; + video.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error(`Unable to read video metadata for: ${file.name}`)); + }; + video.src = url; + }); +} + export async function getImageDimensions(file) { const buffer = await file.slice(0, 256 * 1024).arrayBuffer(); const view = new DataView(buffer);