feat(site-builder): add insert/colour/format toolbar to the Edit HTML modal
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { CollapsibleSection, sectionGap } from './shared';
|
import { CollapsibleSection, sectionGap } from './shared';
|
||||||
import { Modal } from '../../../ui/Modal';
|
import { Modal } from '../../../ui/Modal';
|
||||||
import { CodeEditor } from '../../../ui/CodeEditor';
|
import { CodeEditor, type CodeEditorHandle } from '../../../ui/CodeEditor';
|
||||||
|
import { HtmlToolbar } from './HtmlToolbar';
|
||||||
|
import { formatHtml } from '../../../utils/format-html';
|
||||||
|
|
||||||
/* "Edit HTML" modal for the HtmlBlock `code` prop. `code` is raw HTML
|
/* "Edit HTML" modal for the HtmlBlock `code` prop. `code` is raw HTML
|
||||||
(potentially many lines, embedded <style>/<script>), so it gets a
|
(potentially many lines, embedded <style>/<script>), so it gets a
|
||||||
@@ -9,6 +11,11 @@ import { CodeEditor } from '../../../ui/CodeEditor';
|
|||||||
generic single-line/textarea string-prop rendering. */
|
generic single-line/textarea string-prop rendering. */
|
||||||
export const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
|
export const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const editorRef = useRef<CodeEditorHandle>(null);
|
||||||
|
const handleFormat = (): void => {
|
||||||
|
const current = editorRef.current?.getValue() ?? value;
|
||||||
|
onChange(formatHtml(current));
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<CollapsibleSection title="HTML Code">
|
<CollapsibleSection title="HTML Code">
|
||||||
<div style={sectionGap}>
|
<div style={sectionGap}>
|
||||||
@@ -54,7 +61,8 @@ export const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => v
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 16 }}>
|
<div style={{ padding: 16 }}>
|
||||||
<CodeEditor value={value} onChange={onChange} language="html" height={420} />
|
<HtmlToolbar editorRef={editorRef} onFormat={handleFormat} />
|
||||||
|
<CodeEditor ref={editorRef} value={value} onChange={onChange} language="html" height={420} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, test, expect, vi } from 'vitest';
|
||||||
|
import React from 'react';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
import { act } from 'react-dom/test-utils';
|
||||||
|
import { HtmlToolbar, SNIPPETS } from './HtmlToolbar';
|
||||||
|
import type { CodeEditorHandle } from '../../../ui/CodeEditor';
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeHandle() {
|
||||||
|
return { insertAtCursor: vi.fn(), getValue: vi.fn(() => '') } as unknown as CodeEditorHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HtmlToolbar', () => {
|
||||||
|
test('every snippet button inserts its snippet with its caret offset', () => {
|
||||||
|
const handle = fakeHandle();
|
||||||
|
const ref = { current: handle } as React.RefObject<CodeEditorHandle>;
|
||||||
|
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
|
||||||
|
|
||||||
|
for (const snippet of SNIPPETS) {
|
||||||
|
const btn = container.querySelector(`[data-snippet="${snippet.label}"]`) as HTMLButtonElement;
|
||||||
|
expect(btn, `missing button for ${snippet.label}`).not.toBeNull();
|
||||||
|
act(() => { btn.click(); });
|
||||||
|
expect(handle.insertAtCursor).toHaveBeenCalledWith(snippet.text, snippet.caret);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the colour input inserts a style attribute at the caret', () => {
|
||||||
|
const handle = fakeHandle();
|
||||||
|
const ref = { current: handle } as React.RefObject<CodeEditorHandle>;
|
||||||
|
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
|
||||||
|
|
||||||
|
const colour = container.querySelector('input[type="color"]') as HTMLInputElement;
|
||||||
|
colour.value = '#ff8800';
|
||||||
|
act(() => { colour.dispatchEvent(new Event('input', { bubbles: true })); });
|
||||||
|
|
||||||
|
expect(handle.insertAtCursor).toHaveBeenCalledWith(' style="color: #ff8800"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Format calls onFormat', () => {
|
||||||
|
const onFormat = vi.fn();
|
||||||
|
const ref = { current: fakeHandle() } as React.RefObject<CodeEditorHandle>;
|
||||||
|
render(<HtmlToolbar editorRef={ref} onFormat={onFormat} />);
|
||||||
|
const btn = container.querySelector('[data-action="format"]') as HTMLButtonElement;
|
||||||
|
act(() => { btn.click(); });
|
||||||
|
expect(onFormat).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a null editor ref is a no-op, not a crash', () => {
|
||||||
|
const ref = { current: null } as React.RefObject<CodeEditorHandle>;
|
||||||
|
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
|
||||||
|
const btn = container.querySelector('[data-snippet="div"]') as HTMLButtonElement;
|
||||||
|
expect(() => act(() => { btn.click(); })).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { CodeEditorHandle } from '../../../ui/CodeEditor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snippet buttons for the Edit HTML modal. `caret` is the offset from the
|
||||||
|
* start of the inserted text where the caret should land -- i.e. between the
|
||||||
|
* open and close tags, so the next keystroke types content rather than
|
||||||
|
* landing after the closing tag.
|
||||||
|
*/
|
||||||
|
export const SNIPPETS: { label: string; icon: string; title: string; text: string; caret: number }[] = [
|
||||||
|
{ label: 'div', icon: 'fa-square-o', title: 'Insert a div', text: '<div></div>', caret: 5 },
|
||||||
|
{ label: 'section', icon: 'fa-window-maximize', title: 'Insert a section', text: '<section></section>', caret: 9 },
|
||||||
|
{ label: 'h2', icon: 'fa-header', title: 'Insert a heading', text: '<h2></h2>', caret: 4 },
|
||||||
|
{ label: 'p', icon: 'fa-paragraph', title: 'Insert a paragraph', text: '<p></p>', caret: 3 },
|
||||||
|
{ label: 'a', icon: 'fa-link', title: 'Insert a link', text: '<a href="#"></a>', caret: 12 },
|
||||||
|
{ label: 'ul', icon: 'fa-list-ul', title: 'Insert a list', text: '<ul>\n <li></li>\n</ul>', caret: 11 },
|
||||||
|
{ label: 'img', icon: 'fa-image', title: 'Insert an image', text: '<img src="" alt="">', caret: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const btnStyle: React.CSSProperties = {
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
minWidth: 28, height: 28, padding: '0 7px',
|
||||||
|
background: '#27272a', color: '#e4e4e7',
|
||||||
|
border: '1px solid #3f3f46', borderRadius: 5,
|
||||||
|
fontSize: 11, cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HtmlToolbar: React.FC<{
|
||||||
|
editorRef: React.RefObject<CodeEditorHandle>;
|
||||||
|
onFormat: () => void;
|
||||||
|
}> = ({ editorRef, onFormat }) => {
|
||||||
|
const insert = (text: string, caret?: number): void => {
|
||||||
|
// The ref is null until CodeEditor mounts; clicking early must no-op.
|
||||||
|
// Only forward a second argument when a caret offset was actually
|
||||||
|
// given -- `insertAtCursor(text, undefined)` is a distinct call from
|
||||||
|
// `insertAtCursor(text)` (an explicit undefined still occupies the
|
||||||
|
// argument list), and the colour control relies on the latter so the
|
||||||
|
// caret lands at the end of the inserted attribute, not mid-string.
|
||||||
|
if (caret === undefined) {
|
||||||
|
editorRef.current?.insertAtCursor(text);
|
||||||
|
} else {
|
||||||
|
editorRef.current?.insertAtCursor(text, caret);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="HTML editing tools"
|
||||||
|
style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 10, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{SNIPPETS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.label}
|
||||||
|
type="button"
|
||||||
|
data-snippet={s.label}
|
||||||
|
title={s.title}
|
||||||
|
aria-label={s.title}
|
||||||
|
style={btnStyle}
|
||||||
|
onClick={() => insert(s.text, s.caret)}
|
||||||
|
>
|
||||||
|
<i className={`fa ${s.icon}`} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span style={{ width: 1, height: 20, background: '#3f3f46', margin: '0 3px' }} aria-hidden="true" />
|
||||||
|
|
||||||
|
<label
|
||||||
|
title="Insert a colour style attribute at the cursor"
|
||||||
|
style={{ ...btnStyle, padding: 0, overflow: 'hidden', position: 'relative' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
aria-label="Insert colour"
|
||||||
|
defaultValue="#3b82f6"
|
||||||
|
onInput={(e) => insert(` style="color: ${(e.target as HTMLInputElement).value}"`)}
|
||||||
|
style={{ width: 40, height: 34, border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="format"
|
||||||
|
title="Re-indent the markup"
|
||||||
|
style={{ ...btnStyle, marginLeft: 'auto', fontWeight: 600, gap: 5 }}
|
||||||
|
onClick={onFormat}
|
||||||
|
>
|
||||||
|
<i className="fa fa-indent" aria-hidden="true" /> Format
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user