diff --git a/craft/src/components/media/ImageBlock.toHtml.test.ts b/craft/src/components/media/ImageBlock.toHtml.test.ts index 8729db2..035e819 100644 --- a/craft/src/components/media/ImageBlock.toHtml.test.ts +++ b/craft/src/components/media/ImageBlock.toHtml.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest'; -import { ImageBlock } from './ImageBlock'; +import { ImageBlock, pxAttr } from './ImageBlock'; const toHtml = (ImageBlock as any).toHtml; @@ -32,3 +32,95 @@ describe('ImageBlock.toHtml src/alt XSS hardening', () => { expect(html).toContain('alt="A photo"'); }); }); + +describe('ImageBlock.toHtml perf attributes (always emitted)', () => { + test('loading="lazy" and decoding="async" are always present', () => { + const { html } = toHtml({ src: 'https://example.com/photo.jpg' }, ''); + expect(html).toContain('loading="lazy"'); + expect(html).toContain('decoding="async"'); + }); + + test('width/height attributes are emitted when the style has plain px values', () => { + const { html } = toHtml({ src: 'https://example.com/photo.jpg', style: { width: '400px', height: '300px' } }, ''); + expect(html).toContain('width="400"'); + expect(html).toContain('height="300"'); + }); + + test('width/height attributes are omitted when the style value is not a plain px length', () => { + const { html } = toHtml({ src: 'https://example.com/photo.jpg', style: { width: '50%', height: 'auto' } }, ''); + expect(html).not.toMatch(/\swidth="/); + expect(html).not.toMatch(/\sheight="/); + }); +}); + +describe('pxAttr', () => { + test('extracts the numeric portion of a plain px length', () => { + expect(pxAttr('400px')).toBe('400'); + expect(pxAttr('12.5px')).toBe('12.5'); + }); + + test('returns undefined for non-px units, non-string, or unset values', () => { + expect(pxAttr('50%')).toBeUndefined(); + expect(pxAttr('auto')).toBeUndefined(); + expect(pxAttr(undefined)).toBeUndefined(); + expect(pxAttr(400)).toBeUndefined(); + }); +}); + +describe('ImageBlock.toHtml CSS framing crop (aspect-ratio + object-fit + object-position)', () => { + test('style.aspectRatio, objectFit, objectPosition all flow into the emitted style attribute', () => { + const { html } = toHtml( + { src: 'https://example.com/photo.jpg', style: { aspectRatio: '16 / 9', objectFit: 'cover', objectPosition: 'center top' } }, + '' + ); + expect(html).toContain('aspect-ratio:16 / 9'); + expect(html).toContain('object-fit:cover'); + expect(html).toContain('object-position:center top'); + }); +}); + +describe('ImageBlock.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => { + test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => { + const { html } = toHtml( + { + src: 'https://example.com/photo.jpg', + style: { + marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px', + paddingTop: '5px', + border: '2px solid #ff0000', + boxShadow: '0 4px 8px rgba(0,0,0,0.12)', + opacity: '0.8', + }, + }, + '' + ); + expect(html).toContain('margin-top:10px'); + expect(html).toContain('padding-top:5px'); + expect(html).toContain('border:2px solid #ff0000'); + expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)'); + expect(html).toContain('opacity:0.8'); + }); +}); + +describe('ImageBlock.craft.props exposes the box-model/animation/visibility rollout', () => { + test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => { + const props = (ImageBlock as any).craft.props; + expect(props.animation).toBe(''); + expect(props.animationDelay).toBe('0'); + expect(props.hideOnDesktop).toBe(false); + expect(props.hideOnTablet).toBe(false); + expect(props.hideOnMobile).toBe(false); + }); + + test('style carries blank/default box-model and crop keys', () => { + const style = (ImageBlock as any).craft.props.style; + expect(style).toHaveProperty('aspectRatio'); + expect(style).toHaveProperty('objectFit'); + expect(style).toHaveProperty('objectPosition'); + expect(style).toHaveProperty('marginTop'); + expect(style).toHaveProperty('paddingTop'); + expect(style.border).toBe('none'); + expect(style.boxShadow).toBe('none'); + expect(style.opacity).toBe('1'); + }); +}); diff --git a/craft/src/components/media/ImageBlock.tsx b/craft/src/components/media/ImageBlock.tsx index 1320790..7a0115c 100644 --- a/craft/src/components/media/ImageBlock.tsx +++ b/craft/src/components/media/ImageBlock.tsx @@ -9,6 +9,23 @@ interface ImageBlockProps { src?: string; alt?: string; style?: CSSProperties; + animation?: string; + animationDelay?: string; + hideOnDesktop?: boolean; + hideOnTablet?: boolean; + hideOnMobile?: boolean; +} + +/** Extracts the numeric portion of a plain "px" CSS length string, for + * emitting real `width`/`height` HTML attributes on the exported `` + * (helps the browser reserve layout space before the image loads -- + * avoiding CLS -- something a CSS-only width/height can't do on its own). + * Returns undefined for any other unit ('%', 'auto', '', etc.) so the + * attribute is simply omitted when the pixel size isn't known. */ +export function pxAttr(v: unknown): string | undefined { + if (typeof v !== 'string') return undefined; + const m = v.trim().match(/^(\d+(?:\.\d+)?)px$/); + return m ? m[1] : undefined; } // Helper: upload a file to the WHP API and return the proxy URL @@ -83,7 +100,27 @@ export const ImageBlock: UserComponent = ({ ImageBlock.craft = { displayName: 'Image', - props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } }, + props: { + src: PLACEHOLDER_SRC, + alt: '', + style: { + width: '100%', + height: 'auto', + aspectRatio: '', + objectFit: '' as CSSProperties['objectFit'], + objectPosition: '', + marginTop: '', marginRight: '', marginBottom: '', marginLeft: '', + paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '', + border: 'none', + boxShadow: 'none', + opacity: '1', + }, + animation: '', + animationDelay: '0', + hideOnDesktop: false, + hideOnTablet: false, + hideOnMobile: false, + }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true }, }; @@ -95,5 +132,8 @@ ImageBlock.craft = { } const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style }); const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""'; - return { html: `` }; + const widthAttr = pxAttr((props.style as any)?.width); + const heightAttr = pxAttr((props.style as any)?.height); + const dims = `${widthAttr ? ` width="${widthAttr}"` : ''}${heightAttr ? ` height="${heightAttr}"` : ''}`; + return { html: `` }; }; diff --git a/craft/src/components/media/MapEmbed.toHtml.test.ts b/craft/src/components/media/MapEmbed.toHtml.test.ts index 33482d7..5743b2c 100644 --- a/craft/src/components/media/MapEmbed.toHtml.test.ts +++ b/craft/src/components/media/MapEmbed.toHtml.test.ts @@ -28,6 +28,49 @@ describe('MapEmbed.toHtml iframe src ampersand encoding (F-export review Minor)' }); }); +describe('MapEmbed.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => { + test('margin/padding/border/box-shadow/opacity all flow into the wrapper style attribute', () => { + const { html } = toHtml( + { + address: 'New York, NY', + style: { + marginTop: '16px', + paddingRight: '4px', + border: '1px solid #cccccc', + boxShadow: '0 4px 8px rgba(0,0,0,0.12)', + opacity: '0.95', + }, + }, + '' + ); + expect(html).toContain('margin-top:16px'); + expect(html).toContain('padding-right:4px'); + expect(html).toContain('border:1px solid #cccccc'); + expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)'); + expect(html).toContain('opacity:0.95'); + }); +}); + +describe('MapEmbed.craft.props exposes the animation/visibility rollout', () => { + test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => { + const props = (MapEmbed as any).craft.props; + expect(props.animation).toBe(''); + expect(props.animationDelay).toBe('0'); + expect(props.hideOnDesktop).toBe(false); + expect(props.hideOnTablet).toBe(false); + expect(props.hideOnMobile).toBe(false); + }); + + test('style carries blank/default box-model keys', () => { + const style = (MapEmbed as any).craft.props.style; + expect(style).toHaveProperty('marginTop'); + expect(style).toHaveProperty('paddingTop'); + expect(style.border).toBe('none'); + expect(style.boxShadow).toBe('none'); + expect(style.opacity).toBe('1'); + }); +}); + describe('MapEmbed.toHtml address/zoom/height XSS hardening', () => { test('a malicious address cannot break out of the src or title attribute', () => { const malicious = 'X" onerror="alert(1)'; diff --git a/craft/src/components/media/MapEmbed.tsx b/craft/src/components/media/MapEmbed.tsx index 22d6e96..763982a 100644 --- a/craft/src/components/media/MapEmbed.tsx +++ b/craft/src/components/media/MapEmbed.tsx @@ -8,6 +8,11 @@ interface MapEmbedProps { zoom?: number; height?: string; style?: CSSProperties; + animation?: string; + animationDelay?: string; + hideOnDesktop?: boolean; + hideOnTablet?: boolean; + hideOnMobile?: boolean; } function buildMapUrl(address: string, zoom: number): string { @@ -70,7 +75,18 @@ MapEmbed.craft = { address: 'New York, NY', zoom: 14, height: '400px', - style: {}, + style: { + marginTop: '', marginRight: '', marginBottom: '', marginLeft: '', + paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '', + border: 'none', + boxShadow: 'none', + opacity: '1', + }, + animation: '', + animationDelay: '0', + hideOnDesktop: false, + hideOnTablet: false, + hideOnMobile: false, }, rules: { canDrag: () => true, diff --git a/craft/src/components/media/VideoBlock.toHtml.test.ts b/craft/src/components/media/VideoBlock.toHtml.test.ts index 37fb3f8..e8ecb5b 100644 --- a/craft/src/components/media/VideoBlock.toHtml.test.ts +++ b/craft/src/components/media/VideoBlock.toHtml.test.ts @@ -118,3 +118,75 @@ describe('VideoBlock.toHtml iframe src ampersand encoding (F-export review Minor expect(srcMatch![1]).not.toMatch(/&(?!amp;)/); }); }); + +describe('VideoBlock.toHtml size + aspect ratio (frame honors style props, not a hardcoded 16:9)', () => { + test('direct file: style.width flows to the wrapper, style.aspectRatio flows to the