Merge PR #16: enh code editor

This commit was merged in pull request #16.
This commit is contained in:
2026-07-14 13:47:46 +00:00
5 changed files with 523 additions and 23 deletions
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
TEXT_COLORS,
BG_COLORS,
@@ -17,12 +17,85 @@ import {
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']);
const SKIP_PROPS = new Set(['style', 'children', 'cssId', 'cssClass', 'code']);
const { setProp: setPropValue, setPropStyle: setStyleValue } = useNodeProp(selectedId);
@@ -35,9 +108,15 @@ export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Recor
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">
@@ -0,0 +1,85 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { HeadCodeModal } from './HeadCodeModal';
import { SiteDesignProvider, useSiteDesign } from '../../state/SiteDesignContext';
/* ---------- DOM test harness -- same react-dom/client + `act` pattern used
throughout src/ui/*.test.tsx (no @testing-library/react in this repo). ---------- */
let container: HTMLDivElement;
let root: Root;
function render(ui: React.ReactElement) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(ui);
});
}
afterEach(() => {
if (container) {
act(() => { root.unmount(); });
container.remove();
}
document.body.style.overflow = '';
});
// Exposes the current headCode so assertions can read it back after a
// simulated edit -- CodeEditor writes through `updateDesign`, this just
// surfaces the result.
function Harness({ onReady }: { onReady: (headCode: string) => void }) {
const { design } = useSiteDesign();
onReady(design.headCode);
return null;
}
describe('HeadCodeModal', () => {
// HeadCodeModal portals its content to document.body (see the comment in
// HeadCodeModal.tsx), so the rendered DOM lives outside `container` --
// query document.body instead.
test('renders the CodeEditor (fallback textarea path) seeded with the current headCode', () => {
render(
<SiteDesignProvider>
<HeadCodeModal open onClose={vi.fn()} />
</SiteDesignProvider>,
);
// CodeMirror loads via async dynamic import (see CodeEditor.test.tsx);
// synchronously after mount the fallback textarea is what's live.
const textarea = document.body.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]');
expect(textarea).not.toBeNull();
expect(textarea!.value).toBe('');
expect(textarea!.dataset.language).toBe('html');
});
test('typing in the editor writes through to SiteDesignContext.headCode', () => {
let latestHeadCode = '';
render(
<SiteDesignProvider>
<HeadCodeModal open onClose={vi.fn()} />
<Harness onReady={(v) => { latestHeadCode = v; }} />
</SiteDesignProvider>,
);
const textarea = document.body.querySelector<HTMLTextAreaElement>('[data-testid="code-editor-fallback"]')!;
act(() => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!;
setter.call(textarea, '<meta name="x" content="y">');
textarea.dispatchEvent(new Event('input', { bubbles: true }));
});
expect(latestHeadCode).toBe('<meta name="x" content="y">');
});
test('does not render when closed', () => {
render(
<SiteDesignProvider>
<HeadCodeModal open={false} onClose={vi.fn()} />
</SiteDesignProvider>,
);
expect(document.body.querySelector('[data-testid="code-editor-fallback"]')).toBeNull();
});
});
+10 -21
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { createPortal } from 'react-dom';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { Modal } from '../../ui/Modal';
import { CodeEditor } from '../../ui/CodeEditor';
interface HeadCodeModalProps {
open: boolean;
@@ -55,27 +56,15 @@ export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) =
Code added here will be injected into the <code style={{ background: 'rgba(255,255,255,0.08)', padding: '1px 4px', borderRadius: 3, fontSize: 11 }}>&lt;head&gt;</code> of every page on your site. Use it for analytics, custom fonts, or global CSS.
</div>
<textarea
value={design.headCode || ''}
onChange={(e) => updateDesign({ headCode: e.target.value })}
placeholder={"<!-- Google Analytics -->\n<script async src=\"https://...\"></script>\n\n<!-- Custom Fonts -->\n<link href=\"https://fonts.googleapis.com/...\" rel=\"stylesheet\">\n\n<style>\n /* Global CSS overrides */\n body { }\n</style>"}
style={{
flex: 1,
minHeight: 300,
padding: 14,
background: '#0d0d0f',
color: '#e4e4e7',
border: '1px solid #3f3f46',
borderRadius: 8,
fontFamily: 'Source Code Pro, Consolas, monospace',
fontSize: 13,
lineHeight: 1.6,
resize: 'vertical',
outline: 'none',
tabSize: 2,
}}
spellCheck={false}
/>
<div style={{ flex: 1, minHeight: 300 }}>
<CodeEditor
value={design.headCode || ''}
onChange={(code) => updateDesign({ headCode: code })}
language="html"
height="100%"
placeholder={"<!-- Google Analytics -->\n<script async src=\"https://...\"></script>\n\n<!-- Custom Fonts -->\n<link href=\"https://fonts.googleapis.com/...\" rel=\"stylesheet\">\n\n<style>\n /* Global CSS overrides */\n body { }\n</style>"}
/>
</div>
</div>
{/* Footer */}