import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeHtml, escapeAttr, safeImageUrl, scopeId, cssValue } from '../../utils/escape'; interface GalleryImage { src: string; alt: string; caption?: string; } interface GalleryProps { images?: GalleryImage[]; columns?: number; gap?: string; style?: CSSProperties; lightbox?: boolean; animation?: string; animationDelay?: string; hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; } const placeholderSvg = (index: number) => { const colors = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#ec4899']; const color = colors[index % colors.length]; return `data:image/svg+xml,${encodeURIComponent(`${index + 1}`)}`; }; const defaultImages: GalleryImage[] = [ { src: placeholderSvg(0), alt: 'Gallery image 1', caption: 'First image' }, { src: placeholderSvg(1), alt: 'Gallery image 2', caption: 'Second image' }, { src: placeholderSvg(2), alt: 'Gallery image 3', caption: 'Third image' }, { src: placeholderSvg(3), alt: 'Gallery image 4', caption: 'Fourth image' }, { src: placeholderSvg(4), alt: 'Gallery image 5', caption: 'Fifth image' }, { src: placeholderSvg(5), alt: 'Gallery image 6', caption: 'Sixth image' }, ]; export const Gallery: UserComponent = ({ images = defaultImages, columns = 3, gap = '16px', style = {}, lightbox = false, }) => { const { connectors: { connect, drag }, selected, } = useNode((node) => ({ selected: node.events.selected, })); return (
{ if (ref) connect(drag(ref)); }} style={{ padding: '60px 20px', backgroundColor: '#ffffff', outline: selected ? '2px solid #3b82f6' : 'none', ...style, }} >
{images.map((img, i) => (
{img.alt} {img.caption && (
{img.caption}
)}
))}
); }; /* ---------- Craft config ---------- */ Gallery.craft = { displayName: 'Gallery', props: { images: defaultImages, columns: 3, gap: '16px', style: { backgroundColor: '#ffffff', marginTop: '', marginRight: '', marginBottom: '', marginLeft: '', paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '', border: 'none', boxShadow: 'none', opacity: '1', }, lightbox: false, animation: '', animationDelay: '0', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false, }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true, }, }; /* ---------- HTML export ---------- */ (Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string, nodeId?: string) => { const sectionStyle = cssPropsToString({ padding: '60px 20px', ...props.style, }); const images = props.images || defaultImages; // Number() coercion: `columns` is a raw string-interpolation sink into the // grid style attribute below (repeat(${columns},1fr)) -- a non-numeric // (e.g. hand-crafted/AI-generated tree) value would otherwise be able to // break out; Number() of anything non-numeric collapses safely to NaN. const columns = Number(props.columns) || 3; // Sanitized -- gap is a raw string-interpolation sink into the grid style // attribute below. const gap = cssValue(props.gap) || '16px'; const lightbox = props.lightbox || false; // Deterministic AND unique id, scoped on the Craft node id, for this // gallery's overlay/grid element ids and inline-script function names -- // so two Gallery instances (e.g. both left at default images) don't // collide and end up sharing/clobbering one lightbox overlay. const galleryId = scopeId(nodeId, JSON.stringify(images) + columns + gap, 'gallery'); const items = images.map((img) => { const caption = img.caption ? `
${escapeHtml(img.caption)}
` : ''; // Lightbox items carry the image URL as a data attribute rather than an // inline onclick with an interpolated src -- a single delegated click // listener below reads it, so a src containing a quote can't break out // of a per-item event-handler string. const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeImageUrl(img.src || ''))}" role="button" tabindex="0"` : ''; const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px'; return ` ${escapeAttr(img.alt)} ${caption} `; }).join('\n '); let lightboxHtml = ''; let gridIdAttr = ''; if (lightbox) { gridIdAttr = ` id="${galleryId}_grid"`; // M-2: focus management for the lightbox dialog. // - OPEN: stash `document.activeElement` (the thumbnail that triggered // the open) in a module-scoped var, then move focus onto the close // button -- so a screen-reader/keyboard user lands inside the dialog // instead of focus staying on (or silently falling back to ) // behind the now-visible overlay. // - Tab trap: while the overlay is open, every Tab keypress is // intercepted and refocuses the close button (the dialog's only // focusable control besides Escape/click-to-close), so focus can // never wander out into the page content hidden behind the overlay. // - CLOSE (Escape, backdrop click, or the close button): restore focus // to the element stashed on open. lightboxHtml = ` `; } return { html: ` ${items} ${lightboxHtml} `, }; };