88df4f2888
Replaces the plain single-line input (HtmlBlock's `code` prop, via
GenericPropsEditor) and the plain <textarea> (HeadCodeModal's site-wide
head code) with a proper syntax-highlighted, tab-completing code editor.
- New src/ui/CodeEditor.tsx: reusable CodeMirror 6 editor (state/view/
commands/autocomplete/language/lang-html/lang-css/lang-javascript/
theme-one-dark). All @codemirror/* packages are pulled in via a single
dynamic import() inside the component so they land in separate lazy
chunks instead of the main bundle -- confirmed via `npm run build`:
main editor.js grew by only ~5.5KB (657KB -> 663KB raw) while ~570KB of
CodeMirror source split into index*.js chunks that only load when a
code editor modal is actually opened. While that import is in flight,
or if it ever fails, the component renders a plain <textarea> so typing
never breaks.
- GenericPropsEditor.tsx: special-cases the `code` prop (used only by
HtmlBlock today) into an "Edit HTML" button that opens the CodeEditor
in a modal (language="html"), instead of rendering it as a single-line
text input alongside the component's other string props.
- HeadCodeModal.tsx: swaps its <textarea> for CodeEditor (language="html"
-- head code is HTML with embedded <script>/<style>), keeping the
existing SiteDesignContext.updateDesign({ headCode }) wiring.
GuidedStyles.tsx untouched: HtmlBlock's displayName ("HTML") already
matches GuidedStyles' `isUtility` regex and routes to GenericPropsEditor,
so no dispatcher change was needed.
HtmlBlock.tsx untouched: purifyHtml/toHtml sanitization is unchanged, as
specified. Skipped the optional AnimationControl/VisibilityControl
addition -- HtmlBlock has no dedicated StylePanel (it shares
GenericPropsEditor with Divider/Spacer/every unmatched type), and doing
it well would mean adding real Animation/Visibility widgets to that
shared editor for every consumer, which is a bigger change than "trivial"
for this package's scope.
Tests: CodeEditor.test.tsx and HeadCodeModal.test.tsx both exploit the
fact that dynamic import() always resolves on a later microtask, so
asserting against the DOM immediately after the initial synchronous
render deterministically exercises the <textarea> fallback path (value
display, onChange wiring, language prop plumbing) without needing to
mock @codemirror/*.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
213 lines
8.8 KiB
TypeScript
213 lines
8.8 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
TEXT_COLORS,
|
|
BG_COLORS,
|
|
SPACING_PRESETS,
|
|
RADIUS_PRESETS,
|
|
} from '../../../constants/presets';
|
|
import {
|
|
SectionLabel,
|
|
ColorSwatchGrid,
|
|
PresetButtonGrid,
|
|
CollapsibleSection,
|
|
ColorPickerField,
|
|
labelStyle,
|
|
inputStyle,
|
|
sectionGap,
|
|
useNodeProp,
|
|
} from './shared';
|
|
import { ArrayItemFieldsEditor } from './ArrayItemFields';
|
|
import { Modal } from '../../../ui/Modal';
|
|
import { CodeEditor } from '../../../ui/CodeEditor';
|
|
|
|
/* ---------- "Edit HTML" modal for the HtmlBlock `code` prop ----------
|
|
`code` is raw HTML (potentially many lines, embedded <style>/<script>),
|
|
so it gets a dedicated syntax-highlighted CodeEditor in a modal instead
|
|
of falling into the generic single-line/textarea string-prop rendering
|
|
below (see GenericPropsEditor's SKIP of the `code` key). */
|
|
const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<CollapsibleSection title="HTML Code">
|
|
<div style={sectionGap}>
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
style={{
|
|
width: '100%', padding: '8px 12px', fontSize: 12, fontWeight: 600,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46',
|
|
borderRadius: 6, cursor: 'pointer',
|
|
}}
|
|
>
|
|
<i className="fa fa-code" /> Edit HTML
|
|
</button>
|
|
</div>
|
|
<Modal open={open} onClose={() => setOpen(false)} width="min(720px, 90vw)">
|
|
<div
|
|
style={{
|
|
background: 'var(--color-bg-surface)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 12,
|
|
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
|
|
}}>
|
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Edit HTML</div>
|
|
<button
|
|
onClick={() => setOpen(false)}
|
|
style={{
|
|
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
|
|
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
|
|
}}
|
|
>
|
|
<i className="fa fa-times" />
|
|
</button>
|
|
</div>
|
|
<div style={{ padding: 16 }}>
|
|
<CodeEditor value={value} onChange={onChange} language="html" height={420} />
|
|
</div>
|
|
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'flex-end' }}>
|
|
<button
|
|
onClick={() => setOpen(false)}
|
|
style={{
|
|
padding: '7px 20px', fontSize: 13, fontWeight: 600,
|
|
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
|
|
}}
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</CollapsibleSection>
|
|
);
|
|
};
|
|
|
|
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
|
|
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
|
|
selectedId, nodeProps, typeName,
|
|
}) => {
|
|
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass', 'code']);
|
|
|
|
const { setProp: setPropValue, setPropStyle: setStyleValue } = useNodeProp(selectedId);
|
|
|
|
// Categorize all props
|
|
const allProps = Object.entries(nodeProps).filter(([key]) => !SKIP_PROPS.has(key));
|
|
const colorProps = allProps.filter(([key, val]) => typeof val === 'string' && /color/i.test(key));
|
|
const boolProps = allProps.filter(([_, val]) => typeof val === 'boolean');
|
|
const numberProps = allProps.filter(([key, val]) => typeof val === 'number' && !/color/i.test(key));
|
|
const stringProps = allProps.filter(([key, val]) => typeof val === 'string' && !/color/i.test(key));
|
|
const arrayProps = allProps.filter(([_, val]) => Array.isArray(val));
|
|
|
|
const style = nodeProps.style || {};
|
|
const hasCodeProp = typeof nodeProps.code === 'string';
|
|
|
|
return (
|
|
<>
|
|
{/* Raw HTML (HtmlBlock's `code` prop) -- dedicated CodeEditor modal */}
|
|
{hasCodeProp && (
|
|
<HtmlCodeField value={nodeProps.code} onChange={(v) => setPropValue('code', v)} />
|
|
)}
|
|
|
|
{/* String props */}
|
|
{stringProps.length > 0 && (
|
|
<CollapsibleSection title="Properties">
|
|
{stringProps.map(([key, val]) => {
|
|
const humanLabel = key.replace(/([A-Z])/g, ' $1').trim();
|
|
const isLong = String(val).length > 60 || key === 'description' || key === 'text' || key === 'content';
|
|
return (
|
|
<div key={key} style={sectionGap}>
|
|
<label style={labelStyle}>{humanLabel}</label>
|
|
{isLong ? (
|
|
<textarea value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
|
) : (
|
|
<input type="text" value={String(val)} onChange={(e) => setPropValue(key, e.target.value)} style={inputStyle} />
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Number props */}
|
|
{numberProps.length > 0 && (
|
|
<CollapsibleSection title="Numbers" defaultOpen={true}>
|
|
{numberProps.map(([key, val]) => (
|
|
<div key={key} style={sectionGap}>
|
|
<label style={labelStyle}>{key.replace(/([A-Z])/g, ' $1').trim()}</label>
|
|
<input type="number" value={val as number} onChange={(e) => setPropValue(key, parseFloat(e.target.value) || 0)} style={inputStyle} />
|
|
</div>
|
|
))}
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Boolean props */}
|
|
{boolProps.length > 0 && (
|
|
<CollapsibleSection title="Options" defaultOpen={true}>
|
|
{boolProps.map(([key, val]) => (
|
|
<div key={key} style={sectionGap}>
|
|
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
|
<input type="checkbox" checked={val as boolean} onChange={(e) => setPropValue(key, e.target.checked)} />
|
|
{key.replace(/([A-Z])/g, ' $1').trim()}
|
|
</label>
|
|
</div>
|
|
))}
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Color props */}
|
|
{colorProps.length > 0 && (
|
|
<CollapsibleSection title="Colors">
|
|
{colorProps.map(([key, val]) => (
|
|
<ColorPickerField key={key} label={key.replace(/([A-Z])/g, ' $1').trim()} value={String(val)} onChange={(v) => setPropValue(key, v)} />
|
|
))}
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Array props */}
|
|
{arrayProps.map(([key, items]) => (
|
|
<ArrayItemFieldsEditor key={key} selectedId={selectedId} propKey={key} items={items as any[]} />
|
|
))}
|
|
|
|
{/* Style controls */}
|
|
<CollapsibleSection title="Style">
|
|
<div className="guided-section">
|
|
<SectionLabel>Background</SectionLabel>
|
|
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setStyleValue('backgroundColor', v)} />
|
|
</div>
|
|
{/* Text color in style */}
|
|
<div className="guided-section">
|
|
<SectionLabel>Text Color</SectionLabel>
|
|
<ColorSwatchGrid colors={TEXT_COLORS} activeValue={style.color as string} onSelect={(v: string) => setStyleValue('color', v)} />
|
|
</div>
|
|
<div className="guided-section">
|
|
<SectionLabel>Padding</SectionLabel>
|
|
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setStyleValue('padding', v)} />
|
|
</div>
|
|
<div className="guided-section">
|
|
<SectionLabel>Text Alignment</SectionLabel>
|
|
<div className="preset-grid align-grid">
|
|
{(['left', 'center', 'right'] as const).map((a) => (
|
|
<button key={a} className={`preset-btn ${style.textAlign === a ? 'active' : ''}`} onClick={() => setStyleValue('textAlign', a)} title={a}>
|
|
<i className={`fa fa-align-${a}`} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="guided-section">
|
|
<SectionLabel>Border Radius</SectionLabel>
|
|
<PresetButtonGrid presets={RADIUS_PRESETS} activeValue={style.borderRadius as string} onSelect={(v) => setStyleValue('borderRadius', v)} />
|
|
</div>
|
|
</CollapsibleSection>
|
|
</>
|
|
);
|
|
};
|