2026-04-05 18:31:16 -07:00
|
|
|
import React, { useState, useCallback, useRef, CSSProperties } from 'react';
|
|
|
|
|
import { useEditor } from '@craftjs/core';
|
|
|
|
|
import {
|
fix(image-radius): split out 3x-scale IMAGE_RADIUS_PRESETS for the image picker
Image radii need to be visibly larger than the radius scale that works for
buttons/containers — at typical photo dimensions, 16px reads as nearly
square. Add an image-specific scale at 3x the shared values (S=24px,
M=48px, L=96px) and route ImageStylePanel through it. Other components
(buttons, sections, containers) keep RADIUS_PRESETS unchanged.
Note: this commit also bundles unrelated pre-existing working-tree changes
in the legacy GrapesJS site-builder root (CLAUDE.md, index.html,
css/editor.css, js/assets.js, js/editor.js, js/whp-integration.js) that
were inadvertently picked up by an earlier `git add -u`. The image-radius
change is the only intentional content of this commit; the rest is
in-progress legacy work that happened to be sitting uncommitted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:31:58 -07:00
|
|
|
IMAGE_RADIUS_PRESETS,
|
2026-04-05 18:31:16 -07:00
|
|
|
} from '../../../constants/presets';
|
|
|
|
|
import {
|
|
|
|
|
StylePanelProps,
|
|
|
|
|
SectionLabel,
|
|
|
|
|
PresetButtonGrid,
|
|
|
|
|
TextInputField,
|
|
|
|
|
uploadToWhp,
|
|
|
|
|
} from './shared';
|
|
|
|
|
|
|
|
|
|
/* ---------- IMAGE (with upload/browse/drop) ---------- */
|
|
|
|
|
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
|
|
|
|
const { actions } = useEditor();
|
|
|
|
|
const style: CSSProperties = nodeProps.style || {};
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const [showBrowser, setShowBrowser] = useState(false);
|
|
|
|
|
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
|
|
|
|
|
const [browserLoading, setBrowserLoading] = useState(false);
|
|
|
|
|
const [imgUrl, setImgUrl] = useState(nodeProps.src || '');
|
|
|
|
|
|
|
|
|
|
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg";
|
|
|
|
|
const isPlaceholder = !nodeProps.src || nodeProps.src.startsWith('data:image/svg');
|
|
|
|
|
|
|
|
|
|
const setPropStyle = useCallback(
|
|
|
|
|
(property: string, value: string) => {
|
|
|
|
|
actions.setProp(selectedId, (props: any) => {
|
|
|
|
|
props.style = { ...props.style, [property]: value };
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[actions, selectedId],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleUpload = useCallback(async (file: File) => {
|
|
|
|
|
const url = await uploadToWhp(file);
|
|
|
|
|
if (url) {
|
|
|
|
|
actions.setProp(selectedId, (props: any) => { props.src = url; });
|
|
|
|
|
setImgUrl(url);
|
|
|
|
|
}
|
|
|
|
|
}, [actions, selectedId]);
|
|
|
|
|
|
|
|
|
|
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 maxWidthPresets = [
|
|
|
|
|
{ label: '25%', value: '25%' },
|
|
|
|
|
{ label: '50%', value: '50%' },
|
|
|
|
|
{ label: '75%', value: '75%' },
|
|
|
|
|
{ label: '100%', value: '100%' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{/* Image source with upload/browse */}
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Image Source</SectionLabel>
|
|
|
|
|
|
|
|
|
|
{!isPlaceholder && nodeProps.src ? (
|
|
|
|
|
<>
|
|
|
|
|
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
|
|
|
|
|
<img src={nodeProps.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => { actions.setProp(selectedId, (props: any) => { props.src = ''; }); setImgUrl(''); }}
|
|
|
|
|
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(nodeProps.src || '')}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Upload + Browse buttons */}
|
|
|
|
|
<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={() => {
|
|
|
|
|
actions.setProp(selectedId, (props: any) => { props.src = asset.url; });
|
|
|
|
|
setImgUrl(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 for advanced users */}
|
|
|
|
|
<div style={{ marginTop: 6 }}>
|
|
|
|
|
<div className="guided-input-row">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="guided-input"
|
|
|
|
|
value={imgUrl}
|
|
|
|
|
placeholder="Or paste image URL..."
|
|
|
|
|
onChange={(e) => setImgUrl(e.target.value)}
|
|
|
|
|
style={{ fontSize: 11 }}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
className="preset-btn apply-btn"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
actions.setProp(selectedId, (props: any) => { props.src = imgUrl; });
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Apply
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Alt Text */}
|
|
|
|
|
<TextInputField
|
|
|
|
|
label="Alt Text"
|
|
|
|
|
value={nodeProps.alt || ''}
|
|
|
|
|
placeholder="Describe the image..."
|
|
|
|
|
onChange={(v) => {
|
|
|
|
|
actions.setProp(selectedId, (props: any) => { props.alt = v; });
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* Border Radius */}
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Border Radius</SectionLabel>
|
|
|
|
|
<PresetButtonGrid
|
fix(image-radius): split out 3x-scale IMAGE_RADIUS_PRESETS for the image picker
Image radii need to be visibly larger than the radius scale that works for
buttons/containers — at typical photo dimensions, 16px reads as nearly
square. Add an image-specific scale at 3x the shared values (S=24px,
M=48px, L=96px) and route ImageStylePanel through it. Other components
(buttons, sections, containers) keep RADIUS_PRESETS unchanged.
Note: this commit also bundles unrelated pre-existing working-tree changes
in the legacy GrapesJS site-builder root (CLAUDE.md, index.html,
css/editor.css, js/assets.js, js/editor.js, js/whp-integration.js) that
were inadvertently picked up by an earlier `git add -u`. The image-radius
change is the only intentional content of this commit; the rest is
in-progress legacy work that happened to be sitting uncommitted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 21:31:58 -07:00
|
|
|
presets={IMAGE_RADIUS_PRESETS}
|
2026-04-05 18:31:16 -07:00
|
|
|
activeValue={style.borderRadius as string}
|
|
|
|
|
onSelect={(v) => setPropStyle('borderRadius', v)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Max Width */}
|
|
|
|
|
<div className="guided-section">
|
|
|
|
|
<SectionLabel>Max Width</SectionLabel>
|
|
|
|
|
<PresetButtonGrid
|
|
|
|
|
presets={maxWidthPresets}
|
|
|
|
|
activeValue={style.maxWidth as string}
|
|
|
|
|
onSelect={(v) => setPropStyle('maxWidth', v)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|