feat(builder): media package -- image crop/perf, video size+picker, gallery cols/lightbox, box-model+anim/vis rollout
- ImageBlock/ImageStylePanel: SizeControl width(absorbs old maxWidth presets)/height, AspectRatioControl + OBJECT_FIT grid + FocalPointGrid for CSS framing crop (aspect-ratio/object-fit/object-position on the <img>). Exported <img> always gets loading="lazy" decoding="async", plus width/ height attrs when the style has a plain px length (pxAttr helper). - VideoBlock/MediaStylePanel: Video URL text input replaced with AssetPicker(mediaType="video") writing videoUrl (paste-URL still handles YouTube/Vimeo, upload/browse handle files). Added SizeControl(width) + AspectRatioControl so the video frame honors real size/aspect instead of a hardcoded 16:9 (padding-bottom hack replaced with CSS aspect-ratio). New optional `poster` prop (image AssetPicker) + preload="metadata" on file-type <video>. - Gallery: surfaced the existing-but-unexposed `columns` and `lightbox` props with panel controls. - All 5 owned components (ImageBlock, VideoBlock, Gallery, ContentSlider, MapEmbed): added margin/padding (per-side)/border/box-shadow/opacity style defaults + AnimationControl/VisibilityControl-backed animation/ animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props. New shared mediaBoxModel.tsx (package-local, not shared.tsx) DRYs the box-model + border/effects + animation/visibility panel sections across ImageStylePanel and MediaStylePanel. - Tests: extended *.toHtml.test.ts for all 5 components (crop/perf attrs, video size/aspect/poster/preload, gallery columns/lightbox, box-model style emission, craft.props presence) + new MediaStylePanel.video.test.tsx verifying the AssetPicker wiring writes videoUrl/poster and the video-only size controls are gated on videoUrl. 694 tests green, tsc + vite build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <video>', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', style: { width: '50%', aspectRatio: '4 / 3' } }, '');
|
||||
expect(html).toMatch(/<div style="[^"]*width:50%[^"]*"/);
|
||||
expect(html).toMatch(/<video[^>]*style="[^"]*aspect-ratio:4 \/ 3[^"]*"/);
|
||||
});
|
||||
|
||||
test('direct file: no aspectRatio set -- no aspect-ratio declaration is forced onto the <video>', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
|
||||
const videoTag = html.match(/<video[^>]*>/)![0];
|
||||
expect(videoTag).not.toContain('aspect-ratio');
|
||||
});
|
||||
|
||||
test('YouTube/Vimeo: style.aspectRatio overrides the 16:9 default on the iframe container', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', style: { aspectRatio: '1 / 1' } }, '');
|
||||
expect(html).toMatch(/<div[^>]*style="[^"]*aspect-ratio:1 \/ 1[^"]*"[^>]*><iframe/);
|
||||
});
|
||||
|
||||
test('YouTube/Vimeo: defaults to 16 / 9 when no aspectRatio style is set', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789' }, '');
|
||||
expect(html).toMatch(/<div[^>]*style="[^"]*aspect-ratio:16 \/ 9[^"]*"[^>]*><iframe/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.toHtml poster + preload (file type)', () => {
|
||||
test('poster attribute is emitted (escaped) and preload="metadata" is always present on a direct file', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: 'https://example.com/poster.jpg' }, '');
|
||||
expect(html).toContain('poster="https://example.com/poster.jpg"');
|
||||
expect(html).toContain('preload="metadata"');
|
||||
});
|
||||
|
||||
test('no poster prop -- no poster attribute is emitted, but preload="metadata" still is', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4' }, '');
|
||||
expect(html).not.toContain('poster=');
|
||||
expect(html).toContain('preload="metadata"');
|
||||
});
|
||||
|
||||
test('a malicious poster (javascript: scheme) is blocked by safeImageUrl', () => {
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: 'javascript:alert(1)' }, '');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('a poster value cannot break out of the poster attribute', () => {
|
||||
const malicious = 'https://example.com/x.jpg" onerror="alert(1)';
|
||||
const { html } = toHtml({ videoUrl: 'https://example.com/clip.mp4', poster: malicious }, '');
|
||||
expect(html).not.toContain('onerror="alert(1)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('VideoBlock.craft.props exposes the box-model/animation/visibility rollout', () => {
|
||||
test('poster, animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||
const props = (VideoBlock as any).craft.props;
|
||||
expect(props.poster).toBe('');
|
||||
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 + size/aspect keys', () => {
|
||||
const style = (VideoBlock as any).craft.props.style;
|
||||
expect(style).toHaveProperty('width');
|
||||
expect(style).toHaveProperty('aspectRatio');
|
||||
expect(style).toHaveProperty('marginTop');
|
||||
expect(style).toHaveProperty('paddingTop');
|
||||
expect(style.border).toBe('none');
|
||||
expect(style.boxShadow).toBe('none');
|
||||
expect(style.opacity).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user