Refs #44 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
207 lines
7.0 KiB
TypeScript
207 lines
7.0 KiB
TypeScript
import React, { CSSProperties } from 'react';
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape';
|
|
import { tokenRef } from '../../utils/tokens';
|
|
|
|
interface Counter {
|
|
number: number;
|
|
suffix: string;
|
|
label: string;
|
|
}
|
|
|
|
interface NumberCounterProps {
|
|
counters?: Counter[];
|
|
columns?: number;
|
|
numberColor?: string;
|
|
labelColor?: string;
|
|
numberSize?: string;
|
|
style?: CSSProperties;
|
|
animation?: string;
|
|
animationDelay?: string;
|
|
hideOnDesktop?: boolean;
|
|
hideOnTablet?: boolean;
|
|
hideOnMobile?: boolean;
|
|
}
|
|
|
|
const defaultCounters: Counter[] = [
|
|
{ number: 150, suffix: '+', label: 'Projects' },
|
|
{ number: 50, suffix: '+', label: 'Clients' },
|
|
{ number: 10, suffix: '', label: 'Years' },
|
|
{ number: 99, suffix: '%', label: 'Satisfaction' },
|
|
];
|
|
|
|
export const NumberCounter: UserComponent<NumberCounterProps> = ({
|
|
counters = defaultCounters,
|
|
columns = 4,
|
|
numberColor = tokenRef('primary'),
|
|
labelColor = tokenRef('muted'),
|
|
numberSize = '48px',
|
|
style = {},
|
|
}) => {
|
|
const {
|
|
connectors: { connect, drag },
|
|
selected,
|
|
} = useNode((node) => ({
|
|
selected: node.events.selected,
|
|
}));
|
|
|
|
const items = counters.length > 0 ? counters : defaultCounters;
|
|
|
|
return (
|
|
<section
|
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
|
style={{
|
|
padding: '60px 20px',
|
|
backgroundColor: '#ffffff',
|
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
|
...style,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
maxWidth: '1100px',
|
|
margin: '0 auto',
|
|
display: 'grid',
|
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
gap: '32px',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{items.map((counter, i) => (
|
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
|
<span style={{
|
|
fontSize: numberSize,
|
|
fontWeight: '700',
|
|
color: numberColor,
|
|
lineHeight: '1.1',
|
|
fontFamily: 'Inter, sans-serif',
|
|
}}>
|
|
{counter.number}{counter.suffix}
|
|
</span>
|
|
<span style={{
|
|
fontSize: '15px',
|
|
color: labelColor,
|
|
fontFamily: 'Inter, sans-serif',
|
|
fontWeight: '500',
|
|
}}>
|
|
{counter.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
NumberCounter.craft = {
|
|
displayName: 'Number Counter',
|
|
props: {
|
|
counters: defaultCounters,
|
|
columns: 4,
|
|
numberColor: tokenRef('primary'),
|
|
labelColor: tokenRef('muted'),
|
|
numberSize: '48px',
|
|
style: { backgroundColor: '#ffffff' },
|
|
animation: '',
|
|
animationDelay: '',
|
|
hideOnDesktop: false,
|
|
hideOnTablet: false,
|
|
hideOnMobile: false,
|
|
},
|
|
rules: {
|
|
canDrag: () => true,
|
|
canMoveIn: () => false,
|
|
canMoveOut: () => true,
|
|
},
|
|
};
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
|
|
const {
|
|
counters = defaultCounters,
|
|
style = {},
|
|
} = props;
|
|
// Number() coercion: `columns` is a raw string-interpolation sink into the
|
|
// grid style attribute below (repeat(${columns},1fr)); Number() of
|
|
// anything non-numeric collapses safely to NaN instead of breaking out.
|
|
const columns = Number(props.columns) || 4;
|
|
// Sanitized -- raw string-interpolation sinks in the counter/label spans
|
|
// below.
|
|
const numberColor = cssValue(props.numberColor) || tokenRef('primary');
|
|
const labelColor = cssValue(props.labelColor) || tokenRef('muted');
|
|
const numberSize = cssValue(props.numberSize) || '48px';
|
|
|
|
const items = counters.length > 0 ? counters : defaultCounters;
|
|
|
|
// Deterministic AND unique id for this counter instance's wrapper/span
|
|
// ids and getElementById() calls inside its inline script -- scoped on
|
|
// the Craft node id so two NumberCounter instances (e.g. both left at
|
|
// default props) don't collide and end up animating each other's digits.
|
|
const seed = items.map((c) => `${c.number}${c.suffix}::${c.label}`).join('|');
|
|
const uid = scopeId(nodeId, seed, 'nc');
|
|
|
|
const sectionStyle = cssPropsToString({
|
|
padding: '60px 20px',
|
|
backgroundColor: '#ffffff',
|
|
...style,
|
|
});
|
|
|
|
const countersHtml = items.map((counter, i) => {
|
|
// Number() coercion + escapeAttr: `counter.number` is declared `number`
|
|
// per-item inside an array prop, but is NOT type-checked at runtime --
|
|
// it arrives raw via the AI `update_props` path or a deserialized
|
|
// saved-state blob and was previously interpolated straight into this
|
|
// data-target attribute, letting a string like `150"><script>...`
|
|
// break out of the attribute and inject markup. Number() collapses any
|
|
// non-numeric value safely to NaN (then 0), and escapeAttr is kept as
|
|
// defense-in-depth in case Number()'s string coercion output ever
|
|
// contains a stray character (it can't today, but the sink should never
|
|
// rely solely on the coercion).
|
|
const numberVal = Number(counter.number);
|
|
const safeNumber = Number.isFinite(numberVal) ? numberVal : 0;
|
|
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
|
|
<span id="${uid}_n${i}" data-target="${escapeAttr(String(safeNumber))}" data-suffix="${escapeAttr(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${escapeHtml(counter.suffix)}</span>
|
|
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${escapeHtml(counter.label)}</span>
|
|
</div>`;
|
|
}).join('\n ');
|
|
|
|
return {
|
|
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
|
<div id="${uid}" style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:32px;text-align:center">
|
|
${countersHtml}
|
|
</div>
|
|
<script>
|
|
(function(){
|
|
var uid="${uid}",started=false;
|
|
function animate(){
|
|
if(started)return;started=true;
|
|
for(var i=0;i<${items.length};i++){
|
|
(function(el){
|
|
var target=parseInt(el.getAttribute("data-target")),
|
|
suffix=el.getAttribute("data-suffix")||"",
|
|
current=0,
|
|
step=Math.max(1,Math.floor(target/60)),
|
|
timer=setInterval(function(){
|
|
current+=step;
|
|
if(current>=target){current=target;clearInterval(timer);}
|
|
el.textContent=current+suffix;
|
|
},16);
|
|
})(document.getElementById(uid+"_n"+i));
|
|
}
|
|
}
|
|
if("IntersectionObserver"in window){
|
|
var obs=new IntersectionObserver(function(entries){
|
|
entries.forEach(function(e){if(e.isIntersecting){animate();obs.disconnect();}});
|
|
},{threshold:0.2});
|
|
obs.observe(document.getElementById(uid));
|
|
}else{animate();}
|
|
})();
|
|
</script>
|
|
</section>`,
|
|
};
|
|
};
|