Files
site-builder/craft/src/components/media/MapEmbed.tsx
T
shadowdaoandClaude Opus 4.8 0419291259 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>
2026-07-14 06:30:50 -07:00

123 lines
3.4 KiB
TypeScript

import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeAttr, safeUrl } from '../../utils/escape';
interface MapEmbedProps {
address?: string;
zoom?: number;
height?: string;
style?: CSSProperties;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
function buildMapUrl(address: string, zoom: number): string {
const encoded = encodeURIComponent(address);
// `zoom` is declared as `number` but is not runtime-type-checked (AI
// update_props / deserialized state can hand us anything). The final src
// string is still run through escapeAttr(safeUrl(...)) at the toHtml call
// site, which already blocks attribute-breakout -- but Number-coercing
// here too keeps the emitted URL a well-formed `z=<digits>` query param
// instead of smuggling arbitrary attacker text into it.
const z = Number(zoom);
const safeZoom = Number.isFinite(z) ? z : 14;
return `https://maps.google.com/maps?q=${encoded}&z=${safeZoom}&output=embed`;
}
export const MapEmbed: UserComponent<MapEmbedProps> = ({
address = 'New York, NY',
zoom = 14,
height = '400px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
width: '100%',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<iframe
src={buildMapUrl(address, zoom)}
style={{
width: '100%',
height,
border: 'none',
borderRadius: (style as any)?.borderRadius || '0px',
display: 'block',
}}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</div>
);
};
/* ---------- Craft config ---------- */
MapEmbed.craft = {
displayName: 'Map',
props: {
address: 'New York, NY',
zoom: 14,
height: '400px',
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,
canMoveIn: () => false,
canMoveOut: () => true,
},
};
/* ---------- HTML export ---------- */
(MapEmbed as any).toHtml = (props: MapEmbedProps, _childrenHtml: string) => {
const {
address = 'New York, NY',
zoom = 14,
height = '400px',
style = {},
} = props;
const wrapperStyle = cssPropsToString({ width: '100%', ...style });
const iframeStyle = cssPropsToString({
width: '100%',
height,
border: 'none',
borderRadius: (style as any)?.borderRadius || undefined,
display: 'block',
});
const src = buildMapUrl(address, zoom);
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(src))}" title="${escapeAttr(`Map of ${address}`)}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
};
};