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:
mehmet turac
2026-05-22 16:11:01 +08:00
committed by GitHub
parent 6273874e30
commit 45c77fac41
5 changed files with 375 additions and 25 deletions
+5
View File
@@ -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 }));
+212 -20
View File
@@ -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,
+26
View File
@@ -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(