- 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>
134 lines
5.1 KiB
TypeScript
134 lines
5.1 KiB
TypeScript
import React from 'react';
|
|
import { SHADOW_PRESETS } from '../../../constants/presets';
|
|
import {
|
|
SectionLabel,
|
|
PresetButtonGrid,
|
|
CollapsibleSection,
|
|
SpacingControl,
|
|
SpacingSide,
|
|
BorderControl,
|
|
BorderValue,
|
|
buildBorderShorthand,
|
|
AnimationControl,
|
|
VisibilityControl,
|
|
sectionGap,
|
|
labelStyle,
|
|
} from './shared';
|
|
|
|
/* ==========================================================================
|
|
Shared box-model / border+effects / animation+visibility sections for the
|
|
MEDIA package's panels (ImageStylePanel + MediaStylePanel). Kept local to
|
|
this package (not in shared.tsx, which is foundation/import-only) since
|
|
it's just DRY-ing the identical JSX block across ImageBlock/VideoBlock/
|
|
Gallery/ContentSlider/MapEmbed rather than a genuinely cross-package
|
|
reusable control.
|
|
========================================================================== */
|
|
|
|
function capitalize(s: string): string {
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
|
|
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
|
|
* the {width,style,color} shape BorderControl edits. Only needs to
|
|
* round-trip values this same panel produced via buildBorderShorthand --
|
|
* not arbitrary author-supplied CSS. */
|
|
export function parseBorderShorthand(v: string | undefined): BorderValue {
|
|
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
|
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
|
|
if (!m) return { width: '', style: 'none', color: '#000000' };
|
|
return { width: m[1], style: m[2], color: m[3] };
|
|
}
|
|
|
|
export interface BoxModelSectionProps {
|
|
style: Record<string, any>;
|
|
setPropStyle: (prop: string, value: string) => void;
|
|
}
|
|
|
|
/** Margin + Padding, per-side, via the shared SpacingControl. */
|
|
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle }) => {
|
|
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
|
|
setPropStyle(`${kind}${capitalize(side)}`, value);
|
|
|
|
return (
|
|
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
|
<SpacingControl
|
|
label="Margin"
|
|
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
|
onChange={sideSetter('margin')}
|
|
/>
|
|
<SpacingControl
|
|
label="Padding"
|
|
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
|
|
onChange={sideSetter('padding')}
|
|
/>
|
|
</CollapsibleSection>
|
|
);
|
|
};
|
|
|
|
/** style.opacity is stored as a CSS-length-free numeric string ("0.8") or
|
|
* may be blank/undefined (treated as fully opaque). Converts to a 0-100
|
|
* integer for the range input / label. */
|
|
function opacityPercent(v: unknown): number {
|
|
if (v === undefined || v === null || v === '') return 100;
|
|
const n = parseFloat(String(v));
|
|
return Number.isFinite(n) ? Math.round(n * 100) : 100;
|
|
}
|
|
|
|
export interface BorderEffectsSectionProps {
|
|
style: Record<string, any>;
|
|
setPropStyle: (prop: string, value: string) => void;
|
|
}
|
|
|
|
/** Border (width/style/color) + box-shadow preset + opacity slider. */
|
|
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
|
|
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
|
<BorderControl
|
|
value={parseBorderShorthand(style.border)}
|
|
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
|
/>
|
|
<div className="guided-section">
|
|
<SectionLabel>Shadow</SectionLabel>
|
|
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
|
</div>
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
value={opacityPercent(style.opacity)}
|
|
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</div>
|
|
</CollapsibleSection>
|
|
);
|
|
|
|
export interface AnimVisSectionProps {
|
|
nodeProps: Record<string, any>;
|
|
setProp: (key: string, value: any) => void;
|
|
}
|
|
|
|
/** Entrance animation + responsive hide toggles -- top-level props consumed
|
|
* directly by html-export.ts's buildDataAttrs (no toHtml change needed). */
|
|
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProp }) => (
|
|
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
|
|
<AnimationControl
|
|
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
|
|
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
|
/>
|
|
<VisibilityControl
|
|
value={{
|
|
hideOnDesktop: nodeProps.hideOnDesktop,
|
|
hideOnTablet: nodeProps.hideOnTablet,
|
|
hideOnMobile: nodeProps.hideOnMobile,
|
|
}}
|
|
onChange={(v) => {
|
|
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
|
setProp('hideOnTablet', !!v.hideOnTablet);
|
|
setProp('hideOnMobile', !!v.hideOnMobile);
|
|
}}
|
|
/>
|
|
</CollapsibleSection>
|
|
);
|