Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d26680c
Add quote maker
shikshachauhan Jul 29, 2026
13de336
Fix UI and Fonts
shikshachauhan Aug 10, 2026
71b9880
Add cross button on toast
shikshachauhan Aug 10, 2026
f0631c7
Rename quote maker to mini editor
shikshachauhan Aug 11, 2026
7407138
Move mini editor to a widget
shikshachauhan Aug 11, 2026
46ceef3
Move backgounds and fonts loader in separate utilities
shikshachauhan Aug 11, 2026
0c10d56
Apply first font for small app frames
shikshachauhan Aug 11, 2026
57df2df
Add test cases
shikshachauhan Aug 11, 2026
947c161
Remove static files backup
shikshachauhan Aug 12, 2026
7c60404
Use css variables
shikshachauhan Aug 12, 2026
9c92945
add Express mini-editor actions UI, functinality to follow
vvineett Aug 13, 2026
877177d
add Express mini-editor actions UI, functinality to follow
vvineett Aug 13, 2026
fd3230d
add html to canvas download utils for using in mini-editor
vvineett Aug 10, 2026
56fa4e6
Integrate download utils with mini-editor and improve performance usi…
vvineett Aug 14, 2026
11007f0
fix lint and tests
vvineett Aug 14, 2026
796bd82
Add mini editor modal
shikshachauhan Aug 13, 2026
5050c0d
Use spectrum icons
shikshachauhan Aug 13, 2026
fa110cd
Character truncation and fixed width variable height handling
shikshachauhan Aug 13, 2026
0ac5f8d
UI fixes
shikshachauhan Aug 14, 2026
1fedbd8
Fix the arc
shikshachauhan Aug 14, 2026
2f5c2f3
Implement hover and focus states
shikshachauhan Aug 14, 2026
0a50470
merge CCEX-289053-mini-editor into mini-editor-download-util
vvineett Aug 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions express/code/blocks/collapsible-rows/collapsible-rows.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,84 @@ main .section .collapsible-row-sub-header p {
main .section .collapsible-row-sub-header p:last-of-type {
margin-bottom: 0;
}

/* Copy quote / Create a design actions — per Figma node 0-19592: a
right-aligned pair below the quote+author text, 8px gap, pill buttons. */
.collapsible-row-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-100);
margin-top: var(--spacing-300);
}

.collapsible-row-action {
display: inline-flex;
align-items: center;
gap: var(--spacing-80);
box-sizing: border-box;
height: 32px;
padding: 7px 16px 7px 14px;
border: none;
border-radius: 24px;
font-family: var(--body-font-family);
font-size: 14px;
font-weight: var(--heading-font-weight-medium, 700);
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.15s ease;
}

.collapsible-row-action img {
width: 16px;
height: 16px;
}

.collapsible-row-action--copy {
background: #e9e9e9;
color: #292929;
}

.collapsible-row-action--copy:hover {
background: #dcdcdc;
}

.collapsible-row-action--design {
background: #3b63fb;
color: #fff;
}

.collapsible-row-action--design:hover {
background: #2d4fd6;
}

.collapsible-row-action--design img {
filter: brightness(0) invert(1);
}

