mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(web): restore manual source edit tabs (#1854)
* fix(web): restore manual source edit tabs * test: target active manual edit preview
This commit is contained in:
@@ -4859,6 +4859,9 @@ function HtmlViewer({
|
||||
setSource(result.source);
|
||||
sourceRef.current = result.source;
|
||||
setInlinedSource(null);
|
||||
if (patch.kind !== 'set-style') {
|
||||
setManualEditFrozenSource(result.source);
|
||||
}
|
||||
setManualEditHistory((current) => [entry, ...current]);
|
||||
setManualEditUndone([]);
|
||||
setManualEditDraft((current) => ({ ...current, fullSource: result.source }));
|
||||
@@ -4912,6 +4915,7 @@ function HtmlViewer({
|
||||
setSource(latest.beforeSource);
|
||||
sourceRef.current = latest.beforeSource;
|
||||
setInlinedSource(null);
|
||||
setManualEditFrozenSource(latest.beforeSource);
|
||||
setManualEditHistory(rest);
|
||||
setManualEditUndone((current) => [latest, ...current]);
|
||||
setManualEditDraft((current) => ({ ...current, fullSource: latest.beforeSource }));
|
||||
@@ -4943,6 +4947,7 @@ function HtmlViewer({
|
||||
setSource(latest.afterSource);
|
||||
sourceRef.current = latest.afterSource;
|
||||
setInlinedSource(null);
|
||||
setManualEditFrozenSource(latest.afterSource);
|
||||
setManualEditUndone(rest);
|
||||
setManualEditHistory((current) => [latest, ...current]);
|
||||
setManualEditDraft((current) => ({ ...current, fullSource: latest.afterSource }));
|
||||
|
||||
@@ -29,10 +29,11 @@ export function ManualEditPanel({
|
||||
onDraftChange,
|
||||
onStyleChange,
|
||||
onInvalidStyle,
|
||||
onApplyPatch,
|
||||
onError,
|
||||
onClearSelection,
|
||||
onApplyPatch,
|
||||
onPickImage,
|
||||
busy = false,
|
||||
pageStylesEnabled = true,
|
||||
}: {
|
||||
targets: ManualEditTarget[];
|
||||
@@ -65,6 +66,10 @@ export function ManualEditPanel({
|
||||
useEffect(() => {
|
||||
selectedTargetRef.current = selectedTarget;
|
||||
}, [selectedTarget]);
|
||||
const [activeTab, setActiveTab] = useState<ManualEditTab>('style');
|
||||
const tab = targetForInspector
|
||||
? (activeTab === 'page' ? 'style' : activeTab)
|
||||
: (activeTab === 'source' ? 'source' : 'page');
|
||||
|
||||
const changeTargetStyle = (key: keyof ManualEditStyles, value: string) => {
|
||||
const nextStyles = { ...draft.styles, [key]: value };
|
||||
@@ -81,31 +86,134 @@ export function ManualEditPanel({
|
||||
onError('');
|
||||
onStyleChange?.(targetForInspector.id, normalized.styles, `Style: ${targetForInspector.label}`);
|
||||
};
|
||||
const applyAttributes = () => {
|
||||
if (!targetForInspector) return;
|
||||
try {
|
||||
const parsed = JSON.parse(draft.attributesText) as unknown;
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error();
|
||||
const attributes = Object.fromEntries(
|
||||
Object.entries(parsed as Record<string, unknown>).map(([key, value]) => [key, String(value)]),
|
||||
);
|
||||
onError('');
|
||||
onApplyPatch({ id: targetForInspector.id, kind: 'set-attributes', attributes }, `Attributes: ${targetForInspector.label}`);
|
||||
} catch {
|
||||
onError('Invalid attributes JSON.');
|
||||
}
|
||||
};
|
||||
const applyContent = () => {
|
||||
if (!targetForInspector) return;
|
||||
if (targetForInspector.kind === 'link') {
|
||||
onApplyPatch({ id: targetForInspector.id, kind: 'set-link', text: draft.text, href: draft.href }, `Content: ${targetForInspector.label}`);
|
||||
return;
|
||||
}
|
||||
if (targetForInspector.kind === 'image') {
|
||||
onApplyPatch({ id: targetForInspector.id, kind: 'set-image', src: draft.src, alt: draft.alt }, `Content: ${targetForInspector.label}`);
|
||||
return;
|
||||
}
|
||||
onApplyPatch({ id: targetForInspector.id, kind: 'set-text', value: draft.text }, `Content: ${targetForInspector.label}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="manual-edit-right">
|
||||
<section className="manual-edit-modal cc-panel">
|
||||
<div className="manual-edit-tabs" role="tablist" aria-label="Manual edit tabs">
|
||||
{(targetForInspector ? ELEMENT_TABS : PAGE_TABS).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === item.id}
|
||||
className={tab === item.id ? 'selected' : ''}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{targetForInspector ? (
|
||||
<StyleInspector
|
||||
styles={draft.styles}
|
||||
layoutEnabled={targetForInspector.isLayoutContainer}
|
||||
onClearSelection={onClearSelection}
|
||||
onChange={changeTargetStyle}
|
||||
/>
|
||||
<>
|
||||
{tab === 'content' ? (
|
||||
<ContentEditor
|
||||
target={targetForInspector}
|
||||
draft={draft}
|
||||
busy={busy}
|
||||
onDraftChange={onDraftChange}
|
||||
onApply={applyContent}
|
||||
/>
|
||||
) : null}
|
||||
{tab === 'style' ? (
|
||||
<StyleInspector
|
||||
styles={draft.styles}
|
||||
layoutEnabled={targetForInspector.isLayoutContainer}
|
||||
onClearSelection={onClearSelection}
|
||||
onChange={changeTargetStyle}
|
||||
/>
|
||||
) : null}
|
||||
{tab === 'attributes' ? (
|
||||
<div className="manual-edit-tab-body">
|
||||
<label className="manual-edit-field">
|
||||
<span>Attributes JSON</span>
|
||||
<textarea
|
||||
className="manual-edit-code"
|
||||
value={draft.attributesText}
|
||||
onChange={(event) => onDraftChange({ ...draft, attributesText: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={applyAttributes}>Apply Attributes</button>
|
||||
</div>
|
||||
) : null}
|
||||
{tab === 'html' ? (
|
||||
<div className="manual-edit-tab-body">
|
||||
<label className="manual-edit-field">
|
||||
<span>Selected element HTML</span>
|
||||
<textarea
|
||||
className="manual-edit-code tall"
|
||||
value={draft.outerHtml}
|
||||
onChange={(event) => onDraftChange({ ...draft, outerHtml: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
onClick={() => onApplyPatch({ id: targetForInspector.id, kind: 'set-outer-html', html: draft.outerHtml }, `HTML: ${targetForInspector.label}`)}
|
||||
>
|
||||
Apply HTML
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{tab === 'source' ? (
|
||||
<SourceEditor
|
||||
draft={draft}
|
||||
busy={busy}
|
||||
onDraftChange={onDraftChange}
|
||||
onApply={() => onApplyPatch({ kind: 'set-full-source', source: draft.fullSource }, 'Full source')}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : !targetForInspector ? (
|
||||
<PageInspector
|
||||
enabled={pageStylesEnabled}
|
||||
onStyleChange={(styles) => {
|
||||
const normalized = normalizeManualEditStyles(styles, { layoutEnabled: true });
|
||||
if (!normalized.ok) {
|
||||
onError(normalized.error);
|
||||
onInvalidStyle?.('__body__', Object.keys(styles) as Array<keyof ManualEditStyles>);
|
||||
return;
|
||||
}
|
||||
onError('');
|
||||
onStyleChange?.('__body__', normalized.styles, 'Page styles');
|
||||
}}
|
||||
/>
|
||||
tab === 'source' ? (
|
||||
<SourceEditor
|
||||
draft={draft}
|
||||
busy={busy}
|
||||
onDraftChange={onDraftChange}
|
||||
onApply={() => onApplyPatch({ kind: 'set-full-source', source: draft.fullSource }, 'Full source')}
|
||||
/>
|
||||
) : (
|
||||
<PageInspector
|
||||
enabled={pageStylesEnabled}
|
||||
onStyleChange={(styles) => {
|
||||
const normalized = normalizeManualEditStyles(styles, { layoutEnabled: true });
|
||||
if (!normalized.ok) {
|
||||
onError(normalized.error);
|
||||
onInvalidStyle?.('__body__', Object.keys(styles) as Array<keyof ManualEditStyles>);
|
||||
return;
|
||||
}
|
||||
onError('');
|
||||
onStyleChange?.('__body__', normalized.styles, 'Page styles');
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{targetForInspector?.kind === 'image' && onPickImage ? (
|
||||
@@ -196,6 +304,90 @@ export function ManualEditPanel({
|
||||
);
|
||||
}
|
||||
|
||||
type ManualEditTab = 'page' | 'content' | 'style' | 'attributes' | 'html' | 'source';
|
||||
|
||||
const ELEMENT_TABS: ReadonlyArray<{ id: ManualEditTab; label: string }> = [
|
||||
{ id: 'content', label: 'Content' },
|
||||
{ id: 'style', label: 'Style' },
|
||||
{ id: 'attributes', label: 'Attributes' },
|
||||
{ id: 'html', label: 'HTML' },
|
||||
{ id: 'source', label: 'Source' },
|
||||
];
|
||||
|
||||
const PAGE_TABS: ReadonlyArray<{ id: ManualEditTab; label: string }> = [
|
||||
{ id: 'page', label: 'Page' },
|
||||
{ id: 'source', label: 'Source' },
|
||||
];
|
||||
|
||||
function ContentEditor({
|
||||
target,
|
||||
draft,
|
||||
busy,
|
||||
onDraftChange,
|
||||
onApply,
|
||||
}: {
|
||||
target: ManualEditTarget;
|
||||
draft: ManualEditDraft;
|
||||
busy: boolean;
|
||||
onDraftChange: (draft: ManualEditDraft) => void;
|
||||
onApply: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="manual-edit-tab-body">
|
||||
{target.kind === 'image' ? (
|
||||
<>
|
||||
<label className="manual-edit-field">
|
||||
<span>Image source</span>
|
||||
<input value={draft.src} onChange={(event) => onDraftChange({ ...draft, src: event.currentTarget.value })} />
|
||||
</label>
|
||||
<label className="manual-edit-field">
|
||||
<span>Alt text</span>
|
||||
<input value={draft.alt} onChange={(event) => onDraftChange({ ...draft, alt: event.currentTarget.value })} />
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label className="manual-edit-field">
|
||||
<span>Text</span>
|
||||
<textarea value={draft.text} onChange={(event) => onDraftChange({ ...draft, text: event.currentTarget.value })} />
|
||||
</label>
|
||||
)}
|
||||
{target.kind === 'link' ? (
|
||||
<label className="manual-edit-field">
|
||||
<span>Href</span>
|
||||
<input value={draft.href} onChange={(event) => onDraftChange({ ...draft, href: event.currentTarget.value })} />
|
||||
</label>
|
||||
) : null}
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={onApply}>Apply Content</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceEditor({
|
||||
draft,
|
||||
busy,
|
||||
onDraftChange,
|
||||
onApply,
|
||||
}: {
|
||||
draft: ManualEditDraft;
|
||||
busy: boolean;
|
||||
onDraftChange: (draft: ManualEditDraft) => void;
|
||||
onApply: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="manual-edit-tab-body">
|
||||
<label className="manual-edit-field">
|
||||
<span>Full artifact source</span>
|
||||
<textarea
|
||||
className="manual-edit-code tall"
|
||||
value={draft.fullSource}
|
||||
onChange={(event) => onDraftChange({ ...draft, fullSource: event.currentTarget.value })}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={onApply}>Apply Source</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageInspector({
|
||||
enabled,
|
||||
onStyleChange,
|
||||
|
||||
@@ -22147,6 +22147,32 @@ body.desktop-pet-shell .pet-task-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.manual-edit-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.manual-edit-tabs button {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 9px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.manual-edit-tabs button.selected {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.manual-edit-tab-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
@@ -151,6 +151,70 @@ describe('FileViewer manual edit history regressions', () => {
|
||||
expect(savedSources[2]).toContain('background-color: rgb(249, 115, 22)');
|
||||
expect(savedSources[2]).not.toContain('rgb(239, 68, 68)');
|
||||
});
|
||||
|
||||
it('refreshes the manual edit canvas after non-style source patches', async () => {
|
||||
const initialSource = '<!doctype html><html><body><h1 data-od-id="hero">Hero</h1></body></html>';
|
||||
const savedSources: string[] = [];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url.includes('/api/projects/project-1/deployments')) {
|
||||
return new Response(JSON.stringify({ deployments: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
|
||||
const payload = JSON.parse(String(init.body)) as { content: string };
|
||||
savedSources.push(payload.content);
|
||||
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/projects/project-1/raw/preview.html')) {
|
||||
return new Response(initialSource, { status: 200 });
|
||||
}
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(
|
||||
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
|
||||
liveHtml={initialSource}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||
await waitFor(() => expect(panelState.props).not.toBeNull());
|
||||
const getActivePreviewFrame = () => screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = getActivePreviewFrame();
|
||||
expect(frame.getAttribute('data-od-active')).toBe('true');
|
||||
expect(frame.getAttribute('data-od-render-mode')).toBe('srcdoc');
|
||||
expect(panelState.props?.draft.fullSource).toContain('Hero');
|
||||
});
|
||||
const postMessageSpy = vi.spyOn(getActivePreviewFrame().contentWindow!, 'postMessage');
|
||||
|
||||
act(() => {
|
||||
panelState.props?.onApplyPatch(
|
||||
{ id: 'hero', kind: 'set-text', value: 'Updated hero' },
|
||||
'Content: Hero',
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(savedSources).toHaveLength(1));
|
||||
await waitFor(() => expect(panelState.props?.draft.fullSource).toContain('Updated hero'));
|
||||
await waitFor(() => {
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'od:srcdoc-transport-activate',
|
||||
html: expect.stringContaining('Updated hero'),
|
||||
}),
|
||||
'*',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function htmlPreviewFile(): ProjectFile {
|
||||
|
||||
@@ -52,12 +52,57 @@ describe('ManualEditPanel', () => {
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
it('renders the style inspector without the advanced editor entry', () => {
|
||||
it('restores manual edit tabs for content, HTML, and source edits', () => {
|
||||
renderPanel();
|
||||
|
||||
expect(host.textContent).toContain('TYPOGRAPHY');
|
||||
expect(host.textContent).not.toContain('Advanced');
|
||||
expect(host.textContent).not.toContain('Content');
|
||||
expect(host.textContent).toContain('Content');
|
||||
expect(host.textContent).toContain('HTML');
|
||||
expect(host.textContent).toContain('Source');
|
||||
});
|
||||
|
||||
it('applies selected-element HTML from the manual edit panel', () => {
|
||||
const onApplyPatch = vi.fn();
|
||||
renderPanel({
|
||||
onApplyPatch,
|
||||
outerHtml: '<h1 data-od-id="hero-title">Updated</h1>',
|
||||
});
|
||||
|
||||
clickTab('HTML');
|
||||
const htmlArea = host.querySelector('.manual-edit-code.tall') as HTMLTextAreaElement | null;
|
||||
if (!htmlArea) throw new Error('HTML editor not found');
|
||||
expect(htmlArea.value).toBe('<h1 data-od-id="hero-title">Updated</h1>');
|
||||
const apply = buttonByText('Apply HTML');
|
||||
act(() => {
|
||||
apply.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onApplyPatch).toHaveBeenCalledWith(
|
||||
{ id: 'hero-title', kind: 'set-outer-html', html: '<h1 data-od-id="hero-title">Updated</h1>' },
|
||||
'HTML: Hero Title',
|
||||
);
|
||||
});
|
||||
|
||||
it('applies full source edits from the manual edit panel', () => {
|
||||
const onApplyPatch = vi.fn();
|
||||
renderPanel({
|
||||
onApplyPatch,
|
||||
fullSource: '<html><body><h1>Updated source</h1></body></html>',
|
||||
});
|
||||
|
||||
clickTab('Source');
|
||||
const sourceArea = host.querySelector('.manual-edit-code.tall') as HTMLTextAreaElement | null;
|
||||
if (!sourceArea) throw new Error('Source editor not found');
|
||||
expect(sourceArea.value).toBe('<html><body><h1>Updated source</h1></body></html>');
|
||||
const apply = buttonByText('Apply Source');
|
||||
act(() => {
|
||||
apply.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onApplyPatch).toHaveBeenCalledWith(
|
||||
{ kind: 'set-full-source', source: '<html><body><h1>Updated source</h1></body></html>' },
|
||||
'Full source',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows returning from an element inspector to the page inspector', () => {
|
||||
@@ -435,6 +480,20 @@ describe('ManualEditPanel', () => {
|
||||
return section;
|
||||
}
|
||||
|
||||
function buttonByText(text: string): HTMLButtonElement {
|
||||
const button = Array.from(host.querySelectorAll('button'))
|
||||
.find((candidate) => candidate.textContent === text) as HTMLButtonElement | undefined;
|
||||
if (!button) throw new Error(`${text} button not found`);
|
||||
return button;
|
||||
}
|
||||
|
||||
function clickTab(text: string) {
|
||||
const button = buttonByText(text);
|
||||
act(() => {
|
||||
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function renderPanel({
|
||||
onDraftChange = vi.fn<OnDraftChange>(),
|
||||
onApplyPatch = vi.fn<OnApplyPatch>(),
|
||||
@@ -446,6 +505,8 @@ describe('ManualEditPanel', () => {
|
||||
selectedTarget = target,
|
||||
styles = emptyManualEditStyles(),
|
||||
pageStylesEnabled = true,
|
||||
outerHtml = target.outerHtml,
|
||||
fullSource = '<html></html>',
|
||||
}: {
|
||||
onDraftChange?: OnDraftChange;
|
||||
onApplyPatch?: OnApplyPatch;
|
||||
@@ -457,13 +518,15 @@ describe('ManualEditPanel', () => {
|
||||
selectedTarget?: ManualEditTarget | null;
|
||||
styles?: ReturnType<typeof emptyManualEditStyles>;
|
||||
pageStylesEnabled?: boolean;
|
||||
outerHtml?: string;
|
||||
fullSource?: string;
|
||||
} = {}) {
|
||||
const draft = {
|
||||
...emptyManualEditDraft('<html></html>'),
|
||||
...emptyManualEditDraft(fullSource),
|
||||
text: 'Updated copy',
|
||||
attributesText,
|
||||
styles,
|
||||
outerHtml: target.outerHtml,
|
||||
outerHtml,
|
||||
};
|
||||
act(() => {
|
||||
root.render(
|
||||
|
||||
Reference in New Issue
Block a user