- 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>
140 lines
5.5 KiB
TypeScript
140 lines
5.5 KiB
TypeScript
import React, { CSSProperties, useCallback, useRef } from 'react';
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
import { escapeAttr, safeImageUrl } from '../../utils/escape';
|
|
|
|
export const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
|
|
|
|
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 "<n>px" CSS length string, for
|
|
* emitting real `width`/`height` HTML attributes on the exported `<img>`
|
|
* (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
|
|
async function uploadToWhp(file: File): Promise<string | null> {
|
|
const cfg = (window as any).WHP_CONFIG;
|
|
if (!cfg) return URL.createObjectURL(file); // Standalone fallback
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
try {
|
|
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
|
|
method: 'POST',
|
|
headers: { 'X-CSRF-Token': cfg.csrfToken },
|
|
body: formData,
|
|
});
|
|
const data = await resp.json();
|
|
if (data.success && data.url) return data.url;
|
|
return null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
export const ImageBlock: UserComponent<ImageBlockProps> = ({
|
|
src = PLACEHOLDER_SRC,
|
|
alt = '',
|
|
style = {},
|
|
}) => {
|
|
const {
|
|
connectors: { connect, drag },
|
|
selected,
|
|
actions: { setProp },
|
|
} = useNode((node) => ({ selected: node.events.selected }));
|
|
|
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
|
const isPlaceholder = !src || src === PLACEHOLDER_SRC || src.startsWith('data:image/svg');
|
|
|
|
// Handle drag-and-drop of files directly onto the image
|
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const file = e.dataTransfer.files?.[0];
|
|
if (file && file.type.startsWith('image/')) {
|
|
const url = await uploadToWhp(file);
|
|
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
|
|
}
|
|
}, [setProp]);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
}, []);
|
|
|
|
return (
|
|
<img
|
|
ref={(ref: HTMLImageElement | null) => {
|
|
imgRef.current = ref;
|
|
if (ref) connect(drag(ref));
|
|
}}
|
|
src={src || PLACEHOLDER_SRC}
|
|
alt={alt || 'Image'}
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
style={{
|
|
display: 'block',
|
|
maxWidth: '100%',
|
|
outline: 'none',
|
|
cursor: selected ? 'move' : 'pointer',
|
|
...style,
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
ImageBlock.craft = {
|
|
displayName: 'Image',
|
|
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 },
|
|
};
|
|
|
|
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
|
|
// Skip placeholder/empty images in export
|
|
const src = props.src || '';
|
|
if (!src || src.startsWith('data:image/svg') || src === PLACEHOLDER_SRC) {
|
|
return { html: '' };
|
|
}
|
|
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
|
|
const alt = props.alt ? ` alt="${escapeAttr(props.alt)}"` : ' alt=""';
|
|
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: `<img src="${escapeAttr(safeImageUrl(src))}"${alt}${dims} loading="lazy" decoding="async"${s ? ` style="${s}"` : ''} />` };
|
|
};
|