Every user-controlled URL emitted by a component's static toHtml (href, src, action, and CSS url()) now runs through escapeAttr(safeUrl(...)) before hitting the exported HTML string, closing the XSS gaps flagged in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/ HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a few more found via a grep sweep of href=/src=/action=/url( inside toHtml template strings: MapEmbed's iframe src and the shared form-relay-wiring fallback form action (a javascript: form action executes on submit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
482 lines
20 KiB
TypeScript
482 lines
20 KiB
TypeScript
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
import { escapeAttr, safeUrl } from '../../utils/escape';
|
|
|
|
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;
|
|
}
|
|
|
|
// 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}
|
|
alt={alt || 'Image'}
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
style={{
|
|
display: 'block',
|
|
maxWidth: '100%',
|
|
outline: 'none',
|
|
cursor: selected ? 'move' : 'pointer',
|
|
...style,
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
/* ---------- Helpers for parsing CSS unit values ---------- */
|
|
|
|
type SizeUnit = 'px' | '%' | 'auto';
|
|
|
|
function parseSizeValue(value: string | number | undefined): { num: string; unit: SizeUnit } {
|
|
if (!value || value === 'auto') return { num: '', unit: 'auto' };
|
|
const str = String(value);
|
|
if (str === 'auto') return { num: '', unit: 'auto' };
|
|
const match = str.match(/^(\d+(?:\.\d+)?)\s*(px|%)$/);
|
|
if (match) return { num: match[1], unit: match[2] as SizeUnit };
|
|
// Pure number = px
|
|
if (/^\d+(?:\.\d+)?$/.test(str)) return { num: str, unit: 'px' };
|
|
return { num: '', unit: 'px' };
|
|
}
|
|
|
|
function buildSizeString(num: string, unit: SizeUnit): string | undefined {
|
|
if (unit === 'auto') return 'auto';
|
|
if (!num) return undefined;
|
|
return `${num}${unit}`;
|
|
}
|
|
|
|
type Alignment = 'left' | 'center' | 'right';
|
|
|
|
function detectAlignment(style: CSSProperties | undefined): Alignment {
|
|
if (!style) return 'left';
|
|
const ml = style.marginLeft;
|
|
const mr = style.marginRight;
|
|
if (ml === 'auto' && mr === 'auto') return 'center';
|
|
if (ml === 'auto' && mr !== 'auto') return 'right';
|
|
return 'left';
|
|
}
|
|
|
|
/* ---------- Settings panel ---------- */
|
|
|
|
const ImageBlockSettings: React.FC = () => {
|
|
const { actions: { setProp }, props } = useNode((node) => ({
|
|
props: node.data.props as ImageBlockProps,
|
|
}));
|
|
|
|
const isPlaceholder = !props.src || props.src === PLACEHOLDER_SRC || props.src?.startsWith('data:image/svg');
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [showBrowser, setShowBrowser] = useState(false);
|
|
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
|
const [browserLoading, setBrowserLoading] = useState(false);
|
|
|
|
// Sizing unit state
|
|
const widthParsed = parseSizeValue(props.style?.width);
|
|
const [widthUnit, setWidthUnit] = useState<SizeUnit>(widthParsed.unit === 'auto' ? 'px' : widthParsed.unit);
|
|
const heightParsed = parseSizeValue(props.style?.height);
|
|
const [heightUnit, setHeightUnit] = useState<SizeUnit>(heightParsed.unit === 'auto' ? 'px' : heightParsed.unit);
|
|
const maxWidthParsed = parseSizeValue(props.style?.maxWidth);
|
|
const [maxWidthUnit, setMaxWidthUnit] = useState<SizeUnit>(maxWidthParsed.unit === 'auto' ? '%' : maxWidthParsed.unit);
|
|
|
|
const alignment = detectAlignment(props.style);
|
|
|
|
const handleUpload = useCallback(async (file: File) => {
|
|
const url = await uploadToWhp(file);
|
|
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
|
|
}, [setProp]);
|
|
|
|
const handleBrowse = useCallback(async () => {
|
|
if (showBrowser) { setShowBrowser(false); return; }
|
|
const cfg = (window as any).WHP_CONFIG;
|
|
if (!cfg) return;
|
|
setBrowserLoading(true);
|
|
try {
|
|
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
|
|
const data = await resp.json();
|
|
if (data.success && Array.isArray(data.assets)) {
|
|
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
|
|
setBrowserAssets(images);
|
|
setShowBrowser(true);
|
|
}
|
|
} catch (e) {
|
|
console.error('Browse failed:', e);
|
|
} finally {
|
|
setBrowserLoading(false);
|
|
}
|
|
}, [showBrowser]);
|
|
|
|
const radiusPresets = ['0', '8px', '16px', '32px', '50%'];
|
|
|
|
const setPropStyle = useCallback((key: string, value: string | undefined) => {
|
|
setProp((p: ImageBlockProps) => {
|
|
p.style = { ...p.style, [key]: value };
|
|
});
|
|
}, [setProp]);
|
|
|
|
const setAlignment = useCallback((align: Alignment) => {
|
|
setProp((p: ImageBlockProps) => {
|
|
const s = { ...p.style };
|
|
if (align === 'center') {
|
|
s.marginLeft = 'auto';
|
|
s.marginRight = 'auto';
|
|
s.display = 'block';
|
|
} else if (align === 'right') {
|
|
s.marginLeft = 'auto';
|
|
s.marginRight = undefined;
|
|
s.display = 'block';
|
|
} else {
|
|
s.marginLeft = undefined;
|
|
s.marginRight = undefined;
|
|
s.display = 'block';
|
|
}
|
|
p.style = s;
|
|
});
|
|
}, [setProp]);
|
|
|
|
// Extract friendly filename from URL
|
|
const getFriendlyName = (src: string) => {
|
|
const match = src.match(/filename=([^&]+)/);
|
|
if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, '');
|
|
return src.split('/').pop() || 'image';
|
|
};
|
|
|
|
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
|
|
const inputStyle: CSSProperties = { flex: 1, minWidth: 0, padding: '4px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 };
|
|
const selectStyle: CSSProperties = { padding: '4px 2px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, cursor: 'pointer' };
|
|
const btnStyle = (active: boolean): CSSProperties => ({
|
|
flex: 1, padding: '4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
|
border: '1px solid #3f3f46',
|
|
background: active ? '#3b82f6' : '#27272a',
|
|
color: active ? '#fff' : '#a1a1aa',
|
|
});
|
|
|
|
return (
|
|
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{/* Image preview */}
|
|
<div>
|
|
<label style={labelStyle}>Image Source</label>
|
|
|
|
{!isPlaceholder ? (
|
|
<>
|
|
{/* Current image thumbnail + filename + remove */}
|
|
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
|
<img src={props.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
|
|
<button onClick={() => setProp((p: ImageBlockProps) => { p.src = PLACEHOLDER_SRC; })}
|
|
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
title="Remove image">
|
|
<i className="fa fa-times" />
|
|
</button>
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
|
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(props.src || '')}</span>
|
|
</div>
|
|
</>
|
|
) : (
|
|
/* Drop zone when no image set */
|
|
<div
|
|
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
|
|
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
|
|
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
|
|
onDrop={async (e) => {
|
|
e.preventDefault();
|
|
e.currentTarget.style.borderColor = '#3f3f46';
|
|
const file = e.dataTransfer.files?.[0];
|
|
if (file && file.type.startsWith('image/')) await handleUpload(file);
|
|
}}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
|
|
Drop image here or click to upload
|
|
</div>
|
|
)}
|
|
|
|
{/* Action buttons: Upload + Browse */}
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
|
|
>
|
|
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
|
|
</button>
|
|
<button
|
|
onClick={handleBrowse}
|
|
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
|
|
>
|
|
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
|
|
</button>
|
|
</div>
|
|
|
|
{/* Inline asset browser grid */}
|
|
{showBrowser && (
|
|
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
|
|
{browserAssets.map(asset => (
|
|
<div
|
|
key={asset.name}
|
|
onClick={() => { setProp((p: ImageBlockProps) => { p.src = asset.url; }); setShowBrowser(false); }}
|
|
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }}
|
|
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
|
|
>
|
|
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
|
|
</div>
|
|
))}
|
|
{browserAssets.length === 0 && (
|
|
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
|
|
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
|
|
|
|
{/* URL input (collapsed, for advanced users) */}
|
|
<div style={{ marginTop: 6 }}>
|
|
<input type="text"
|
|
value={isPlaceholder ? '' : (props.src || '')}
|
|
onChange={(e) => setProp((p: ImageBlockProps) => { p.src = e.target.value || PLACEHOLDER_SRC; })}
|
|
placeholder="Or paste image URL..."
|
|
style={{ width: '100%', padding: '4px 8px', background: '#1e1e2a', color: '#71717a', border: '1px solid #27272a', borderRadius: 4, fontSize: 10 }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Alt Text */}
|
|
<div>
|
|
<label style={labelStyle}>Alt Text</label>
|
|
<input
|
|
type="text"
|
|
value={props.alt || ''}
|
|
onChange={(e) => setProp((p: ImageBlockProps) => { p.alt = e.target.value; })}
|
|
placeholder="Describe the image..."
|
|
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Width */}
|
|
<div>
|
|
<label style={labelStyle}>Width</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
value={widthParsed.num}
|
|
disabled={props.style?.width === 'auto'}
|
|
onChange={(e) => {
|
|
const val = buildSizeString(e.target.value, widthUnit);
|
|
setPropStyle('width', val || 'auto');
|
|
}}
|
|
placeholder="auto"
|
|
style={inputStyle}
|
|
/>
|
|
<select
|
|
value={props.style?.width === 'auto' ? 'auto' : widthUnit}
|
|
onChange={(e) => {
|
|
const unit = e.target.value as SizeUnit;
|
|
if (unit === 'auto') {
|
|
setPropStyle('width', 'auto');
|
|
} else {
|
|
setWidthUnit(unit);
|
|
const num = widthParsed.num || '100';
|
|
setPropStyle('width', `${num}${unit}`);
|
|
}
|
|
}}
|
|
style={selectStyle}
|
|
>
|
|
<option value="px">px</option>
|
|
<option value="%">%</option>
|
|
<option value="auto">auto</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Max Width */}
|
|
<div>
|
|
<label style={labelStyle}>Max Width</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
value={maxWidthParsed.num}
|
|
onChange={(e) => {
|
|
const val = buildSizeString(e.target.value, maxWidthUnit);
|
|
setPropStyle('maxWidth', val || '100%');
|
|
}}
|
|
placeholder="100%"
|
|
style={inputStyle}
|
|
/>
|
|
<select
|
|
value={maxWidthUnit}
|
|
onChange={(e) => {
|
|
const unit = e.target.value as SizeUnit;
|
|
setMaxWidthUnit(unit);
|
|
const num = maxWidthParsed.num || '100';
|
|
setPropStyle('maxWidth', `${num}${unit}`);
|
|
}}
|
|
style={selectStyle}
|
|
>
|
|
<option value="px">px</option>
|
|
<option value="%">%</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Height */}
|
|
<div>
|
|
<label style={labelStyle}>Height</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
value={heightParsed.num}
|
|
disabled={props.style?.height === 'auto'}
|
|
onChange={(e) => {
|
|
const val = buildSizeString(e.target.value, heightUnit);
|
|
setPropStyle('height', val || 'auto');
|
|
}}
|
|
placeholder="auto"
|
|
style={inputStyle}
|
|
/>
|
|
<select
|
|
value={props.style?.height === 'auto' ? 'auto' : heightUnit}
|
|
onChange={(e) => {
|
|
const unit = e.target.value as SizeUnit;
|
|
if (unit === 'auto') {
|
|
setPropStyle('height', 'auto');
|
|
} else {
|
|
setHeightUnit(unit);
|
|
const num = heightParsed.num || '300';
|
|
setPropStyle('height', `${num}${unit}`);
|
|
}
|
|
}}
|
|
style={selectStyle}
|
|
>
|
|
<option value="px">px</option>
|
|
<option value="auto">auto</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Object Fit (visible when both width and height are explicit values) */}
|
|
{props.style?.width && props.style.width !== 'auto' && props.style?.height && props.style.height !== 'auto' && (
|
|
<div>
|
|
<label style={labelStyle}>Object Fit</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{(['cover', 'contain', 'fill', 'none'] as const).map((fit) => (
|
|
<button
|
|
key={fit}
|
|
onClick={() => setPropStyle('objectFit', fit)}
|
|
style={btnStyle(props.style?.objectFit === fit)}
|
|
>
|
|
{fit}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Alignment */}
|
|
<div>
|
|
<label style={labelStyle}>Alignment</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button onClick={() => setAlignment('left')} style={btnStyle(alignment === 'left')}>
|
|
<i className="fa fa-align-left" style={{ marginRight: 3 }} />Left
|
|
</button>
|
|
<button onClick={() => setAlignment('center')} style={btnStyle(alignment === 'center')}>
|
|
<i className="fa fa-align-center" style={{ marginRight: 3 }} />Center
|
|
</button>
|
|
<button onClick={() => setAlignment('right')} style={btnStyle(alignment === 'right')}>
|
|
<i className="fa fa-align-right" style={{ marginRight: 3 }} />Right
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Border Radius */}
|
|
<div>
|
|
<label style={labelStyle}>Border Radius</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{radiusPresets.map((r) => (
|
|
<button key={r} onClick={() => setPropStyle('borderRadius', r)}
|
|
style={btnStyle(props.style?.borderRadius === r)}
|
|
>{r}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
ImageBlock.craft = {
|
|
displayName: 'Image',
|
|
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
|
|
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
|
|
related: { settings: ImageBlockSettings },
|
|
};
|
|
|
|
(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=""';
|
|
return { html: `<img src="${escapeAttr(safeUrl(src))}"${alt}${s ? ` style="${s}"` : ''} />` };
|
|
};
|