Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
103 changes: 103 additions & 0 deletions streamlibs/blocks/chart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { handleComponents } from '../components/components.js';
import { safeJsonFetch } from '../utils/error-handler.js';

const CHART_TYPES = ['area', 'bar', 'column', 'donut', 'line', 'list', 'oversized-number', 'pie'];
const CHART_COLORS = ['blue', 'indigo', 'purple', 'magenta', 'seafoam', 'green', 'orange'];

// Ensure the block carries exactly the extracted chart-type class. The template
// block already ships with the right type (variant selection), but the Figma
// `chartType` is authoritative, so normalise here.
function handleChartType(blockContent, chartType) {
if (!chartType || !CHART_TYPES.includes(chartType)) return;
blockContent.classList.add(chartType);
}

function handleColor(blockContent, color) {
if (color && CHART_COLORS.includes(color)) blockContent.classList.add(color);
}

function handleLabelDirection(blockContent, direction) {
if (direction === 'diagonal') blockContent.classList.add('diagonal');
}

function handleBackground(sectionWrapper, background) {
if (!background) return;
const sectionMetadata = document.createElement('div');
sectionMetadata.classList.add('section-metadata');
const row = document.createElement('div');
const keyDiv = document.createElement('div');
keyDiv.textContent = 'Background';
const valueDiv = document.createElement('div');
valueDiv.textContent = background;
row.appendChild(keyDiv);
row.appendChild(valueDiv);
sectionMetadata.appendChild(row);
sectionWrapper.appendChild(sectionMetadata);
}

// The data-source row holds a link to the external chart JSON. Keep the row in
// place (Milo reads chart rows positionally: title > subtitle > data > footnote).
// When Figma has no real JSON (PM supplies it later) leave the template link so
// the author can replace it.
const MILO_DOC_ORIGIN = 'https://main--milo--adobecom.aem.page';

function handleDataSource(blockContent, selector, value) {
const linkEl = blockContent.querySelector(selector);
if (!linkEl) return;
if (value) {
linkEl.href = value;
linkEl.textContent = value;
return;
}

const raw = linkEl.getAttribute('href') || '';
if (raw.startsWith('/')) {
const abs = `${MILO_DOC_ORIGIN}${raw}`;
linkEl.setAttribute('href', abs);
linkEl.textContent = abs;
} else if (raw.includes('--milo--adobecom.hlx.page')) {
const abs = raw.replace('--milo--adobecom.hlx.page', '--milo--adobecom.aem.page');
linkEl.setAttribute('href', abs);
linkEl.textContent = abs;
}
}

export default async function mapBlockContent(
sectionWrapper,
blockContent,
figContent,
) {
const properties = figContent?.details?.properties;
if (!properties) return;

try {
// Strip all template-inherited classes (kitchen-sink colors, border, diagonal-label
// variants, etc.) so only Figma-driven classes end up on the block.
blockContent.className = 'chart';

const mappingData = await safeJsonFetch('chart.json');
mappingData.data.forEach((mappingConfig) => {
const value = properties[mappingConfig.key];
switch (mappingConfig.key) {
case 'dataSource':
handleDataSource(blockContent, mappingConfig.selector, value);
break;
default:
if (value) handleComponents(blockContent, value, mappingConfig);
break;
}
});
// Drop emptied text rows' inner cells (placeholder + to-remove); the outer
// row div stays so Milo's positional row parsing is preserved.
blockContent.querySelectorAll('.to-remove').forEach((el) => el.remove());

handleChartType(blockContent, properties.chartType);
handleColor(blockContent, properties.color);
handleLabelDirection(blockContent, properties.labelDirection);
if (properties.colorTheme === 'dark') blockContent.classList.add('dark');
handleBackground(sectionWrapper, properties.background);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
}
}
13 changes: 13 additions & 0 deletions streamlibs/operations/annotation/annotation.css
Original file line number Diff line number Diff line change
Expand Up @@ -1993,6 +1993,13 @@ body.annotation-asset-select-mode main img:hover {
isolation: isolate;
}

/* Chart blocks: push footnote text away from the button's bottom-right corner.
Scoped to `.chart.has-block-action` so only the chart that carries the button is
affected — non-chart blocks and charts without a button are untouched. */
.chart.has-block-action .footnote {
padding-right: 40px;
}

