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:
2026-07-14 06:30:50 -07:00
parent 1d9460c173
commit 0419291259
14 changed files with 851 additions and 44 deletions
+36 -11
View File
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
import { escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape';
/* ---------- Types ---------- */
@@ -12,6 +12,7 @@ interface VideoBlockProps {
videoUrl?: string;
videoType?: VideoType;
embedUrl?: string;
poster?: string;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
@@ -22,6 +23,11 @@ interface VideoBlockProps {
innerMaxWidth?: string;
style?: CSSProperties;
children?: React.ReactNode;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
/* ---------- URL detection ---------- */
@@ -120,6 +126,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
videoUrl = '',
videoType: _videoTypeProp,
embedUrl: _embedUrlProp,
poster = '',
autoplay = false,
muted = true,
loop = false,
@@ -259,8 +266,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
<div
style={{
position: 'relative',
paddingBottom: '56.25%',
height: 0,
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
overflow: 'hidden',
borderRadius: (style as any)?.borderRadius || undefined,
}}
@@ -269,8 +275,7 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
style={{
position: 'absolute',
top: 0,
left: 0,
inset: 0,
width: '100%',
height: '100%',
border: 'none',
@@ -284,14 +289,18 @@ export const VideoBlock: UserComponent<VideoBlockProps> = ({
{type === 'file' && (
<video
src={embedUrl}
poster={poster || undefined}
autoPlay={autoplay}
muted={muted}
loop={loop}
controls={controls}
preload="metadata"
playsInline
style={{
display: 'block',
width: '100%',
aspectRatio: (style as any)?.aspectRatio || undefined,
objectFit: 'cover',
borderRadius: (style as any)?.borderRadius || undefined,
}}
/>
@@ -310,6 +319,7 @@ VideoBlock.craft = {
videoUrl: '',
videoType: 'none',
embedUrl: '',
poster: '',
autoplay: false,
muted: true,
loop: false,
@@ -318,7 +328,20 @@ VideoBlock.craft = {
overlayColor: '#000000',
overlayOpacity: 50,
innerMaxWidth: '1200px',
style: {},
style: {
width: '',
aspectRatio: '',
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,
@@ -334,6 +357,7 @@ VideoBlock.craft = {
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
const {
videoUrl = '',
poster = '',
autoplay = false,
muted = true,
loop: doLoop = false,
@@ -422,15 +446,13 @@ VideoBlock.craft = {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
const containerStyle = cssPropsToString({
position: 'relative',
paddingBottom: '56.25%',
height: '0',
aspectRatio: (style as any)?.aspectRatio || '16 / 9',
overflow: 'hidden',
borderRadius: (style as any)?.borderRadius || undefined,
});
const iframeStyle = cssPropsToString({
position: 'absolute',
top: '0',
left: '0',
inset: '0',
width: '100%',
height: '100%',
border: 'none',
@@ -447,13 +469,16 @@ VideoBlock.craft = {
if (doLoop) vidAttrs.push('loop');
if (controls) vidAttrs.push('controls');
vidAttrs.push('playsinline');
const posterAttr = poster ? ` poster="${escapeAttr(safeImageUrl(poster))}"` : '';
const vidStyle = cssPropsToString({
display: 'block',
width: '100%',
aspectRatio: (style as any)?.aspectRatio || undefined,
objectFit: 'cover',
borderRadius: (style as any)?.borderRadius || undefined,
});
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${escapeAttr(safeUrl(embedUrl))}"${posterAttr} preload="metadata" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
};
};