/* Icon-only on mobile — "Copy quote" needs the label at tablet/desktop
widths where the pill has room, but on mobile the icon alone is enough
and the text just crowds the "Create a design" pill beside it. The
accessible name still comes from the (now sr-only) label text, so this
is a visual change only — see buildQuoteActions in collapsible-rows.js. */
@media (max-width: 767px) {
.collapsible-row-action--copy span {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

.collapsible-row-action--copy {
padding: 7px;
}
}

.collapsible-rows .collapsible-row-toggle-btn {
display: block;
width: fit-content;
Expand Down
82 changes: 77 additions & 5 deletions express/code/blocks/collapsible-rows/collapsible-rows.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,59 @@
import { getLibs } from '../../scripts/utils.js';
import { getLibs, getIconElementDeprecated } from '../../scripts/utils.js';
import { isExpressTypographyClass, isMiloTypographyClass } from '../../scripts/typography-utils.js';
import showCopyToast from '../../scripts/utils/copy-toast.js';

let createTag;
let getConfig;
let replaceKey;

/**
* Name of the custom event a mini-editor block on the same page listens
* for (see mini-editor.js) to jump its editor to this exact quote/author
* and scroll it into view — the two blocks are otherwise unrelated, so a
* DOM event keeps them decoupled instead of importing one into the other.
*/
const USE_QUOTE_EVENT = 'mini-editor:use-quote';

/**
* Builds the "Copy quote" / "Create a design" action pair added below each
* quote row's text, per Figma node 0-19592. Copy uses the same "quote —
* author" format and shared bottom toast as mini-editor's own copy
* actions, so every copy affordance on the page behaves identically.
* "Create a design" only makes sense when a mini-editor block is present
* on the page to receive the USE_QUOTE_EVENT it dispatches, so the whole
* action pair is skipped otherwise.
*/
function buildQuoteActions(quote, author, hasMiniEditor) {
if (!hasMiniEditor) return null;

const actions = createTag('div', { class: 'collapsible-row-actions' });

const copyBtn = createTag('button', { type: 'button', class: 'collapsible-row-action collapsible-row-action--copy' }, [
getIconElementDeprecated('copy-quote'),
createTag('span', {}, ['Copy quote']),
]);
copyBtn.addEventListener('click', async () => {
const text = author ? `${quote} — ${author}` : quote;
try {
await navigator.clipboard.writeText(text);
showCopyToast('Quote copied to clipboard');
} catch {
// Clipboard write failed (e.g. permissions) — no toast, nothing else to do.
}
});

const designBtn = createTag('button', { type: 'button', class: 'collapsible-row-action collapsible-row-action--design' }, [
getIconElementDeprecated('create-design'),
createTag('span', {}, ['Create a design']),
]);
designBtn.addEventListener('click', () => {
document.dispatchEvent(new CustomEvent(USE_QUOTE_EVENT, { detail: { quote, author } }));
});

actions.append(copyBtn, designBtn);
return actions;
}

function shouldReuseSingleElement(tempContainer) {
const childElements = Array.from(tempContainer.children);
if (childElements.length !== 1) return false;
Expand Down Expand Up @@ -42,7 +91,7 @@ function createContentElement(html, baseClass, options = {}) {
return element;
}

function buildTableLayout(block, typographyClasses = {}) {
function buildTableLayout(block, typographyClasses = {}, hasMiniEditor = false) {
const parentDiv = block.closest('.section');
parentDiv?.classList.add('collapsible-rows-grey-bg', 'collapsible-section-padding');

Expand Down Expand Up @@ -109,6 +158,12 @@ function buildTableLayout(block, typographyClasses = {}) {
subHeaderEl.classList.add(...typographyClasses.body);
}
subHeaderAccordion.append(subHeaderEl);
const quoteActions = buildQuoteActions(
headerEl.textContent.trim(),
subHeaderEl.textContent.trim(),
hasMiniEditor,
);
if (quoteActions) subHeaderAccordion.append(quoteActions);

headerEl.addEventListener('click', () => {
headerAccordion.classList.toggle('rounded-corners');
Expand All @@ -121,7 +176,13 @@ function buildTableLayout(block, typographyClasses = {}) {
});
}

function buildOriginalLayout(block, typographyClasses = {}, viewMoreText = 'View more', viewLessText = 'View less') {
function buildOriginalLayout(
block,
typographyClasses = {},
viewMoreText = 'View more',
viewLessText = 'View less',
hasMiniEditor = false,
) {
const collapsibleRows = [];
const rows = Array.from(block.children);

Expand Down Expand Up @@ -169,6 +230,12 @@ function buildOriginalLayout(block, typographyClasses = {}, viewMoreText = 'View
subHeaderEl.classList.add(...typographyClasses.body);
}
accordion.append(subHeaderEl);
const quoteActions = buildQuoteActions(
headerEl.textContent.trim(),
subHeaderEl.textContent.trim(),
hasMiniEditor,
);
if (quoteActions) accordion.append(quoteActions);
});

const toggleButton = createTag('a', { class: 'collapsible-row-toggle-btn button' });
Expand Down Expand Up @@ -242,14 +309,19 @@ export default async function decorate(block) {
const typographyClasses = extractTypographyClasses(block);

const isExpandableVariant = block.classList.contains('expandable');
// "Create a design" only does something when a mini-editor block exists
// on the page to receive its event — checked against the raw authored
// DOM (not decorated state), since block decoration order across blocks
// on a page isn't guaranteed.
const hasMiniEditor = !!document.querySelector('.mini-editor');

if (isExpandableVariant) {
buildTableLayout(block, typographyClasses);
buildTableLayout(block, typographyClasses, hasMiniEditor);
} else {
const [viewMoreText, viewLessText] = await Promise.all([
replaceKey('view-more', getConfig()),
replaceKey('view-less', getConfig()),
]);
buildOriginalLayout(block, typographyClasses, viewMoreText || 'View more', viewLessText || 'View less');
buildOriginalLayout(block, typographyClasses, viewMoreText || 'View more', viewLessText || 'View less', hasMiniEditor);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Mini Editor background loader
*
* Single entry point (`getCardBackgrounds`) that returns the background-card
* collection the mini-editor renders, by fetching live templates from the
* template service for the block's authored `collectionId`.
*
* Every card has the same shape: `{ id, bg }`, where `id` is a template urn
* (used later for todo/CTA actions that need to reference the exact source
* asset) and `bg` is the image URL to paint.
*/

import {
fetchResults,
isValidTemplate,
getImageThumbnailSrc,
} from '../../scripts/template-utils.js';

function buildRecipe(props) {
const params = new URLSearchParams();
params.set('limit', String(props.limit));
if (props.collectionId) params.set('collectionId', props.collectionId);
if (props.topics) params.set('topics', props.topics);
return params.toString();
}

/**
* Returns the mini-editor's background-card collection as `[{ id, bg }, ...]`,
* fetched live from the template service.
*
* @param {Object} props
* @param {string} [props.collectionId] — template collection to fetch from.
* @param {number} props.limit — max cards to return.
* @param {string} [props.topics] — template-fetch topics filter.
* @returns {Promise<Array<{ id: string, bg: string }>>}
*/
export default async function getCardBackgrounds(props) {
const recipe = buildRecipe(props);
const res = await fetchResults(recipe);
if (!res?.items?.length) return [];

return res.items
.filter((item) => isValidTemplate(item))
.slice(0, props.limit)
.map((item) => {
const page = item.pages?.[0];
/* eslint-disable no-underscore-dangle */
const renditionHref = item._links?.['http://ns.adobe.com/adobecloud/rel/rendition']?.href;
const componentHref = item._links?.['http://ns.adobe.com/adobecloud/rel/component']?.href;
/* eslint-enable no-underscore-dangle */
const bg = getImageThumbnailSrc(renditionHref, componentHref, page);
return { id: item.id, bg };
})
.filter((card) => !!card.bg);
}
117 changes: 117 additions & 0 deletions express/code/blocks/mini-editor/mini-editor-fonts-loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Mini Editor fonts loader
*
* Single entry point (`getFontOptions`) that returns the font-choice list the
* mini-editor's font control renders. It owns the decision of WHERE those
* options come from, so callers never branch on it:
*
* - Load the Adobe Fonts (Typekit) kit, then read whatever families it
* actually exposes and turn them into options (the live source).
* - If the kit fails to load or exposes nothing, fall back to the bundled
* FALLBACK_FONT_OPTIONS so the UI always has choices to show.
*
* Every option has the same shape the widget consumes:
* `{ label, font, italic?, weight? }`.
*/

// Fallback used only if the Typekit kit fails to load or exposes no fonts
// (network failure, ad blocker, or API shape change) — see getFontOptions.
const FALLBACK_FONT_OPTIONS = [
{ label: 'Sans', font: '"Cal Sans", "Inter", sans-serif' },
{ label: 'Serif', font: '"Source Serif 4", Georgia, serif', italic: true },
{ label: 'Script', font: '"Dancing Script", cursive', italic: true },
{ label: 'Bold', font: '"Poppins", sans-serif', weight: '700' },
{ label: 'Serious', font: 'Georgia, serif' },
];

// Adobe Fonts (Typekit) kit id — same lazy-load approach as font-generator.js:
// load the JS embed kit (works cross-domain) instead of the CSS endpoint
// (which 412s off non-allow-listed domains), and resolve on active/inactive
// so callers await real font readiness, not just script load.
const ADOBE_FONTS_KIT_ID = 'iqd6egj';

function loadWebFonts() {
return new Promise((resolve) => {
const runTypekit = () => {
try {
window.Typekit.load({
kitId: ADOBE_FONTS_KIT_ID,
scriptTimeout: 3000,
async: true,
active: resolve,
inactive: resolve,
});
} catch {
resolve();
}
};
if (window.Typekit) {
runTypekit();
return;
}
const script = document.createElement('script');
script.src = `https://use.typekit.net/${ADOBE_FONTS_KIT_ID}.js`;
script.async = true;
script.addEventListener('load', runTypekit, { once: true });
script.addEventListener('error', resolve, { once: true });
document.head.append(script);
});
}

// Turns a Typekit family slug ("gothic-a1", "source-han-sans-japanese") into
// a human label ("Gothic A1", "Source Han Sans Japanese") for the font pill/
// buttons — Typekit's font list exposes no display name, only this slug.
function familySlugToLabel(family) {
return family
.split('-')
.map((part) => (part.length <= 2 ? part.toUpperCase() : part[0].toUpperCase() + part.slice(1)))
.join(' ');
}

/**
* Reads the fonts the loaded Typekit kit actually exposes (window.Typekit
* .fonts.fonts — each entry's `family` is both its slug and the exact CSS
* font-family name Typekit registered, confirmed via document.fonts) and
* turns them into option entries, instead of a hand-authored list that can
* silently drift from whatever the kit (ADOBE_FONTS_KIT_ID) actually
* contains. Variants of the same family (weight/style) collapse into one
* option that offers italic/bold if any variant of that family has it.
* Falls back to FALLBACK_FONT_OPTIONS if the kit failed to load or exposes
* nothing, so the UI still has font choices to show.
*/
function buildFontOptions() {
const entries = window.Typekit?.fonts?.fonts;
if (!Array.isArray(entries) || !entries.length) return FALLBACK_FONT_OPTIONS;

const byFamily = new Map();
entries.forEach(({ family, weight, style }) => {
if (!family) return;
const existing = byFamily.get(family) || { italic: false, bold: false };
if (style === 'italic') existing.italic = true;
if (weight === '700' || weight === 'bold') existing.bold = true;
byFamily.set(family, existing);
});
if (!byFamily.size) return FALLBACK_FONT_OPTIONS;

return Array.from(byFamily, ([family, { italic, bold }]) => {
const option = { label: familySlugToLabel(family), font: `"${family}", var(--body-font-family, sans-serif)` };
if (italic) option.italic = true;
if (bold) option.weight = '700';
return option;
});
}

/**
* Returns the mini-editor's font-choice list as `[{ label, font, italic?,
* weight? }, ...]`. Loads the Adobe Fonts kit first so the options reflect
* whatever families it actually registered, then builds them — the caller
* does not need to load the kit or know which source (live/fallback) was
* used.
*
* @returns {Promise<Array<{ label: string, font: string, italic?: boolean,
* weight?: string }>>}
*/
export default async function getFontOptions() {
await loadWebFonts();
return buildFontOptions();
}
Loading
Loading