diff --git a/express/blocks/gen-ai-cards/gen-ai-cards.css b/express/blocks/gen-ai-cards/gen-ai-cards.css new file mode 100644 index 0000000..8b1204e --- /dev/null +++ b/express/blocks/gen-ai-cards/gen-ai-cards.css @@ -0,0 +1,213 @@ +.section .gen-ai-cards-wrapper { + max-width: none; +} + +.gen-ai-cards { + padding: 0 28px; + max-width: 1440px; + margin: auto; +} + +.gen-ai-cards .gen-ai-cards-heading-section { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + margin: auto auto 32px; +} + +.gen-ai-cards .gen-ai-cards-heading-section h2 { + text-align: left; + max-width: 800px; + margin-bottom: 8px; + font-size: var(--heading-font-size-m); +} +.gen-ai-cards .gen-ai-cards-heading-section p { + text-align: left; + margin: 0; + max-width: 800px; +} + +.gen-ai-cards .gen-ai-cards-heading-section .gen-ai-cards-link { + font-size: var(--body-font-size-s); + line-height: 22px; + color: var(--body-color); + text-decoration: underline; +} + +.gen-ai-cards .carousel-container .carousel-platform { + align-items: unset; +} + +.gen-ai-cards .card { + position: relative; + height: 347px; + width: 406px; + border-radius: 8px; + overflow: hidden; + margin: 0 8px; + background-color: #f4f4fd; +} + +.gen-ai-cards .carousel-left-trigger ~ .card { + margin-left: 0; +} + +.gen-ai-cards .card .text-wrapper { + text-align: left; + margin: 24px 24px 16px 24px; +} + +.gen-ai-cards .card .text-wrapper p { + margin: 0; +} + +.gen-ai-cards .card .text-wrapper p.cta-card-title { + font-size: var(--body-font-size-m); + line-height: var(--body-font-size-m); + font-weight: 700; +} + +.gen-ai-cards .card .text-wrapper .tag { + font-size: 10px; + padding: 0 4px; + margin-left: 6px; + font-weight: 700; + background-color: #DEDEF9; + color: var(--color-info-accent); + border-radius: 4px; +} + +.gen-ai-cards .card .text-wrapper p.cta-card-desc { + font-size: var(--body-font-size-s); + line-height: var(--body-font-size-l); +} + +.gen-ai-cards .card .media-wrapper { + position: absolute; + border-radius: 16px; + left: 50%; + bottom: 16px; + transform: translateX(-50%); + width: 374px; + height: 246px; + overflow: hidden; +} + +.gen-ai-cards .card .media-wrapper picture img { + display: block; + height: 100%; + object-fit: cover; +} + +.gen-ai-cards .card .gen-ai-input-form { + position: absolute; + width: 352px; + height: 55px; + padding: 8px; + background-color: var(--color-white); + border-radius: 8px; + box-sizing: border-box; + border: 2px solid var(--color-info-accent); + left: 50%; + top: 100%; + transform: translate(-50%, calc(-100% - 30px)); + display: flex; + justify-content: space-between; +} + +.gen-ai-cards .card .gen-ai-input-form input { + box-sizing: border-box; + border-radius: 8px; + border: none; + font-family: var(--body-font-family); + font-size: var(--body-font-size-s); + resize: none; + width: 230px; +} + +.gen-ai-cards .gen-ai-input:active, +.gen-ai-cards .gen-ai-input:focus-visible { + border: none; + outline: none; +} + +.gen-ai-cards .card .gen-ai-input-form input::placeholder { + font-style: italic; + font-family: var(--body-font-family); + font-size: var(--body-font-size-s); +} + +.gen-ai-cards .card .gen-ai-input-form input:placeholder-shown { + text-overflow: ellipsis; +} + +.gen-ai-cards .card .gen-ai-input-form button:disabled { + pointer-events: none; +} + +.gen-ai-cards .card .gen-ai-input-form .gen-ai-submit { + color: var(--color-white); + background-color: var(--color-info-accent); + border-style: none; + font-family: var(--body-font-family); + font-size: var(--body-font-size-s); + line-height: var(--body-font-size-s); + font-weight: 700; + padding: 10px 1.5em 10px 1.5em; + border-radius: 22px; + cursor: pointer; + transition: background-color .2s; +} + +.gen-ai-cards .card.gen-ai-action .gen-ai-input-form .gen-ai-submit { + left: 56px; + bottom: 24px; +} + +.gen-ai-cards .gen-ai-input-form .gen-ai-submit:not(:disabled):hover { + background-color: var(--color-info-accent-hover); +} + +.gen-ai-cards .card .gen-ai-input-form .gen-ai-submit:disabled { + background-color: var(--color-gray-200); + color: var(--color-gray-500); +} + +.gen-ai-cards .card .links-wrapper { + position: absolute; + display: flex; + flex-direction: column; + justify-content: center; + left: 50%; + bottom: 40px; + transform: translate(-50%, 0); +} + +.gen-ai-cards .card .links-wrapper a { + margin: 4px auto; + width: max-content; + color: var(--body-color); + border: 0; +} + +.gen-ai-cards .card .links-wrapper a:hover { + /* 90% white to align with products */ + background-color: #e6e6e6; +} + +.gen-ai-cards .card .links-wrapper a:not(:hover) { + background-color: var(--color-white); +} + +@media (min-width: 900px) { + .gen-ai-cards .gen-ai-cards-heading-section { + flex-direction: row; + align-items: flex-end; + } + + .gen-ai-cards .carousel-container .carousel-fader-left, + .gen-ai-cards .carousel-container .carousel-fader-right{ + background: unset; + } +} diff --git a/express/blocks/gen-ai-cards/gen-ai-cards.js b/express/blocks/gen-ai-cards/gen-ai-cards.js new file mode 100644 index 0000000..4267184 --- /dev/null +++ b/express/blocks/gen-ai-cards/gen-ai-cards.js @@ -0,0 +1,226 @@ +import { getLibs } from '../../scripts/utils.js'; +import { addTempWrapperDeprecated } from '../../scripts/utils/decorate.js'; +import buildCarousel from '../../scripts/widgets/carousel.js'; + +const { createTag, getConfig } = await import(`${getLibs()}/utils/utils.js`); +const promptTokenRegex = /(?:\{\{|%7B%7B)?prompt(?:-|\+|%20|\s)text(?:\}\}|%7D%7D)?/; + +export function decorateTextWithTag(textSource, options = {}) { + const { + baseT, + tagT, + baseClass, + tagClass, + } = options; + const text = createTag(baseT || 'p', { class: baseClass || '' }); + const tagText = textSource.match(/\[(.*?)]/); + + if (tagText) { + const [fullText, tagTextContent] = tagText; + const $tag = createTag(tagT || 'span', { class: tagClass || 'tag' }); + text.textContent = textSource.replace(fullText, '').trim(); + text.dataset.text = text.textContent.toLowerCase(); + $tag.textContent = tagTextContent; + text.append($tag); + } else { + text.textContent = textSource; + text.dataset.text = text.textContent.toLowerCase(); + } + return text; +} + +export function decorateHeading(block, payload) { + const headingSection = createTag('div', { class: 'gen-ai-cards-heading-section' }); + const headingTextWrapper = createTag('div', { class: 'text-wrapper' }); + const heading = createTag('h2', { class: 'gen-ai-cards-heading' }); + + heading.textContent = payload.heading; + headingSection.append(headingTextWrapper); + headingTextWrapper.append(heading); + + if (payload.subHeadings.length > 0) { + payload.subHeadings.forEach((p) => { + headingTextWrapper.append(p); + }); + } + + if (payload.legalLink.href !== '') { + const legalButton = createTag('a', { + class: 'gen-ai-cards-link', + href: payload.legalLink.href, + }); + legalButton.textContent = payload.legalLink.text; + headingSection.append(legalButton); + } + + block.append(headingSection); +} + +export const windowHelper = { + redirect: (url) => { + window.location.assign(url); + }, +}; + +function handleGenAISubmit(form, link) { + const input = form.querySelector('input'); + if (input.value.trim() === '') return; + const genAILink = link.replace(promptTokenRegex, encodeURI(input.value).replaceAll(' ', '+')); + if (genAILink) windowHelper.redirect(genAILink); +} + +function buildGenAIForm({ ctaLinks, subtext }) { + const genAIForm = createTag('form', { class: 'gen-ai-input-form' }); + const genAIInput = createTag('input', { + placeholder: subtext || '', + type: 'text', + enterKeyhint: 'enter', + }); + const genAISubmit = createTag('button', { + class: 'gen-ai-submit', + type: 'submit', + disabled: true, + }); + + genAIForm.append(genAIInput, genAISubmit); + + genAISubmit.textContent = ctaLinks[0].textContent; + genAISubmit.disabled = genAIInput.value === ''; + + genAIInput.addEventListener('input', () => { + genAISubmit.disabled = genAIInput.value.trim() === ''; + }); + + genAIInput.addEventListener('keyup', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleGenAISubmit(genAIForm, ctaLinks[0].href); + } + }); + + genAIForm.addEventListener('submit', (e) => { + e.preventDefault(); + handleGenAISubmit(genAIForm, ctaLinks[0].href); + }); + + return genAIForm; +} + +function removeLazyAfterNeighborLoaded(image, lastImage) { + if (!image || !lastImage) return; + lastImage.onload = (e) => { + if (e.eventPhase >= Event.AT_TARGET) { + image.querySelector('img').removeAttribute('loading'); + } + }; +} + +async function decorateCards(block, { actions }) { + const cards = createTag('div', { class: 'gen-ai-cards-cards' }); + let searchBranchLinks; + + await import(`${getLibs()}/features/placeholders.js`).then(async (mod) => { + searchBranchLinks = await mod.replaceKey('search-branch-links', getConfig()); + return mod.replaceKey(); + }); + + actions.forEach((cta, i) => { + const { + image, + ctaLinks, + text, + title, + } = cta; + const card = createTag('div', { class: 'card' }); + const linksWrapper = createTag('div', { class: 'links-wrapper' }); + const mediaWrapper = createTag('div', { class: 'media-wrapper' }); + const textWrapper = createTag('div', { class: 'text-wrapper' }); + + card.append(textWrapper, mediaWrapper, linksWrapper); + if (image) { + mediaWrapper.append(image); + if (i > 0) { + const lastImage = actions[i - 1].image?.querySelector('img'); + removeLazyAfterNeighborLoaded(image, lastImage); + } + } + + const hasGenAIForm = promptTokenRegex.test(ctaLinks?.[0]?.href); + + if (ctaLinks.length > 0) { + if (hasGenAIForm) { + const genAIForm = buildGenAIForm(cta); + card.classList.add('gen-ai-action'); + card.append(genAIForm); + linksWrapper.remove(); + } else { + const a = ctaLinks[0]; + const btnUrl = new URL(a.href); + if (searchBranchLinks?.replace(/\s/g, '').split(',').includes(`${btnUrl.origin}${btnUrl.pathname}`)) { + btnUrl.searchParams.set('q', cta.text); + btnUrl.searchParams.set('category', 'templates'); + a.href = decodeURIComponent(btnUrl.toString()); + } + a.classList.add('con-button'); + a.removeAttribute('title'); + linksWrapper.append(a); + } + } + + const titleText = decorateTextWithTag(title, { tagT: 'sup', baseClass: 'cta-card-title' }); + textWrapper.append(titleText); + const desc = createTag('p', { class: 'cta-card-desc' }); + desc.textContent = text; + textWrapper.append(desc); + + cards.append(card); + }); + + block.append(cards); +} + +function constructPayload(block) { + const rows = Array.from(block.children); + block.innerHTML = ''; + const headingDiv = rows.shift(); + + const payload = { + heading: headingDiv.querySelector('h2, h3, h4, h5, h6')?.textContent?.trim(), + subHeadings: headingDiv.querySelectorAll('p:not(.button-container, :has(a.con-button, a[href*="legal"]))'), + legalLink: { + text: headingDiv.querySelector('a[href*="legal"]')?.textContent?.trim(), + href: headingDiv.querySelector('a[href*="legal"]')?.href, + }, + actions: [], + }; + + rows.forEach((row) => { + const ctaObj = { + image: row.querySelector(':scope > div:nth-of-type(1) picture'), + videoLink: row.querySelector(':scope > div:nth-of-type(1) a'), + title: row.querySelector(':scope > div:nth-of-type(2) p:nth-of-type(2):not(.button-container) strong')?.textContent.trim(), + text: row.querySelector(':scope > div:nth-of-type(2) p:not(.button-container):not(:has(strong)):not(:has(em)):not(:empty)')?.textContent.trim(), + subtext: row.querySelector(':scope > div:nth-of-type(2) p:not(.button-container) em')?.textContent.trim(), + ctaLinks: row.querySelectorAll(':scope > div:nth-of-type(2) a'), + }; + + payload.actions.push(ctaObj); + }); + + return payload; +} + +export default async function decorate(block) { + addTempWrapperDeprecated(block, 'gen-ai-cards'); + const links = block.querySelectorAll(':scope a[href*="adobesparkpost"]'); + + if (links) { + const linksPopulated = new CustomEvent('linkspopulated', { detail: links }); + document.dispatchEvent(linksPopulated); + } + + const payload = constructPayload(block); + decorateHeading(block, payload); + await decorateCards(block, payload); + await buildCarousel('', block.querySelector('.gen-ai-cards-cards')); +} diff --git a/express/scripts/branchlinks.js b/express/scripts/branchlinks.js index 9020c00..1307a34 100644 --- a/express/scripts/branchlinks.js +++ b/express/scripts/branchlinks.js @@ -56,6 +56,7 @@ export default async function trackBranchParameters(links) { const listBranchMetadataNodes = [...document.head.querySelectorAll('meta[name^=branch-]')]; const listAdditionalBranchMetadataNodes = listBranchMetadataNodes.filter((e) => !setBasicBranchMetadata.has(e.name.replace(/^branch-/, ''))); + const searchBranchLinks = await placeholderMod.replaceKey('search-branch-links', getConfig()); const [ searchTerm, @@ -103,7 +104,6 @@ export default async function trackBranchParameters(links) { params.get('cgen'), ]; - const promises = []; links.forEach((a) => { if (a.href && a.href.match(/adobesparkpost(-web)?\.app\.link/)) { a.rel = 'nofollow'; @@ -120,29 +120,26 @@ export default async function trackBranchParameters(links) { } const placement = getPlacement(a); - const prom = placeholderMod.replaceKey('search-branch-links', getConfig()).then((searchBranchLink) => { - const isSearchBranchLink = searchBranchLink?.replace(/\s/g, '').split(',').includes(`${btnUrl.origin}${btnUrl.pathname}`); - if (isSearchBranchLink) { - setParams('category', category || 'templates'); - setParams('taskID', taskID); - setParams('assetCollection', assetCollection); - setParams('height', canvasHeight); - setParams('width', canvasWidth); - setParams('unit', canvasUnit); - setParams('sceneline', sceneline); - - if (searchCategory) { - setParams('searchCategory', searchCategory); - } else if (searchTerm) { - setParams('q', searchTerm); - } - if (loadPrintAddon) setParams('loadPrintAddon', loadPrintAddon); - setParams('tab', tab); - setParams('action', action); - setParams('prompt', prompt); + const isSearchBranchLink = searchBranchLinks?.replace(/\s/g, '').split(',').includes(`${btnUrl.origin}${btnUrl.pathname}`); + if (isSearchBranchLink) { + setParams('category', category || 'templates'); + setParams('taskID', taskID); + setParams('assetCollection', assetCollection); + setParams('height', canvasHeight); + setParams('width', canvasWidth); + setParams('unit', canvasUnit); + setParams('sceneline', sceneline); + + if (searchCategory) { + setParams('searchCategory', searchCategory); + } else if (searchTerm) { + setParams('q', searchTerm); } - }); - promises.push(prom); + if (loadPrintAddon) setParams('loadPrintAddon', loadPrintAddon); + setParams('tab', tab); + setParams('action', action); + setParams('prompt', prompt); + } for (const { name, content } of listAdditionalBranchMetadataNodes) { const paramName = toCamelCase(name.replace(/^branch-/, '')); @@ -179,5 +176,4 @@ export default async function trackBranchParameters(links) { a.href = decodeURIComponent(btnUrl.toString()); } }); - await Promise.all(promises); } diff --git a/express/scripts/utils/content-replace.js b/express/scripts/utils/content-replace.js index ef51285..1d8a6b5 100644 --- a/express/scripts/utils/content-replace.js +++ b/express/scripts/utils/content-replace.js @@ -289,7 +289,7 @@ async function sanitizeMeta(meta) { // metadata -> dom blades async function autoUpdatePage(main) { - const wl = ['{{heading_placeholder}}', '{{type}}', '{{quantity}}']; + const wl = ['{{heading_placeholder}}', '{{type}}', '{{quantity}}', '{{prompt-text}}']; // FIXME: deprecate wl if (!main) return; diff --git a/test/blocks/gen-ai-cards/gen-ai-cards.test.js b/test/blocks/gen-ai-cards/gen-ai-cards.test.js new file mode 100644 index 0000000..025953b --- /dev/null +++ b/test/blocks/gen-ai-cards/gen-ai-cards.test.js @@ -0,0 +1,114 @@ +import { readFile } from '@web/test-runner-commands'; +import sinon from 'sinon'; +import { expect } from '@esm-bundle/chai'; + +const imports = await Promise.all([ + import('../../../express/scripts/scripts.js'), + import('../../../express/blocks/gen-ai-cards/gen-ai-cards.js'), +]); + +const { default: decorate, windowHelper } = imports[1]; +const testBody = await readFile({ path: './mocks/body.html' }); + +describe('Gen AI Cards', () => { + let blocks; + before(async () => { + window.isTestEnv = true; + document.body.innerHTML = testBody; + window.placeholders = { 'search-branch-links': 'https://adobesparkpost.app.link/c4bWARQhWAb' }; + blocks = [...document.querySelectorAll('.gen-ai-cards')]; + await Promise.all(blocks.map((bl) => decorate(bl))); + }); + afterEach(() => { + window.placeholders = undefined; + }); + + it('should have all things', async () => { + for (const block of blocks) { + expect(block).to.exist; + expect(block.querySelector('.gen-ai-cards-heading-section')).to.exist; + const cards = block.querySelector('.carousel-container .carousel-platform'); + expect(cards).to.exist; + expect(cards.querySelectorAll('.card').length).to.equal(5); + expect(cards.querySelector('.card').classList.contains('gen-ai-action')).to.be.true; + expect(cards.querySelectorAll('.card')[1].classList.contains('gen-ai-action')).to.be.false; + expect(cards.querySelectorAll('.card')[2].classList.contains('gen-ai-action')).to.be.true; + expect(cards.querySelectorAll('.card')[3].classList.contains('gen-ai-action')).to.be.true; + } + }); + + it('should have all cards with proper children', async () => { + for (const block of blocks) { + const cards = block.querySelector('.carousel-container .carousel-platform'); + for (const card of cards.querySelectorAll('.card')) { + expect(card.querySelector('.text-wrapper .cta-card-desc')).to.exist; + expect(card.querySelector('.text-wrapper .cta-card-title')).to.exist; + expect(card.querySelector('.media-wrapper picture')).to.exist; + } + for (const card of cards.querySelectorAll('.card:not(.gen-ai-action)')) { + const cta = card.querySelector('.links-wrapper a'); + expect(cta).to.exist; + expect(cta.textContent).to.exist; + expect(cta.href).to.exist; + } + for (const card of cards.querySelectorAll('.card.gen-ai-action')) { + const form = card.querySelector('.gen-ai-input-form'); + expect(form).to.exist; + const input = form.querySelector('input'); + const button = form.querySelector('button'); + expect(input).to.exist; + expect(button).to.exist; + expect(input.placeholder).to.exist; + expect(button.textContent).to.exist; + expect(button.classList.contains('gen-ai-submit')).to.be.true; + } + } + }); + + function decodeHTMLEntities(text) { + const textArea = document.createElement('textarea'); + textArea.innerHTML = text; + return textArea.value; + } + + it('should toggle submit button disabled based on input content', async () => { + for (const block of blocks) { + const cards = block.querySelector('.carousel-container .carousel-platform'); + const card = cards.querySelector('.card.gen-ai-action'); + const form = card.querySelector('.gen-ai-input-form'); + const input = form.querySelector('input'); + const button = form.querySelector('button'); + const stub = sinon.stub(windowHelper, 'redirect'); + expect(button.disabled).to.be.true; + const enterEvent = new KeyboardEvent('keyup', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(enterEvent); + expect(button.disabled).to.be.true; + expect(stub.called).to.be.false; + form.dispatchEvent(new Event('submit')); + expect(stub.called).to.be.false; + + input.value = 'test'; + input.dispatchEvent(new Event('input')); + expect(button.disabled).to.be.false; + + input.value = ''; + input.dispatchEvent(new Event('input')); + expect(button.disabled).to.be.true; + input.value = 'fakeInput'; + input.dispatchEvent(enterEvent); + expect(stub.called).to.be.true; + expect(decodeHTMLEntities(stub.firstCall.args[0])).to.equal( + decodeHTMLEntities('https://new.express.adobe.com/new?category=media&prompt=fakeInput&action=text+to+image&width=1080&height=1080'), + ); + + stub.restore(); + } + }); +}); diff --git a/test/blocks/gen-ai-cards/mocks/body.html b/test/blocks/gen-ai-cards/mocks/body.html new file mode 100644 index 0000000..88c8d07 --- /dev/null +++ b/test/blocks/gen-ai-cards/mocks/body.html @@ -0,0 +1,191 @@ + + + + + This is a good one. + + +
+
+
+
+
+
+

This is a good one.

+

Unlock your imagination with generative AI. Experiment, imagine, and create an infinite range of content in Adobe Express with generative AI features.

+

Adobe Generative AI Terms

+
+
+
+
+ + + + + + +
+
+

Generate

+

Text to image

+

Generate images from a detailed text description.

+

Try describing people, places, and things

+
+
+
+
+ + + + + + +
+
+

Upload image to start

+

Generative Fill

+

Add or remove elements with a text prompt.

+
+
+
+
+ + + + + + +
+
+

Generate

+

Text to template

+

Add or remove elements with a text prompt.

+

Describe the template you want to make

+
+
+
+
+ + + + + + +
+
+

Generate

+

Text effects

+

Apply styles or textures to text with a text prompt.

+

Describe the text effect you want to make

+
+
+ +
+
+ + + + + + +
+
+

Upload image to start

+

Generative Fill

+

Add or remove elements with a text prompt.

+
+
+
+
+
+
+

This is a good one.

+

Unlock your imagination with generative AI. Experiment, imagine, and create an infinite range of content in Adobe Express with generative AI features.

+

Adobe Generative AI Terms

+
+
+
+
+ + + + + + +
+
+

Generate

+

Text to image

+

Generate images from a detailed text description.

+

Try describing people, places, and things

+
+
+
+
+ + + + + + +
+
+

Upload image to start

+

Generative Fill

+

Add or remove elements with a text prompt.

+
+
+
+
+ + + + + + +
+
+

Generate

+

Text to template

+

Add or remove elements with a text prompt.

+

Describe the template you want to make

+
+
+
+
+ + + + + + +
+
+

Generate

+

Text effects

+

Apply styles or textures to text with a text prompt.

+

Describe the text effect you want to make

+
+
+ +
+
+ + + + + + +
+
+

Upload image to start

+

Generative Fill

+

Add or remove elements with a text prompt.

+
+
+
+
+
+ + +