.block-action-btn {
position: absolute;
bottom: 0;
Expand Down Expand Up @@ -2031,6 +2038,12 @@ body.editor-mode .figma-panel .has-block-action .block-action-btn {
0 6px 18px rgba(15, 23, 42, 0.2);
}

/* Editor mode: button is 72px wide + 10px inset, so footnote needs more clearance. */
body.editor-mode .da-panel .chart.has-block-action .footnote,
body.editor-mode .figma-panel .chart.has-block-action .footnote {
padding-right: 90px;
}

body.editor-mode .da-panel .has-block-action .block-action-btn .block-action-btn-icon,
body.editor-mode .figma-panel .has-block-action .block-action-btn .block-action-btn-icon {
width: 40px;
Expand Down
36 changes: 36 additions & 0 deletions streamlibs/sources/figma.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,41 @@ function showFailedBlocksPopover(failedBlocks) {
});
}

// Merge charts that belong to the same Chart Selector (2-up/3-up) into a single
// section wrapper so Milo counts N chart divs and applies up-N (side-by-side),
// matching the Figma layout. Charts are moved left→right by chartGroupOrder into
// the group's earliest wrapper (preserving vertical page position); the other
// now-empty wrappers are dropped (set to null → removed by the caller's filter).
// blocks and htmlParts are index-aligned; blocks carry chartGroupId/chartGroupOrder
// from stream-service. Blocks with no chartGroupId (all non-chart blocks and
// standalone 1-up charts) are skipped entirely, so nothing else is affected.
function mergeChartGroups(blocks, htmlParts) {
const groups = new Map();
blocks.forEach((block, i) => {
const groupId = block?.chartGroupId;
if (!groupId) return;
const part = htmlParts[i];
// Only merge fully-mapped chart section wrappers (Element hosting a .chart).
if (!(part instanceof Element) || !part.querySelector(':scope > .chart')) return;
if (!groups.has(groupId)) groups.set(groupId, []);
groups.get(groupId).push({ index: i, order: block.chartGroupOrder ?? 0, part });
});

groups.forEach((members) => {
if (members.length < 2) return;
const hostIndex = Math.min(...members.map((m) => m.index));
const host = members.find((m) => m.index === hostIndex).part;
// Re-append every member's chart into the host in left→right order.
// appendChild moves nodes, so the host's own chart is reordered in place too.
[...members]
.sort((a, b) => a.order - b.order)
.map((m) => m.part.querySelector(':scope > .chart'))
.filter(Boolean)
.forEach((chartEl) => host.appendChild(chartEl));
members.forEach((m) => { if (m.part !== host) htmlParts[m.index] = null; });
});
}

async function createHTML(blockMapping, figmaUrl, tracker) {
const blocks = blockMapping.details.components;
const { blockContentConcurrency } = await getFigmaRetryConfig();
Expand All @@ -289,6 +324,7 @@ async function createHTML(blockMapping, figmaUrl, tracker) {
);
const failedBlocks = htmlParts.filter((r) => r?._failed);
if (failedBlocks.length) showFailedBlocksPopover(failedBlocks);
mergeChartGroups(blocks, htmlParts);
return htmlParts.filter((r) => r && !r._failed);
}

Expand Down
23 changes: 21 additions & 2 deletions streamlibs/utils/block-action-button.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
/** Shared "create fragment" control for Figma and DA block roots in the preview. */
export function appendBlockActionButton(blockEl) {
if (!blockEl || typeof blockEl.classList === 'undefined') return;
blockEl.classList.add('has-block-action');
// Chart-only guard: Milo's chart.js sizes each chart by counting its section's
// child divs (`:scope > div:not(.section-metadata)`). Appending the control to
// the section wrapper makes Milo's decorateDefaults wrap the (non-div) button in
// a `div.content`, so the count becomes 2 → the section gets `up-2` and the chart
// is pinned to `calc(50% - 8px)` wide. Host the control INSIDE the chart block
// instead so it is never a section-level sibling and the count stays correct.
//
// For merged 2-up/3-up groups, charts are already sorted left→right by
// mergeChartGroups, so the LAST `.chart` child is the rightmost — its right edge
// IS the group's right edge. Hosting the button there places it at the bottom-right
// corner of the whole group without affecting Milo's up-N count.
const chartEls = blockEl.querySelectorAll ? [...blockEl.querySelectorAll(':scope > .chart')] : [];
let host;
if (chartEls.length > 1) {
host = chartEls[chartEls.length - 1];
host.classList.add('chart-group-end');
} else {
host = chartEls[0] || blockEl;
}
host.classList.add('has-block-action');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'block-action-btn';
Expand All @@ -24,7 +43,7 @@ export function appendBlockActionButton(blockEl) {
<line x1="10.18" y1="12.92" x2="15.32" y2="12.92"/>
</svg>`;
btn.appendChild(icon);
blockEl.appendChild(btn);
host.appendChild(btn);
}

/**
Expand Down