815 lines
31 KiB
TypeScript

import React, { useCallback, useState } from 'react';
import Palette from './Palette/Palette';
import Canvas from './Canvas/Canvas';
import ProjectToolbar from '../ProjectToolbar/ProjectToolbar';
import { useProject } from '../../context/ProjectContext';
import styles from './VisualEditor.module.css';
import type { ComponentStyle, ComponentType, DropdownOption, TableColumn, TableRow } from '../../types/project';
import ButtonEventEditor from './ButtonEventEditor';
import PageEventEditor from './PageEventEditor';
import ComponentDeleteDialog from './ComponentDeleteDialog';
import { findComponentReferences, type PendingComponentDeletion } from './componentDeletionUtils';
import ValidationSummary from '../ValidationSummary';
import PageManager from './PageManager';
// ── Dropdown options row editor ───────────────────────────────────────────────
type DropdownOptionsEditorProps = {
componentId: string;
options: DropdownOption[];
configuredValue: string;
onOptionsChange: (options: DropdownOption[]) => void;
onConfiguredValueChange: (value: string) => void;
};
function DropdownOptionsEditor({
options,
configuredValue,
onOptionsChange,
onConfiguredValueChange,
}: DropdownOptionsEditorProps): React.ReactElement {
const [optionsError, setOptionsError] = useState<string | null>(null);
const handleAddOption = () => {
onOptionsChange([...options, { label: '', value: '' }]);
};
const handleChangeOption = (index: number, field: 'label' | 'value', text: string) => {
const updated = options.map((o, i) =>
i === index ? { ...o, [field]: text } : o,
);
onOptionsChange(updated);
// Warn on duplicate values (non-blocking)
const values = updated.map((o) => o.value).filter(Boolean);
const hasDups = values.length !== new Set(values).size;
setOptionsError(hasDups ? 'Duplicate option values detected.' : null);
};
const handleRemoveOption = (index: number) => {
const updated = options.filter((_, i) => i !== index);
onOptionsChange(updated);
const values = updated.map((o) => o.value).filter(Boolean);
const hasDups = values.length !== new Set(values).size;
setOptionsError(hasDups ? 'Duplicate option values detected.' : null);
};
// Warn if configured value is not in option list (non-blocking)
const configuredValueValid =
configuredValue === '' ||
options.some((o) => o.value === configuredValue);
return (
<div style={{ marginTop: 8 }}>
<div className={styles.infoKey} style={{ marginBottom: 4, display: 'block' }}>Options</div>
{options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 3, alignItems: 'center' }}>
<input
className={styles.infoInput}
style={{ width: 70 }}
placeholder="Label"
value={opt.label}
onChange={(e) => handleChangeOption(i, 'label', e.target.value)}
/>
<input
className={styles.infoInput}
style={{ width: 70 }}
placeholder="Value"
value={opt.value}
onChange={(e) => handleChangeOption(i, 'value', e.target.value)}
/>
<button
style={{ fontSize: 10, padding: '2px 5px', cursor: 'pointer', background: 'none',
border: '1px solid #d0d7de', borderRadius: 3, color: '#b91c1c', flexShrink: 0 }}
onClick={() => handleRemoveOption(i)}
title="Remove option"
>
</button>
</div>
))}
{optionsError && (
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 4 }}>{optionsError}</div>
)}
<button
style={{ fontSize: 11, padding: '2px 8px', cursor: 'pointer', background: '#f7f8fa',
border: '1px solid #d0d7de', borderRadius: 3, color: '#1f2328', marginBottom: 6 }}
onClick={handleAddOption}
>
+ Add option
</button>
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-dropdown-value">Default value</label>
<input
id="prop-dropdown-value"
className={styles.infoInput}
type="text"
placeholder="e.g. dev"
value={configuredValue}
onChange={(e) => onConfiguredValueChange(e.target.value)}
style={!configuredValueValid ? { borderColor: '#f59e0b' } : undefined}
title={!configuredValueValid ? 'Value not found in options' : undefined}
/>
</div>
{!configuredValueValid && (
<div style={{ fontSize: 10, color: '#92400e', marginBottom: 4 }}>
Default value not found in options.
</div>
)}
</div>
);
}
// ── Table columns editor ──────────────────────────────────────────────────────
type TableColumnsEditorProps = {
columns: TableColumn[];
onColumnsChange: (columns: TableColumn[]) => void;
};
function TableColumnsEditor({ columns, onColumnsChange }: TableColumnsEditorProps): React.ReactElement {
const [colError, setColError] = useState<string | null>(null);
const validate = (cols: TableColumn[]): string | null => {
const keys = cols.map((c) => c.key).filter(Boolean);
const dupKeys = keys.filter((k, i) => keys.indexOf(k) !== i);
if (dupKeys.length > 0) return `Duplicate column keys: ${dupKeys.map((k) => `"${k}"`).join(', ')}.`;
const emptyKey = cols.some((c) => !c.key);
if (emptyKey) return 'Column key must not be empty.';
return null;
};
const handleAdd = () => {
const updated = [...columns, { key: '', header: '' }];
onColumnsChange(updated);
setColError(validate(updated));
};
const handleChange = (index: number, field: 'key' | 'header', value: string) => {
const updated = columns.map((c, i) => i === index ? { ...c, [field]: value } : c);
onColumnsChange(updated);
setColError(validate(updated));
};
const handleRemove = (index: number) => {
const updated = columns.filter((_, i) => i !== index);
onColumnsChange(updated);
setColError(validate(updated));
};
return (
<div style={{ marginTop: 8 }}>
<div className={styles.infoKey} style={{ marginBottom: 4, display: 'block' }}>Columns</div>
{columns.map((col, i) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 3, alignItems: 'center' }}>
<input
className={styles.infoInput}
style={{ width: 70 }}
placeholder="Label"
value={col.header}
onChange={(e) => handleChange(i, 'header', e.target.value)}
/>
<input
className={styles.infoInput}
style={{ width: 70, borderColor: !col.key ? '#f59e0b' : undefined }}
placeholder="Key"
value={col.key}
onChange={(e) => handleChange(i, 'key', e.target.value)}
title={!col.key ? 'Key must not be empty' : undefined}
/>
<input
className={styles.infoInput}
style={{ width: 58 }}
type="number"
min={1}
placeholder="Width"
aria-label={`Column ${col.header || col.key || i + 1} width`}
value={col.width ?? ''}
onChange={(event) => onColumnsChange(columns.map((current, columnIndex) =>
columnIndex === i
? {
...current,
width: event.target.value === ''
? undefined
: Math.max(1, Number(event.target.value)),
}
: current
))}
/>
<button
style={{ fontSize: 10, padding: '2px 5px', cursor: 'pointer', background: 'none',
border: '1px solid #d0d7de', borderRadius: 3, color: '#b91c1c', flexShrink: 0 }}
onClick={() => handleRemove(i)}
title="Remove column"
>
</button>
</div>
))}
{colError && (
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 4 }}>{colError}</div>
)}
<button
style={{ fontSize: 11, padding: '2px 8px', cursor: 'pointer', background: '#f7f8fa',
border: '1px solid #d0d7de', borderRadius: 3, color: '#1f2328', marginBottom: 6 }}
onClick={handleAdd}
>
+ Add column
</button>
</div>
);
}
// ── Table rows editor ─────────────────────────────────────────────────────────
type TableRowsEditorProps = {
rows: TableRow[];
onRowsChange: (rows: TableRow[]) => void;
};
function TableRowsEditor({ rows, onRowsChange }: TableRowsEditorProps): React.ReactElement {
// Draft text for the JSON textarea; initialise from current rows
const [draft, setDraft] = useState<string>(() => JSON.stringify(rows, null, 2));
const [rowsError, setRowsError] = useState<string | null>(null);
// Sync draft when rows prop changes externally (e.g. on component selection)
const prevRowsRef = React.useRef<TableRow[]>(rows);
if (rows !== prevRowsRef.current) {
prevRowsRef.current = rows;
setDraft(JSON.stringify(rows, null, 2));
setRowsError(null);
}
const handleApply = () => {
let parsed: unknown;
try {
parsed = JSON.parse(draft);
} catch (e: unknown) {
setRowsError(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
return;
}
if (!Array.isArray(parsed)) {
setRowsError('Rows must be a JSON array.');
return;
}
for (let i = 0; i < parsed.length; i++) {
const el = parsed[i];
if (el === null || typeof el !== 'object' || Array.isArray(el)) {
setRowsError(
`Row at index ${i} must be a non-null object (got ${
el === null ? 'null' : Array.isArray(el) ? 'array' : typeof el
}).`,
);
return;
}
}
setRowsError(null);
onRowsChange(parsed as TableRow[]);
};
return (
<div style={{ marginTop: 8 }}>
<div className={styles.infoKey} style={{ marginBottom: 4, display: 'block' }}>
Rows (JSON array)
</div>
<textarea
style={{
width: '100%', minHeight: 80, fontFamily: 'monospace', fontSize: 11,
border: '1px solid #d0d7de', borderRadius: 3, padding: '4px 6px',
resize: 'vertical', boxSizing: 'border-box',
borderColor: rowsError ? '#f59e0b' : undefined,
}}
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
/>
{rowsError && (
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 4 }}>{rowsError}</div>
)}
<button
style={{ fontSize: 11, padding: '2px 8px', cursor: 'pointer', background: '#f7f8fa',
border: '1px solid #d0d7de', borderRadius: 3, color: '#1f2328', marginBottom: 6 }}
onClick={handleApply}
>
Apply rows
</button>
</div>
);
}
// Basic appearance editor
type AppearanceEditorProps = {
value: ComponentStyle;
onChange: (value: ComponentStyle) => void;
};
function AppearanceEditor({ value, onChange }: AppearanceEditorProps): React.ReactElement {
const setValue = (key: keyof ComponentStyle, next: number | string | undefined) => {
const updated = { ...value };
if (next === undefined) delete updated[key];
else Object.assign(updated, { [key]: next });
onChange(updated);
};
return (
<div style={{ marginTop: 10 }}>
<div className={styles.infoKey} style={{ marginBottom: 4, display: 'block' }}>
Basic appearance
</div>
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-font-size">Font size</label>
<select
id="prop-font-size"
className={styles.infoInput}
value={value.fontSize ?? ''}
onChange={(event) => setValue(
'fontSize',
event.target.value === '' ? undefined : Number(event.target.value),
)}
>
<option value="">Default</option>
{[12, 14, 16, 18, 20, 24, 32].map((size) => (
<option key={size} value={size}>{size}px</option>
))}
</select>
</div>
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-text-color">Text</label>
<input
id="prop-text-color"
type="color"
value={value.textColor ?? '#1f2328'}
onChange={(event) => setValue('textColor', event.target.value)}
aria-label="Component text color"
/>
{value.textColor && (
<button type="button" onClick={() => setValue('textColor', undefined)}>Reset</button>
)}
</div>
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-background-color">Background</label>
<input
id="prop-background-color"
type="color"
value={value.backgroundColor ?? '#ffffff'}
onChange={(event) => setValue('backgroundColor', event.target.value)}
aria-label="Component background color"
/>
{value.backgroundColor && (
<button type="button" onClick={() => setValue('backgroundColor', undefined)}>Reset</button>
)}
</div>
</div>
);
}
// Main component
function VisualEditor(): React.ReactElement {
const {
doc,
activePage,
canvas,
selectedId,
selectComponent,
isLoading,
} = useProject();
const {
addComponent,
moveComponent,
removeComponent,
updateComponentProperty,
updateComponentName,
updateComponentSize,
updateComponentEvents,
updatePageEvents,
} = canvas;
const selectedComponent = activePage.components.find((x) => x.id === selectedId) ?? null;
// Local state for width/height text inputs (allows typing without snapping mid-edit)
const [widthDraft, setWidthDraft] = useState<string>('');
const [heightDraft, setHeightDraft] = useState<string>('');
const [pendingDeletion, setPendingDeletion] = useState<PendingComponentDeletion | null>(null);
// Sync drafts when selection changes
const prevSelectedId = React.useRef<string | null>(null);
if (selectedComponent && selectedComponent.id !== prevSelectedId.current) {
prevSelectedId.current = selectedComponent.id;
setWidthDraft(String(selectedComponent.size.width));
setHeightDraft(String(selectedComponent.size.height));
}
if (!selectedComponent && prevSelectedId.current !== null) {
prevSelectedId.current = null;
}
// Duplicate-name detection
const isDuplicateName = useCallback(
(name: string, excludeId: string) =>
activePage.components.some((c) => c.id !== excludeId && c.name === name),
[activePage.components],
);
const handleNameChange = useCallback(
(id: string, value: string) => {
updateComponentName(id, value);
},
[updateComponentName],
);
const handleWidthCommit = useCallback(
(id: string, currentHeight: number) => {
const n = parseInt(widthDraft, 10);
if (!isNaN(n) && n > 0) {
updateComponentSize(id, n, currentHeight);
} else {
// Reset draft to actual value if invalid
setWidthDraft(selectedComponent ? String(selectedComponent.size.width) : widthDraft);
}
},
[widthDraft, updateComponentSize, selectedComponent],
);
const handleHeightCommit = useCallback(
(id: string, currentWidth: number) => {
const n = parseInt(heightDraft, 10);
if (!isNaN(n) && n > 0) {
updateComponentSize(id, currentWidth, n);
} else {
setHeightDraft(selectedComponent ? String(selectedComponent.size.height) : heightDraft);
}
},
[heightDraft, updateComponentSize, selectedComponent],
);
// Add from palette click — place at a staggered default position
const handlePaletteAdd = useCallback(
(type: ComponentType) => {
const offset = activePage.components.length * 24;
addComponent(type, 32 + offset, 32 + offset);
},
[activePage.components.length, addComponent],
);
// Add from canvas drop
const handleCanvasDrop = useCallback(
(type: ComponentType, x: number, y: number) => {
addComponent(type, x, y);
},
[addComponent],
);
const handleRemoveRequest = useCallback((id: string) => {
const component = activePage.components.find((candidate) => candidate.id === id);
if (!component) return;
const references = findComponentReferences(doc, component.name);
if (references.length > 0) {
setPendingDeletion({ component, references });
return;
}
removeComponent(id);
selectComponent(null);
}, [activePage.components, doc, removeComponent, selectComponent]);
const handleConfirmRemoval = useCallback(() => {
if (!pendingDeletion) return;
removeComponent(pendingDeletion.component.id);
selectComponent(null);
setPendingDeletion(null);
}, [pendingDeletion, removeComponent, selectComponent]);
// Show a loading overlay while fetching a project from the backend
if (isLoading) {
return (
<div className={styles.editor}>
<ProjectToolbar />
<div className={styles.loadingOverlay}>Loading project</div>
</div>
);
}
return (
<div className={styles.editor}>
{/* ── Project toolbar (New / Save / Load) ───────────────────── */}
<ProjectToolbar />
<PageManager />
<ValidationSummary />
{/* ── Editor toolbar (page info) ────────────────────────────── */}
<div className={styles.editorBar}>
<span className={styles.projectName}>{doc.project.name}</span>
<span className={styles.pageName}>{activePage.name}</span>
<PageEventEditor
events={activePage.events}
actions={doc.project.actions}
onChange={updatePageEvents}
/>
<span className={styles.componentCount}>
{activePage.components.length} component
{activePage.components.length !== 1 ? 's' : ''}
</span>
</div>
{/* ── Main area: palette | canvas | info ────────────────────── */}
<div className={styles.body}>
<Palette onAdd={handlePaletteAdd} />
<Canvas
components={activePage.components}
selectedId={selectedId}
onSelect={selectComponent}
onMove={moveComponent}
onRemove={handleRemoveRequest}
onDrop={handleCanvasDrop}
/>
{/* Selection info + live JSON */}
<aside className={styles.info}>
<div className={styles.infoHeader}>Selection</div>
{selectedComponent ? (
<div className={styles.infoBody}>
{/* Type + position — read-only */}
{([
['Type', selectedComponent.type],
['X', `${selectedComponent.position.x}px`],
['Y', `${selectedComponent.position.y}px`],
] as [string, string][]).map(([k, v]) => (
<div key={k} className={styles.infoRow}>
<span className={styles.infoKey}>{k}</span>
<span className={styles.infoVal}>{v}</span>
</div>
))}
{/* Editable name */}
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-name">Name</label>
<input
id="prop-name"
className={styles.infoInput}
type="text"
value={selectedComponent.name}
onChange={(e) => handleNameChange(selectedComponent.id, e.target.value)}
style={
selectedComponent.name === '' ||
isDuplicateName(selectedComponent.name, selectedComponent.id)
? { borderColor: '#f59e0b' }
: undefined
}
title={
selectedComponent.name === ''
? 'Name must not be empty'
: isDuplicateName(selectedComponent.name, selectedComponent.id)
? 'Duplicate name — components must have unique names'
: undefined
}
/>
</div>
{selectedComponent.name === '' && (
<div style={{ fontSize: 10, color: '#b91c1c', marginBottom: 2 }}>
Name must not be empty.
</div>
)}
{selectedComponent.name !== '' &&
isDuplicateName(selectedComponent.name, selectedComponent.id) && (
<div style={{ fontSize: 10, color: '#92400e', marginBottom: 2 }}>
Duplicate name detected.
</div>
)}
{/* Editable width */}
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-width">W</label>
<input
id="prop-width"
className={styles.infoInput}
type="number"
min={1}
value={widthDraft}
onChange={(e) => setWidthDraft(e.target.value)}
onBlur={() => handleWidthCommit(selectedComponent.id, selectedComponent.size.height)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleWidthCommit(selectedComponent.id, selectedComponent.size.height);
}}
/>
</div>
{/* Editable height */}
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-height">H</label>
<input
id="prop-height"
className={styles.infoInput}
type="number"
min={1}
value={heightDraft}
onChange={(e) => setHeightDraft(e.target.value)}
onBlur={() => handleHeightCommit(selectedComponent.id, selectedComponent.size.width)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleHeightCommit(selectedComponent.id, selectedComponent.size.width);
}}
/>
</div>
{/* Visible checkbox */}
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-visible">Visible</label>
<input
id="prop-visible"
type="checkbox"
checked={selectedComponent.properties.visible !== false}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, 'visible', e.target.checked)
}
/>
</div>
{/* Disabled checkbox */}
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-disabled">Disabled</label>
<input
id="prop-disabled"
type="checkbox"
checked={selectedComponent.properties.disabled === true}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, 'disabled', e.target.checked)
}
/>
</div>
{['TextInput', 'TextArea', 'Checkbox', 'RadioGroup', 'Dropdown'].includes(selectedComponent.type) && (
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-required">Required</label>
<input
id="prop-required"
type="checkbox"
checked={selectedComponent.properties.required === true}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, 'required', e.target.checked)
}
/>
</div>
)}
{selectedComponent.properties.label !== undefined && (
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-label">Label</label>
<input
id="prop-label"
className={styles.infoInput}
type="text"
value={String(selectedComponent.properties.label ?? '')}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, 'label', e.target.value)
}
/>
</div>
)}
{selectedComponent.properties.placeholder !== undefined && (
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-placeholder">Placeholder</label>
<input
id="prop-placeholder"
className={styles.infoInput}
type="text"
value={String(selectedComponent.properties.placeholder ?? '')}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, 'placeholder', e.target.value)
}
/>
</div>
)}
{(selectedComponent.type === 'Dropdown' || selectedComponent.type === 'RadioGroup') && (
<DropdownOptionsEditor
componentId={selectedComponent.id}
options={
Array.isArray(selectedComponent.properties.options)
? (selectedComponent.properties.options as DropdownOption[])
: []
}
configuredValue={
typeof selectedComponent.properties.value === 'string'
? selectedComponent.properties.value
: ''
}
onOptionsChange={(opts) =>
updateComponentProperty(selectedComponent.id, 'options', opts)
}
onConfiguredValueChange={(val) =>
updateComponentProperty(selectedComponent.id, 'value', val)
}
/>
)}
{selectedComponent.type === 'Checkbox' && (
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-checked">Default checked</label>
<input
id="prop-checked"
type="checkbox"
checked={selectedComponent.properties.defaultValue === true}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, 'defaultValue', e.target.checked)
}
/>
</div>
)}
{['TextInput', 'TextArea', 'JsonViewer', 'StatusPanel', 'Container'].includes(selectedComponent.type) && (
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-default-value">
{selectedComponent.type === 'Container' ? 'Body' : selectedComponent.type === 'StatusPanel' ? 'Message' : selectedComponent.type === 'JsonViewer' ? 'Default JSON' : 'Default'}
</label>
<textarea
id="prop-default-value"
className={styles.infoInput}
value={typeof selectedComponent.properties.defaultValue === 'string' ? selectedComponent.properties.defaultValue : ''}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, 'defaultValue', e.target.value)
}
/>
</div>
)}
{selectedComponent.type === 'StatusPanel' && (
<div className={styles.infoRow}>
<label className={styles.infoKey} htmlFor="prop-status">Tone</label>
<select
id="prop-status"
className={styles.infoInput}
value={typeof selectedComponent.properties.status === 'string' ? selectedComponent.properties.status : 'info'}
onChange={(e) =>
updateComponentProperty(selectedComponent.id, 'status', e.target.value)
}
>
<option value="info">Info</option>
<option value="success">Success</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
</select>
</div>
)}
{selectedComponent.type === 'Table' && (
<>
<TableColumnsEditor
columns={
Array.isArray(selectedComponent.properties.columns)
? (selectedComponent.properties.columns as TableColumn[])
: []
}
onColumnsChange={(cols) =>
updateComponentProperty(selectedComponent.id, 'columns', cols)
}
/>
<TableRowsEditor
rows={
Array.isArray(selectedComponent.properties.rows)
? (selectedComponent.properties.rows as TableRow[])
: []
}
onRowsChange={(rowData) =>
updateComponentProperty(selectedComponent.id, 'rows', rowData)
}
/>
</>
)}
{selectedComponent.type === 'Button' && (
<ButtonEventEditor
events={selectedComponent.events}
actions={doc.project.actions}
pages={doc.project.pages}
onChange={(events) => updateComponentEvents(selectedComponent.id, events)}
/>
)}
<AppearanceEditor
value={
selectedComponent.properties.style &&
typeof selectedComponent.properties.style === 'object'
? selectedComponent.properties.style as ComponentStyle
: {}
}
onChange={(appearance) =>
updateComponentProperty(selectedComponent.id, 'style', appearance)
}
/>
</div>
) : (
<p className={styles.infoEmpty}>No component selected</p>
)}
{/* Live project JSON */}
<div className={styles.jsonSection}>
<div className={styles.infoHeader}>Project JSON</div>
<pre className={styles.jsonPre}>{JSON.stringify(doc, null, 2)}</pre>
</div>
</aside>
</div>
{pendingDeletion && (
<ComponentDeleteDialog
pending={pendingDeletion}
onCancel={() => setPendingDeletion(null)}
onConfirm={handleConfirmRemoval}
/>
)}
</div>
);
}
export default VisualEditor;