Implement manual edit mode (#620)

This commit is contained in:
Caprika 2026-05-06 16:13:52 +08:00 committed by GitHub
parent 33255a8fdf
commit 8eb9b1b506
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 3539 additions and 12 deletions

View file

@ -39,9 +39,11 @@
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/jsdom": "^28.0.1",
"@types/node": "^20.17.10",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"jsdom": "^29.1.0",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
},

View file

@ -19,6 +19,7 @@ import {
LiveArtifactRefreshError,
refreshLiveArtifact,
updateDeployConfig,
writeProjectTextFile,
} from '../providers/registry';
import type { ProjectFilePreview } from '../providers/registry';
import {
@ -60,6 +61,15 @@ import type {
PreviewCommentMember,
PreviewCommentTarget,
} from '../types';
import { ManualEditPanel, emptyManualEditDraft, type ManualEditDraft } from './ManualEditPanel';
import {
applyManualEditPatch,
readManualEditAttributes,
readManualEditFields,
readManualEditOuterHtml,
readManualEditStyles,
} from '../edit-mode/source-patches';
import type { ManualEditBridgeMessage, ManualEditHistoryEntry, ManualEditPatch, ManualEditTarget } from '../edit-mode/types';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
type SlideState = { active: number; count: number };
@ -171,6 +181,7 @@ interface Props {
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>;
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<void> | void;
onFileSaved?: () => Promise<void> | void;
}
export function FileViewer({
@ -184,6 +195,7 @@ export function FileViewer({
onSavePreviewComment,
onRemovePreviewComment,
onSendBoardCommentAttachments,
onFileSaved,
}: Props) {
const rendererMatch = artifactRendererRegistry.resolve({
file,
@ -203,6 +215,7 @@ export function FileViewer({
onSavePreviewComment={onSavePreviewComment}
onRemovePreviewComment={onRemovePreviewComment}
onSendBoardCommentAttachments={onSendBoardCommentAttachments}
onFileSaved={onFileSaved}
/>
);
}
@ -2010,6 +2023,7 @@ function HtmlViewer({
onSavePreviewComment,
onRemovePreviewComment,
onSendBoardCommentAttachments,
onFileSaved,
}: {
projectId: string;
file: ProjectFile;
@ -2021,6 +2035,7 @@ function HtmlViewer({
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>;
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<void> | void;
onFileSaved?: () => Promise<void> | void;
}) {
const t = useT();
const [mode, setMode] = useState<'preview' | 'source'>('preview');
@ -2049,6 +2064,15 @@ function HtmlViewer({
const [reloadKey, setReloadKey] = useState(0);
const [boardMode, setBoardMode] = useState(false);
const [boardTool, setBoardTool] = useState<BoardTool>('inspect');
const [manualEditMode, setManualEditMode] = useState(false);
const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([]);
const [selectedManualEditTarget, setSelectedManualEditTarget] = useState<ManualEditTarget | null>(null);
const [manualEditDraft, setManualEditDraft] = useState<ManualEditDraft>(() => emptyManualEditDraft());
const [manualEditHistory, setManualEditHistory] = useState<ManualEditHistoryEntry[]>([]);
const [manualEditUndone, setManualEditUndone] = useState<ManualEditHistoryEntry[]>([]);
const [manualEditError, setManualEditError] = useState<string | null>(null);
const [manualEditSaving, setManualEditSaving] = useState(false);
const manualEditSavingRef = useRef(false);
// Opt back into the legacy inline-asset srcDoc path via `?forceInline=1`
// on the host page. Lets users escape-hatch around the URL-load default
// for non-deck HTML that depends on the in-iframe localStorage shim.
@ -2130,7 +2154,7 @@ function HtmlViewer({
const useUrlLoadPreview = shouldUrlLoadHtmlPreview({
mode,
isDeck: effectiveDeck,
commentMode: boardMode,
commentMode: boardMode || manualEditMode,
forceInline,
});
const previewSrcUrl = useMemo(
@ -2156,9 +2180,10 @@ function HtmlViewer({
deck: effectiveDeck,
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
commentBridge: boardMode,
commentBridge: boardMode && !manualEditMode,
editBridge: manualEditMode,
}) : ''),
[previewSource, effectiveDeck, projectId, file.name, previewStateKey, boardMode],
[previewSource, effectiveDeck, projectId, file.name, previewStateKey, boardMode, manualEditMode],
);
useEffect(() => {
@ -2188,10 +2213,17 @@ function HtmlViewer({
win.postMessage({ type: 'od:comment-mode', enabled: boardMode, mode: boardTool }, '*');
}, [boardMode, boardTool, srcDoc]);
function syncCommentMode() {
useEffect(() => {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
}, [manualEditMode, srcDoc]);
function syncBridgeModes() {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od:comment-mode', enabled: boardMode, mode: boardTool }, '*');
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
}
useEffect(() => {
@ -2201,8 +2233,21 @@ function HtmlViewer({
setCommentDraft('');
setQueuedBoardNotes([]);
setStrokePoints([]);
setManualEditTargets([]);
setSelectedManualEditTarget(null);
setManualEditDraft(emptyManualEditDraft());
setManualEditHistory([]);
setManualEditUndone([]);
setManualEditError(null);
}, [file.name]);
useEffect(() => {
if (source == null) return;
setManualEditDraft((current) => (
current.fullSource === source ? current : { ...current, fullSource: source }
));
}, [source]);
useEffect(() => {
if (!boardMode) {
setActiveCommentTarget((current) => (current ? null : current));
@ -2321,6 +2366,168 @@ function HtmlViewer({
return () => window.removeEventListener('message', onMessage);
}, [boardMode, file.name, previewComments]);
useEffect(() => {
if (!manualEditMode) {
setManualEditTargets([]);
setSelectedManualEditTarget(null);
setManualEditError(null);
return;
}
function onMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as ManualEditBridgeMessage | null;
if (!data?.type) return;
if (data.type === 'od-edit-targets' && Array.isArray(data.targets)) {
setManualEditTargets(data.targets);
setSelectedManualEditTarget((current) =>
current ? data.targets.find((target) => target.id === current.id) ?? null : current,
);
return;
}
if (data.type === 'od-edit-select') {
selectManualEditTarget(data.target);
}
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [manualEditMode, source]);
function selectManualEditTarget(target: ManualEditTarget) {
const base = source ?? '';
const fields = readManualEditFields(base, target.id);
setSelectedManualEditTarget(target);
setManualEditDraft({
text: fields.text ?? target.fields.text ?? target.text,
href: fields.href ?? target.fields.href ?? '',
src: fields.src ?? target.fields.src ?? '',
alt: fields.alt ?? target.fields.alt ?? '',
styles: readManualEditStyles(base, target.id),
attributesText: JSON.stringify(readManualEditAttributes(base, target.id), null, 2),
outerHtml: readManualEditOuterHtml(base, target.id) || target.outerHtml,
fullSource: base,
});
setManualEditError(null);
}
async function applyManualEdit(patch: ManualEditPatch, label: string) {
if (manualEditSavingRef.current) return;
if (source == null) return;
manualEditSavingRef.current = true;
setManualEditSaving(true);
setManualEditError(null);
try {
const baseSource = source;
const result = applyManualEditPatch(baseSource, patch);
if (!result.ok) {
setManualEditError(result.error ?? 'Could not apply edit.');
return;
}
if (!(await confirmManualEditHistorySource(
baseSource,
'The file changed outside manual edit mode. Refreshing before applying manual edits.',
))) return;
const saved = await writeProjectTextFile(projectId, file.name, result.source, {
artifactManifest: file.artifactManifest,
});
if (!saved) {
setManualEditError('Could not save the edited file.');
return;
}
const entry: ManualEditHistoryEntry = {
id: `${Date.now()}-${manualEditHistory.length}`,
label,
patch,
beforeSource: baseSource,
afterSource: result.source,
createdAt: Date.now(),
};
setSource(result.source);
setInlinedSource(null);
setManualEditHistory((current) => [entry, ...current]);
setManualEditUndone([]);
setManualEditDraft((current) => ({ ...current, fullSource: result.source }));
await onFileSaved?.();
} finally {
manualEditSavingRef.current = false;
setManualEditSaving(false);
}
}
async function confirmManualEditHistorySource(expectedSource: string, message: string): Promise<boolean> {
const persisted = await fetchProjectFileText(projectId, file.name, {
cache: 'no-store',
cacheBustKey: Date.now(),
});
if (persisted == null || persisted === expectedSource) return true;
setSource(persisted);
setInlinedSource(null);
setManualEditHistory([]);
setManualEditUndone([]);
setManualEditDraft((current) => ({ ...current, fullSource: persisted }));
setManualEditError(message);
return false;
}
async function undoManualEdit() {
if (manualEditSavingRef.current) return;
const [latest, ...rest] = manualEditHistory;
if (!latest) return;
manualEditSavingRef.current = true;
setManualEditSaving(true);
try {
if (!(await confirmManualEditHistorySource(
latest.afterSource,
'The file changed outside manual edit mode. History was cleared to avoid overwriting newer content.',
))) return;
const saved = await writeProjectTextFile(projectId, file.name, latest.beforeSource, {
artifactManifest: file.artifactManifest,
});
if (!saved) {
setManualEditError('Could not save the undo result.');
return;
}
setSource(latest.beforeSource);
setInlinedSource(null);
setManualEditHistory(rest);
setManualEditUndone((current) => [latest, ...current]);
setManualEditDraft((current) => ({ ...current, fullSource: latest.beforeSource }));
await onFileSaved?.();
} finally {
manualEditSavingRef.current = false;
setManualEditSaving(false);
}
}
async function redoManualEdit() {
if (manualEditSavingRef.current) return;
const [latest, ...rest] = manualEditUndone;
if (!latest) return;
manualEditSavingRef.current = true;
setManualEditSaving(true);
try {
if (!(await confirmManualEditHistorySource(
latest.beforeSource,
'The file changed outside manual edit mode. History was cleared to avoid overwriting newer content.',
))) return;
const saved = await writeProjectTextFile(projectId, file.name, latest.afterSource, {
artifactManifest: file.artifactManifest,
});
if (!saved) {
setManualEditError('Could not save the redo result.');
return;
}
setSource(latest.afterSource);
setInlinedSource(null);
setManualEditUndone(rest);
setManualEditHistory((current) => [latest, ...current]);
setManualEditDraft((current) => ({ ...current, fullSource: latest.afterSource }));
await onFileSaved?.();
} finally {
manualEditSavingRef.current = false;
setManualEditSaving(false);
}
}
function postSlide(action: 'next' | 'prev' | 'first' | 'last') {
const win = iframeRef.current?.contentWindow;
if (!win) return;
@ -2711,6 +2918,7 @@ function HtmlViewer({
clearBoardComposer();
return;
}
setManualEditMode(false);
activateBoard(boardTool);
}}
>
@ -2764,6 +2972,24 @@ function HtmlViewer({
</button>
</>
) : null}
<button
className={`viewer-action${manualEditMode ? ' active' : ''}`}
type="button"
data-testid="manual-edit-mode-toggle"
title={t('fileViewer.edit')}
aria-pressed={manualEditMode}
onClick={() => {
if (!manualEditMode) {
setBoardMode(false);
clearBoardComposer();
setMode('preview');
}
setManualEditMode((value) => !value);
}}
>
<Icon name="edit" size={13} />
<span>{t('fileViewer.edit')}</span>
</button>
<span className="viewer-divider" aria-hidden />
<button
type="button"
@ -2980,8 +3206,35 @@ function HtmlViewer({
{source === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : mode === 'preview' ? (
<div className="comment-preview-layer">
<div className="comment-frame-clip">
<div className={manualEditMode ? 'manual-edit-workspace' : 'comment-preview-layer'}>
{manualEditMode ? (
<ManualEditPanel
targets={manualEditTargets}
selectedTarget={selectedManualEditTarget}
draft={manualEditDraft}
history={manualEditHistory}
error={manualEditError}
canUndo={manualEditHistory.length > 0}
canRedo={manualEditUndone.length > 0}
busy={manualEditSaving}
onSelectTarget={selectManualEditTarget}
onDraftChange={setManualEditDraft}
onApplyPatch={(patch, label) => {
void applyManualEdit(patch, label);
}}
onError={setManualEditError}
onCancelDraft={() => {
if (selectedManualEditTarget) selectManualEditTarget(selectedManualEditTarget);
}}
onUndo={() => {
void undoManualEdit();
}}
onRedo={() => {
void redoManualEdit();
}}
/>
) : null}
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
<div
style={{
width: `${100 / previewScale}%`,
@ -2998,7 +3251,7 @@ function HtmlViewer({
title={file.name}
sandbox="allow-scripts"
src={previewSrcUrl}
onLoad={syncCommentMode}
onLoad={syncBridgeModes}
/>
) : (
<iframe
@ -3008,7 +3261,7 @@ function HtmlViewer({
title={file.name}
sandbox="allow-scripts"
srcDoc={srcDoc}
onLoad={syncCommentMode}
onLoad={syncBridgeModes}
/>
)}
</div>

View file

@ -520,6 +520,7 @@ export function FileWorkspace({
onSavePreviewComment={onSavePreviewComment}
onRemovePreviewComment={onRemovePreviewComment}
onSendBoardCommentAttachments={onSendBoardCommentAttachments}
onFileSaved={onRefreshFiles}
/>
) : (
<div className="viewer-empty">

View file

@ -0,0 +1,381 @@
import { useEffect, useState } from 'react';
import { useT } from '../i18n';
import { emptyManualEditStyles, type ManualEditHistoryEntry, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../edit-mode/types';
export interface ManualEditDraft {
text: string;
href: string;
src: string;
alt: string;
styles: ManualEditStyles;
attributesText: string;
outerHtml: string;
fullSource: string;
}
export type ManualEditTab = 'content' | 'style' | 'attributes' | 'html' | 'source';
export function emptyManualEditDraft(source = ''): ManualEditDraft {
return {
text: '',
href: '',
src: '',
alt: '',
styles: emptyManualEditStyles(),
attributesText: '{}',
outerHtml: '',
fullSource: source,
};
}
export function ManualEditPanel({
targets,
selectedTarget,
draft,
history,
error,
canUndo,
canRedo,
busy = false,
onSelectTarget,
onDraftChange,
onApplyPatch,
onError,
onCancelDraft,
onUndo,
onRedo,
}: {
targets: ManualEditTarget[];
selectedTarget: ManualEditTarget | null;
draft: ManualEditDraft;
history: ManualEditHistoryEntry[];
error: string | null;
canUndo: boolean;
canRedo: boolean;
busy?: boolean;
onSelectTarget: (target: ManualEditTarget) => void;
onDraftChange: (draft: ManualEditDraft) => void;
onApplyPatch: (patch: ManualEditPatch, label: string) => void;
onError: (message: string) => void;
onCancelDraft: () => void;
onUndo: () => void;
onRedo: () => void;
}) {
const t = useT();
const [tab, setTab] = useState<ManualEditTab>('content');
useEffect(() => {
setTab('content');
}, [selectedTarget?.id]);
return (
<>
<aside className="manual-edit-layers">
<div className="manual-edit-panel-head">
<h3>{t('manualEdit.layers')}</h3>
<span>{t('manualEdit.editableCount', { count: targets.length })}</span>
</div>
<div className="manual-edit-layer-list">
{targets.map((target) => (
<button
key={target.id}
type="button"
className={`manual-edit-layer-row ${selectedTarget?.id === target.id ? 'selected' : ''}`}
onClick={() => onSelectTarget(target)}
>
<strong>{target.label}</strong>
<span>{target.kind} - {target.id}</span>
</button>
))}
</div>
</aside>
<aside className="manual-edit-right">
<section className="manual-edit-modal">
<div className="manual-edit-modal-head">
<div>
<span>{t('manualEdit.title')}</span>
<h3>{selectedTarget?.label ?? t('manualEdit.selectLayer')}</h3>
</div>
<em>{selectedTarget?.kind ?? 'none'}</em>
</div>
{!selectedTarget ? (
<div className="manual-edit-empty">{t('manualEdit.empty')}</div>
) : (
<>
<div className="manual-edit-meta">
<div>
<strong>{selectedTarget.tagName}</strong>
<span>{selectedTarget.id}</span>
</div>
<code>{selectedTarget.className || t('manualEdit.noClass')}</code>
</div>
<div className="manual-edit-tabs" role="tablist" aria-label={t('manualEdit.tabsAria')}>
<EditTabButton label={t('manualEdit.tabContent')} tab="content" active={tab === 'content'} onClick={setTab} />
<EditTabButton label={t('manualEdit.tabStyle')} tab="style" active={tab === 'style'} onClick={setTab} />
<EditTabButton label={t('manualEdit.tabAttributes')} tab="attributes" active={tab === 'attributes'} onClick={setTab} />
<EditTabButton label={t('manualEdit.tabHtml')} tab="html" active={tab === 'html'} onClick={setTab} />
<EditTabButton label={t('manualEdit.tabSource')} tab="source" active={tab === 'source'} onClick={setTab} />
</div>
<div className="manual-edit-tab-body">
{tab === 'content' ? (
<ContentEditor target={selectedTarget} draft={draft} onDraftChange={onDraftChange} />
) : null}
{tab === 'style' ? (
<StyleEditor
styles={draft.styles}
onChange={(styles) => onDraftChange({ ...draft, styles })}
/>
) : null}
{tab === 'attributes' ? (
<label className="manual-edit-field">
<span>{t('manualEdit.attributesJson')}</span>
<textarea
className="manual-edit-code"
value={draft.attributesText}
onChange={(event) => onDraftChange({ ...draft, attributesText: event.currentTarget.value })}
/>
</label>
) : null}
{tab === 'html' ? (
<label className="manual-edit-field">
<span>{t('manualEdit.selectedHtml')}</span>
<textarea
className="manual-edit-code tall"
value={draft.outerHtml}
onChange={(event) => onDraftChange({ ...draft, outerHtml: event.currentTarget.value })}
/>
</label>
) : null}
{tab === 'source' ? (
<label className="manual-edit-field">
<span>{t('manualEdit.fullSource')}</span>
<textarea
className="manual-edit-code tall"
value={draft.fullSource}
onChange={(event) => onDraftChange({ ...draft, fullSource: event.currentTarget.value })}
/>
</label>
) : null}
</div>
{error ? <div className="manual-edit-error">{error}</div> : null}
<div className="manual-edit-actions">
<button type="button" onClick={onCancelDraft} disabled={busy}>{t('common.cancel')}</button>
{tab === 'content' ? (
<button type="button" className="primary" disabled={busy} onClick={() => onApplyPatch(contentPatch(selectedTarget, draft), `Content: ${selectedTarget.label}`)}>
{t('manualEdit.applyContent')}
</button>
) : null}
{tab === 'style' ? (
<button type="button" className="primary" disabled={busy} onClick={() => onApplyPatch({ id: selectedTarget.id, kind: 'set-style', styles: draft.styles }, `Style: ${selectedTarget.label}`)}>
{t('manualEdit.applyStyle')}
</button>
) : null}
{tab === 'attributes' ? (
<button
type="button"
className="primary"
disabled={busy}
onClick={() => {
try {
onApplyPatch(parseAttributesPatch(selectedTarget.id, draft.attributesText), `Attributes: ${selectedTarget.label}`);
} catch (err) {
onError(err instanceof Error ? err.message : t('manualEdit.invalidAttributes'));
}
}}
>
{t('manualEdit.applyAttributes')}
</button>
) : null}
{tab === 'html' ? (
<button type="button" className="primary" disabled={busy} onClick={() => onApplyPatch({ id: selectedTarget.id, kind: 'set-outer-html', html: draft.outerHtml }, `HTML: ${selectedTarget.label}`)}>
{t('manualEdit.applyHtml')}
</button>
) : null}
{tab === 'source' ? (
<button type="button" className="primary" disabled={busy} onClick={() => onApplyPatch({ kind: 'set-full-source', source: draft.fullSource }, 'Full source')}>
{t('manualEdit.applySource')}
</button>
) : null}
</div>
</>
)}
</section>
<section className="manual-edit-changes">
<div className="manual-edit-panel-head">
<h3>{t('manualEdit.changes')}</h3>
<span>{history.length}</span>
</div>
<div className="manual-edit-history-actions">
<button type="button" onClick={onUndo} disabled={busy || !canUndo}>{t('manualEdit.undo')}</button>
<button type="button" onClick={onRedo} disabled={busy || !canRedo}>{t('manualEdit.redo')}</button>
</div>
{history.length === 0 ? (
<div className="manual-edit-empty">{t('manualEdit.noChanges')}</div>
) : (
<div className="manual-edit-history-list">
{history.map((entry) => (
<article key={entry.id} className="manual-edit-history-entry">
<strong>{entry.label}</strong>
<code>{manualEditPatchSummary(entry.patch)}</code>
</article>
))}
</div>
)}
</section>
</aside>
</>
);
}
function ContentEditor({
target,
draft,
onDraftChange,
}: {
target: ManualEditTarget;
draft: ManualEditDraft;
onDraftChange: (draft: ManualEditDraft) => void;
}) {
const t = useT();
if (target.kind === 'image') {
return (
<>
<label className="manual-edit-field">
<span>{t('manualEdit.imageUrl')}</span>
<input value={draft.src} onChange={(event) => onDraftChange({ ...draft, src: event.currentTarget.value })} />
</label>
<label className="manual-edit-field">
<span>{t('manualEdit.altText')}</span>
<input value={draft.alt} onChange={(event) => onDraftChange({ ...draft, alt: event.currentTarget.value })} />
</label>
</>
);
}
return (
<>
<label className="manual-edit-field">
<span>{target.kind === 'link' ? t('manualEdit.label') : t('manualEdit.text')}</span>
<textarea value={draft.text} onChange={(event) => onDraftChange({ ...draft, text: event.currentTarget.value })} />
</label>
{target.kind === 'link' ? (
<label className="manual-edit-field">
<span>{t('manualEdit.href')}</span>
<input value={draft.href} onChange={(event) => onDraftChange({ ...draft, href: event.currentTarget.value })} />
</label>
) : null}
</>
);
}
function StyleEditor({
styles,
onChange,
}: {
styles: ManualEditStyles;
onChange: (styles: ManualEditStyles) => void;
}) {
const t = useT();
const update = (key: keyof ManualEditStyles, value: string) => onChange({ ...styles, [key]: value });
return (
<div className="manual-edit-style-grid">
<StyleInput label={t('manualEdit.textColor')} value={styles.color} onChange={(value) => update('color', value)} />
<StyleInput label={t('manualEdit.background')} value={styles.backgroundColor} onChange={(value) => update('backgroundColor', value)} />
<StyleInput label={t('manualEdit.fontSize')} value={styles.fontSize} placeholder="46px" onChange={(value) => update('fontSize', value)} />
<label className="manual-edit-field compact">
<span>{t('manualEdit.weight')}</span>
<select value={styles.fontWeight} onChange={(event) => update('fontWeight', event.currentTarget.value)}>
<option value="">default</option>
<option value="400">400</option>
<option value="500">500</option>
<option value="600">600</option>
<option value="700">700</option>
<option value="800">800</option>
</select>
</label>
<label className="manual-edit-field compact">
<span>{t('manualEdit.align')}</span>
<select value={styles.textAlign} onChange={(event) => update('textAlign', event.currentTarget.value)}>
<option value="">default</option>
<option value="left">left</option>
<option value="center">center</option>
<option value="right">right</option>
</select>
</label>
<StyleInput label={t('manualEdit.padding')} value={styles.padding} placeholder="24px" onChange={(value) => update('padding', value)} />
<StyleInput label={t('manualEdit.margin')} value={styles.margin} placeholder="0 0 16px" onChange={(value) => update('margin', value)} />
<StyleInput label={t('manualEdit.radius')} value={styles.borderRadius} placeholder="8px" onChange={(value) => update('borderRadius', value)} />
<StyleInput label={t('manualEdit.border')} value={styles.border} placeholder="1px solid #d0d5dd" wide onChange={(value) => update('border', value)} />
<StyleInput label={t('manualEdit.width')} value={styles.width} placeholder="100%" onChange={(value) => update('width', value)} />
<StyleInput label={t('manualEdit.minHeight')} value={styles.minHeight} placeholder="240px" onChange={(value) => update('minHeight', value)} />
</div>
);
}
function StyleInput({
label,
value,
placeholder,
wide,
onChange,
}: {
label: string;
value: string;
placeholder?: string;
wide?: boolean;
onChange: (value: string) => void;
}) {
return (
<label className={`manual-edit-field compact ${wide ? 'wide' : ''}`}>
<span>{label}</span>
<input value={value} placeholder={placeholder} onChange={(event) => onChange(event.currentTarget.value)} />
</label>
);
}
function EditTabButton({
tab,
label,
active,
onClick,
}: {
tab: ManualEditTab;
label: string;
active: boolean;
onClick: (tab: ManualEditTab) => void;
}) {
return (
<button type="button" className={active ? 'active' : ''} role="tab" aria-selected={active} onClick={() => onClick(tab)}>
{label}
</button>
);
}
function contentPatch(target: ManualEditTarget, draft: ManualEditDraft): ManualEditPatch {
if (target.kind === 'link') return { id: target.id, kind: 'set-link', text: draft.text, href: draft.href };
if (target.kind === 'image') return { id: target.id, kind: 'set-image', src: draft.src, alt: draft.alt };
return { id: target.id, kind: 'set-text', value: draft.text };
}
export function manualEditPatchSummary(patch: ManualEditPatch): string {
if (patch.kind === 'set-full-source') {
return JSON.stringify({ kind: patch.kind, bytes: patch.source.length });
}
if (patch.kind === 'set-outer-html') {
return JSON.stringify({ id: patch.id, kind: patch.kind, bytes: patch.html.length });
}
return JSON.stringify(patch);
}
function parseAttributesPatch(id: string, attributesText: string): ManualEditPatch {
const parsed = JSON.parse(attributesText) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Attributes must be a JSON object.');
}
return {
id,
kind: 'set-attributes',
attributes: Object.fromEntries(Object.entries(parsed).map(([key, value]) => [key, String(value)])),
};
}

View file

@ -0,0 +1,204 @@
export const MANUAL_EDIT_DISCOVERY_SELECTOR = 'main, nav, section, article, header, footer, div, h1, h2, h3, p, a, button, img, strong, span';
export const MANUAL_EDIT_SOURCE_PATH_ATTR = 'data-od-source-path';
export const MANUAL_EDIT_HOST_NODE_SELECTOR = [
'[data-od-sandbox-shim]',
'[data-od-deck-bridge]',
'[data-od-comment-bridge]',
'[data-od-edit-bridge]',
'[data-od-comment-bridge-style]',
'[data-od-edit-bridge-style]',
'[data-od-deck-fix]',
].join(',');
export function manualEditDomPathForElement(el: Element): string {
const parts: number[] = [];
let node: Element | null = el;
while (node && node !== node.ownerDocument.body) {
const parentEl: Element | null = node.parentElement;
if (!parentEl) break;
const children = Array.from(parentEl.children).filter((child) => !isManualEditHostNode(child));
parts.unshift(children.indexOf(node));
node = parentEl;
}
return parts.length ? `path-${parts.join('-')}` : '';
}
export function isManualEditHostNode(el: Element): boolean {
return el.matches(MANUAL_EDIT_HOST_NODE_SELECTOR);
}
export function manualEditStableIdForElement(el: Element): string {
const explicit = el.getAttribute('data-od-id');
if (explicit) return explicit;
const generated = el.getAttribute(MANUAL_EDIT_SOURCE_PATH_ATTR) || el.getAttribute('data-od-runtime-id') || manualEditDomPathForElement(el);
if (generated) el.setAttribute('data-od-runtime-id', generated);
return generated || 'unknown';
}
export function isMeaningfulManualEditElement(el: Element, rect: Pick<DOMRect, 'width' | 'height'>): boolean {
return isSourceMappableManualEditElement(el) && el.matches(MANUAL_EDIT_DISCOVERY_SELECTOR) && rect.width >= 4 && rect.height >= 4;
}
export function isSourceMappableManualEditElement(el: Element): boolean {
return el.hasAttribute('data-od-id') || el.hasAttribute(MANUAL_EDIT_SOURCE_PATH_ATTR);
}
export function buildManualEditBridge(enabled: boolean): string {
return `<script data-od-edit-bridge>(function(){
var enabled = ${JSON.stringify(enabled)};
var discoverySelector = ${JSON.stringify(MANUAL_EDIT_DISCOVERY_SELECTOR)};
var hostNodeSelector = ${JSON.stringify(MANUAL_EDIT_HOST_NODE_SELECTOR)};
var sourcePathAttr = ${JSON.stringify(MANUAL_EDIT_SOURCE_PATH_ATTR)};
var styleProps = ['color','backgroundColor','fontSize','fontWeight','textAlign','padding','margin','borderRadius','border','width','minHeight'];
function isHostNode(el){
return !!(el && el.matches && el.matches(hostNodeSelector));
}
function domPath(el){
var parts = [];
var node = el;
while (node && node !== document.body) {
var parent = node.parentElement;
if (!parent) break;
var children = Array.prototype.slice.call(parent.children).filter(function(child){ return !isHostNode(child); });
parts.unshift(children.indexOf(node));
node = parent;
}
return parts.length ? 'path-' + parts.join('-') : '';
}
function stableId(el){
var explicit = el.getAttribute('data-od-id');
if (explicit) return explicit;
var generated = el.getAttribute(sourcePathAttr) || el.getAttribute('data-od-runtime-id') || domPath(el);
if (generated) el.setAttribute('data-od-runtime-id', generated);
return generated || 'unknown';
}
function isSourceMappable(el){
return !!(el && el.hasAttribute && (el.hasAttribute('data-od-id') || el.hasAttribute(sourcePathAttr)));
}
function isDiscoveryTarget(el){
return !!(el && el.matches && el.matches(discoverySelector));
}
function isPrimaryTarget(el){
if (!el || !el.hasAttribute) return false;
if (el.hasAttribute('data-od-id') || el.hasAttribute('data-od-edit')) return true;
var tag = el.tagName ? el.tagName.toLowerCase() : '';
return tag === 'a' || tag === 'button';
}
function inferKind(el){
var explicit = el.getAttribute('data-od-edit');
if (explicit) return explicit;
var tag = el.tagName ? el.tagName.toLowerCase() : '';
if (tag === 'a') return 'link';
if (tag === 'img') return 'image';
if (['section','main','nav','div','article','header','footer'].indexOf(tag) >= 0) return 'container';
return 'text';
}
function labelFor(el, id, kind){
var explicit = el.getAttribute('data-od-label');
if (explicit) return explicit;
var tag = el.tagName ? el.tagName.toLowerCase() : 'element';
var text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
if (text) return text.slice(0, 42);
if (kind === 'image') return el.getAttribute('alt') || id;
return tag + ' #' + id;
}
function attrsFor(el){
var attrs = {};
for (var i = 0; i < el.attributes.length; i++) {
var attr = el.attributes[i];
if (!attr || attr.name.indexOf('data-od-runtime') === 0) continue;
attrs[attr.name] = attr.value;
}
return attrs;
}
function stylesFor(el){
var computed = window.getComputedStyle(el);
var styles = {};
styleProps.forEach(function(prop){ styles[prop] = el.style[prop] || computed[prop] || ''; });
return styles;
}
function targetFrom(el, includeOuterHtml){
var rect = el.getBoundingClientRect();
var kind = inferKind(el);
var id = stableId(el);
var fields = {};
if (kind === 'link') {
fields.text = (el.textContent || '').trim();
fields.href = el.getAttribute('href') || '';
} else if (kind === 'image') {
fields.src = el.getAttribute('src') || '';
fields.alt = el.getAttribute('alt') || '';
} else {
fields.text = (el.textContent || '').trim();
}
return {
id: id,
kind: kind,
label: labelFor(el, id, kind),
tagName: el.tagName ? el.tagName.toLowerCase() : 'element',
className: typeof el.className === 'string' ? el.className : '',
text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 180),
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
fields: fields,
attributes: attrsFor(el),
styles: stylesFor(el),
outerHtml: includeOuterHtml ? (el.outerHTML || '').replace(/\\sdata-od-runtime-id="[^"]*"/g, '').replace(/\\sdata-od-source-path="[^"]*"/g, '') : ''
};
}
function allTargets(){
var nodes = document.body ? document.body.querySelectorAll(discoverySelector) : [];
var targets = [];
for (var i = 0; i < nodes.length; i++) {
var rect = nodes[i].getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) continue;
if (!isSourceMappable(nodes[i])) continue;
targets.push(targetFrom(nodes[i], false));
}
return targets;
}
function postTargets(){
if (!enabled) return;
window.parent.postMessage({ type: 'od-edit-targets', targets: allTargets() }, '*');
}
function closestTarget(event){
var el = event.target;
var fallback = null;
while (el && el !== document.documentElement) {
if (el !== document.body && el !== document.documentElement && isSourceMappable(el) && isDiscoveryTarget(el)) {
if (isPrimaryTarget(el)) return el;
if (!fallback) fallback = el;
}
el = el.parentElement;
}
return fallback;
}
window.addEventListener('message', function(ev){
if (!ev.data || ev.data.type !== 'od-edit-mode') return;
enabled = !!ev.data.enabled;
document.documentElement.toggleAttribute('data-od-edit-mode', enabled);
if (enabled) setTimeout(postTargets, 0);
});
document.addEventListener('click', function(ev){
if (!enabled) return;
var el = closestTarget(ev);
if (!el) return;
ev.preventDefault();
ev.stopPropagation();
window.parent.postMessage({ type: 'od-edit-select', target: targetFrom(el, true) }, '*');
}, true);
window.addEventListener('resize', postTargets);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postTargets);
else setTimeout(postTargets, 0);
document.documentElement.toggleAttribute('data-od-edit-mode', enabled);
})();</script>`;
}
export function buildManualEditBridgeStyle(): string {
return `<style data-od-edit-bridge-style>
html[data-od-edit-mode] body * { cursor: pointer !important; }
html[data-od-edit-mode] [data-od-id],
html[data-od-edit-mode] [data-od-runtime-id] { outline: 1px dashed rgba(37, 99, 235, 0.35); outline-offset: 3px; }
html[data-od-edit-mode] [data-od-id]:hover,
html[data-od-edit-mode] [data-od-runtime-id]:hover { outline: 2px solid #2563eb; }
</style>`;
}

View file

@ -0,0 +1,242 @@
import { emptyManualEditStyles, type ManualEditFields, type ManualEditPatch, type ManualEditStyles } from './types';
export interface ManualEditPatchResult {
ok: boolean;
source: string;
error?: string;
}
export function applyManualEditPatch(source: string, patch: ManualEditPatch): ManualEditPatchResult {
if (patch.kind === 'set-full-source') return { ok: true, source: patch.source };
const doc = parseSource(source);
if (!doc) return { ok: false, source, error: 'Could not parse source.' };
if (patch.kind === 'set-token') {
const changed = setCssToken(doc, patch.token, patch.value);
return changed
? { ok: true, source: serializeSource(doc, source) }
: { ok: false, source, error: `Token not found: ${patch.token}` };
}
const el = findEditableElement(doc, patch.id);
if (!el) return { ok: false, source, error: `Target not found: ${patch.id}` };
if (patch.kind === 'set-text') {
if (hasElementChildren(el)) {
return { ok: false, source, error: 'This element contains nested markup. Use the HTML tab instead.' };
}
el.textContent = patch.value;
} else if (patch.kind === 'set-link') {
if (hasElementChildren(el)) {
const currentText = el.textContent?.trim() ?? '';
if (patch.text.trim() !== currentText) {
return { ok: false, source, error: 'This link contains nested markup. Use the HTML tab to change its label.' };
}
} else {
el.textContent = patch.text;
}
el.setAttribute('href', patch.href);
} else if (patch.kind === 'set-image') {
el.setAttribute('src', patch.src);
el.setAttribute('alt', patch.alt);
} else if (patch.kind === 'set-style') {
setInlineStyles(el as HTMLElement, patch.styles);
} else if (patch.kind === 'set-attributes') {
setAttributes(el, patch.attributes);
} else if (patch.kind === 'set-outer-html') {
const replaced = replaceOuterHtml(doc, el, patch.html);
if (!replaced.ok) return { ok: false, source, error: replaced.error };
}
return { ok: true, source: serializeSource(doc, source) };
}
export function readManualEditFields(source: string, id: string): ManualEditFields {
const doc = parseSource(source);
const el = doc ? findEditableElement(doc, id) : null;
if (!el) return {};
const kind = inferKind(el);
if (kind === 'link') {
return {
text: el.textContent?.trim() ?? '',
href: el.getAttribute('href') ?? '',
};
}
if (kind === 'image') {
return {
src: el.getAttribute('src') ?? '',
alt: el.getAttribute('alt') ?? '',
};
}
return { text: el.textContent?.trim() ?? '' };
}
export function readManualEditStyles(source: string, id: string): ManualEditStyles {
const doc = parseSource(source);
const el = doc ? findEditableElement(doc, id) : null;
if (!el) return emptyManualEditStyles();
const style = (el as HTMLElement).style;
return {
color: style.color,
backgroundColor: style.backgroundColor,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
textAlign: style.textAlign,
padding: style.padding,
margin: style.margin,
borderRadius: style.borderRadius,
border: style.border,
width: style.width,
minHeight: style.minHeight,
};
}
export function readManualEditAttributes(source: string, id: string): Record<string, string> {
const doc = parseSource(source);
const el = doc ? findEditableElement(doc, id) : null;
if (!el) return {};
const attrs: Record<string, string> = {};
Array.from(el.attributes).forEach((attr) => {
if (attr.name === 'data-od-runtime-id') return;
attrs[attr.name] = attr.value;
});
return attrs;
}
export function readManualEditOuterHtml(source: string, id: string): string {
const doc = parseSource(source);
return (doc ? findEditableElement(doc, id)?.outerHTML : '') ?? '';
}
function parseSource(source: string): Document | null {
if (typeof DOMParser !== 'undefined') {
return new DOMParser().parseFromString(source, 'text/html');
}
if (typeof document !== 'undefined') {
const doc = document.implementation.createHTMLDocument('');
doc.documentElement.innerHTML = source;
return doc;
}
return null;
}
function serializeSource(doc: Document, originalSource: string): string {
if (!isFullHtmlDocument(originalSource)) return doc.body.innerHTML;
return `<!doctype html>\n${doc.documentElement.outerHTML}`;
}
function isFullHtmlDocument(source: string): boolean {
const normalized = firstSourceToken(source).slice(0, 32).toLowerCase();
return normalized.startsWith('<!doctype') || normalized.startsWith('<html');
}
function firstSourceToken(source: string): string {
let rest = source.trimStart();
while (rest.startsWith('<!--') || rest.startsWith('<?')) {
const close = rest.startsWith('<!--') ? '-->' : '?>';
const end = rest.indexOf(close);
if (end === -1) return rest;
rest = rest.slice(end + close.length).trimStart();
}
return rest;
}
function inferKind(el: Element): 'text' | 'link' | 'image' | 'container' {
const explicit = el.getAttribute('data-od-edit');
if (explicit === 'text' || explicit === 'link' || explicit === 'image' || explicit === 'container') return explicit;
const tag = el.tagName.toLowerCase();
if (tag === 'a') return 'link';
if (tag === 'img') return 'image';
if (['section', 'main', 'nav', 'div', 'article', 'header', 'footer'].includes(tag)) return 'container';
return 'text';
}
function findEditableElement(doc: Document, id: string): Element | null {
return (
doc.querySelector(`[data-od-id="${cssEscape(id)}"]`) ??
doc.querySelector(`[data-od-runtime-id="${cssEscape(id)}"]`) ??
findElementByPath(doc, id)
);
}
function findElementByPath(doc: Document, id: string): Element | null {
if (!id.startsWith('path-')) return null;
const indexes = id
.slice('path-'.length)
.split('-')
.map((part) => Number(part));
if (indexes.some((index) => !Number.isInteger(index) || index < 0)) return null;
let current: Element | null = doc.body;
for (const index of indexes) {
current = current?.children.item(index) ?? null;
if (!current) return null;
}
return current;
}
function hasElementChildren(el: Element): boolean {
return Array.from(el.children).some((child) => child.nodeType === 1);
}
function setInlineStyles(el: HTMLElement, styles: Partial<ManualEditStyles>): void {
for (const [name, value] of Object.entries(styles)) {
const cssName = camelToKebab(name);
if (typeof value !== 'string' || value.trim() === '') el.style.removeProperty(cssName);
else el.style.setProperty(cssName, value.trim());
}
}
function setAttributes(el: Element, attributes: Record<string, string>): void {
const protectedAttrs = new Set(['data-od-id', 'data-od-edit', 'data-od-label', 'data-od-runtime-id']);
for (const [name, value] of Object.entries(attributes)) {
if (!isSafeAttributeName(name) || protectedAttrs.has(name)) continue;
if (value.trim() === '') el.removeAttribute(name);
else el.setAttribute(name, value);
}
}
function replaceOuterHtml(doc: Document, el: Element, html: string): { ok: true } | { ok: false; error: string } {
const template = doc.createElement('template');
template.innerHTML = html.trim();
const elements = Array.from(template.content.children);
if (elements.length !== 1) return { ok: false, error: 'Replacement HTML must contain exactly one root element.' };
const next = elements[0]!;
if (el.getAttribute('data-od-id') && !next.getAttribute('data-od-id')) {
next.setAttribute('data-od-id', el.getAttribute('data-od-id') ?? '');
}
if (el.getAttribute('data-od-edit') && !next.getAttribute('data-od-edit')) {
next.setAttribute('data-od-edit', el.getAttribute('data-od-edit') ?? '');
}
el.replaceWith(next);
return { ok: true };
}
function setCssToken(doc: Document, token: string, value: string): boolean {
const styles = Array.from(doc.querySelectorAll('style'));
const pattern = new RegExp(`(${escapeRegExp(token)}\\s*:\\s*)([^;]+)(;)`);
for (const style of styles) {
const text = style.textContent ?? '';
if (!pattern.test(text)) continue;
style.textContent = text.replace(pattern, `$1${value}$3`);
return true;
}
return false;
}
function cssEscape(value: string): string {
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value);
return value.replace(/"/g, '\\"');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function camelToKebab(value: string): string {
return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
}
function isSafeAttributeName(value: string): boolean {
return /^[a-zA-Z_:][a-zA-Z0-9_:.-]*$/.test(value);
}

View file

@ -0,0 +1,90 @@
export type ManualEditKind = 'text' | 'link' | 'image' | 'container' | 'token';
export interface ManualEditRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ManualEditFields {
text?: string;
href?: string;
src?: string;
alt?: string;
}
export interface ManualEditStyles {
color: string;
backgroundColor: string;
fontSize: string;
fontWeight: string;
textAlign: string;
padding: string;
margin: string;
borderRadius: string;
border: string;
width: string;
minHeight: string;
}
export interface ManualEditTarget {
id: string;
kind: ManualEditKind;
label: string;
tagName: string;
className: string;
text: string;
rect: ManualEditRect;
fields: ManualEditFields;
attributes: Record<string, string>;
styles: ManualEditStyles;
outerHtml: string;
}
export type ManualEditPatch =
| { id: string; kind: 'set-text'; value: string }
| { id: string; kind: 'set-link'; text: string; href: string }
| { id: string; kind: 'set-image'; src: string; alt: string }
| { kind: 'set-token'; token: string; value: string }
| { id: string; kind: 'set-style'; styles: Partial<ManualEditStyles> }
| { id: string; kind: 'set-attributes'; attributes: Record<string, string> }
| { id: string; kind: 'set-outer-html'; html: string }
| { kind: 'set-full-source'; source: string };
export interface ManualEditHistoryEntry {
id: string;
label: string;
patch: ManualEditPatch;
beforeSource: string;
afterSource: string;
createdAt: number;
}
export interface ManualEditTargetMessage {
type: 'od-edit-targets';
targets: ManualEditTarget[];
}
export interface ManualEditSelectMessage {
type: 'od-edit-select';
target: ManualEditTarget;
}
export type ManualEditBridgeMessage = ManualEditTargetMessage | ManualEditSelectMessage;
export function emptyManualEditStyles(): ManualEditStyles {
return {
color: '',
backgroundColor: '',
fontSize: '',
fontWeight: '',
textAlign: '',
padding: '',
margin: '',
borderRadius: '',
border: '',
width: '',
minHeight: '',
};
}

View file

@ -595,6 +595,47 @@ export const ar: Dict = {
'fileViewer.comment': 'تعليق',
'fileViewer.edit': 'تعديل',
'fileViewer.draw': 'رسم',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'تصغير',
'fileViewer.zoomIn': 'تكبير',
'fileViewer.resetZoom': 'إعادة تعيين الزوم',

View file

@ -549,6 +549,47 @@ export const de: Dict = {
'fileViewer.comment': 'Kommentieren',
'fileViewer.edit': 'Bearbeiten',
'fileViewer.draw': 'Zeichnen',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'Verkleinern',
'fileViewer.zoomIn': 'Vergrößern',
'fileViewer.resetZoom': 'Zoom zurücksetzen',

View file

@ -608,6 +608,47 @@ export const en: Dict = {
'fileViewer.comment': 'Comment',
'fileViewer.edit': 'Edit',
'fileViewer.draw': 'Draw',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'Zoom out',
'fileViewer.zoomIn': 'Zoom in',
'fileViewer.resetZoom': 'Reset zoom',

View file

@ -550,6 +550,47 @@ export const esES: Dict = {
'fileViewer.comment': 'Comentar',
'fileViewer.edit': 'Editar',
'fileViewer.draw': 'Dibujar',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'Reducir zoom',
'fileViewer.zoomIn': 'Aumentar zoom',
'fileViewer.resetZoom': 'Restablecer zoom',

View file

@ -608,6 +608,47 @@ export const fa: Dict = {
'fileViewer.comment': 'نظر',
'fileViewer.edit': 'ویرایش',
'fileViewer.draw': 'رسم',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'کوچک‌نمایی',
'fileViewer.zoomIn': 'بزرگ‌نمایی',
'fileViewer.resetZoom': 'بازنشانی زوم',

View file

@ -595,6 +595,47 @@ export const fr: Dict = {
'fileViewer.comment': 'Commenter',
'fileViewer.edit': 'Modifier',
'fileViewer.draw': 'Dessiner',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'Zoom arrière',
'fileViewer.zoomIn': 'Zoom avant',
'fileViewer.resetZoom': 'Réinitialiser le zoom',

View file

@ -595,6 +595,47 @@ export const hu: Dict = {
'fileViewer.comment': 'Megjegyzés',
'fileViewer.edit': 'Szerkesztés',
'fileViewer.draw': 'Rajz',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'Kicsinyítés',
'fileViewer.zoomIn': 'Nagyítás',
'fileViewer.resetZoom': 'Nagyítás visszaállítása',

View file

@ -548,6 +548,47 @@ export const ja: Dict = {
'fileViewer.comment': 'コメント',
'fileViewer.edit': '編集',
'fileViewer.draw': '描画',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'ズームアウト',
'fileViewer.zoomIn': 'ズームイン',
'fileViewer.resetZoom': 'ズームをリセット',

View file

@ -595,6 +595,47 @@ export const ko: Dict = {
'fileViewer.comment': '댓글',
'fileViewer.edit': '편집',
'fileViewer.draw': '그리기',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': '축소',
'fileViewer.zoomIn': '확대',
'fileViewer.resetZoom': '배율 초기화',

View file

@ -595,6 +595,47 @@ export const pl: Dict = {
'fileViewer.comment': 'Komentarz',
'fileViewer.edit': 'Edytuj',
'fileViewer.draw': 'Rysuj',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'Pomniejsz',
'fileViewer.zoomIn': 'Powiększ',
'fileViewer.resetZoom': 'Resetuj powiększenie',

View file

@ -607,6 +607,47 @@ export const ptBR: Dict = {
'fileViewer.comment': 'Comentar',
'fileViewer.edit': 'Editar',
'fileViewer.draw': 'Desenhar',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'Diminuir zoom',
'fileViewer.zoomIn': 'Aumentar zoom',
'fileViewer.resetZoom': 'Redefinir zoom',

View file

@ -607,6 +607,47 @@ export const ru: Dict = {
'fileViewer.comment': 'Комментарий',
'fileViewer.edit': 'Редактировать',
'fileViewer.draw': 'Рисовать',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'Уменьшить',
'fileViewer.zoomIn': 'Увеличить',
'fileViewer.resetZoom': 'Сбросить масштаб',

View file

@ -587,6 +587,47 @@ export const tr: Dict = {
'fileViewer.comment': 'Yorum',
'fileViewer.edit': 'Düzenle',
'fileViewer.draw': 'Çiz',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'Yakınlaş',
'fileViewer.zoomIn': 'Uzaklaş',
'fileViewer.resetZoom': 'Uzaklığı sıfırla',

View file

@ -626,6 +626,47 @@ export const uk: Dict = {
'fileViewer.comment': 'Коментар',
'fileViewer.edit': 'Редагувати',
'fileViewer.draw': 'Малювати',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': 'Зменшити',
'fileViewer.zoomIn': 'Збільшити',
'fileViewer.resetZoom': 'Скинути масштаб',

View file

@ -596,6 +596,47 @@ export const zhCN: Dict = {
'fileViewer.comment': '评论',
'fileViewer.edit': '编辑',
'fileViewer.draw': '绘制',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': '缩小',
'fileViewer.zoomIn': '放大',
'fileViewer.resetZoom': '重置缩放',

View file

@ -596,6 +596,47 @@ export const zhTW: Dict = {
'fileViewer.comment': '評論',
'fileViewer.edit': '編輯',
'fileViewer.draw': '繪製',
'manualEdit.layers': "Layers",
'manualEdit.editableCount': "{count} editable",
'manualEdit.title': "Manual editor",
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noClass': "no class",
'manualEdit.tabsAria': "Manual edit tabs",
'manualEdit.tabContent': "Content",
'manualEdit.tabStyle': "Style",
'manualEdit.tabAttributes': "Attributes",
'manualEdit.tabHtml': "Html",
'manualEdit.tabSource': "Source",
'manualEdit.attributesJson': "Attributes JSON",
'manualEdit.selectedHtml': "Selected element HTML",
'manualEdit.fullSource': "Full artifact source",
'manualEdit.applyContent': "Apply Content",
'manualEdit.applyStyle': "Apply Style",
'manualEdit.applyAttributes': "Apply Attributes",
'manualEdit.applyHtml': "Apply HTML",
'manualEdit.applySource': "Apply Source",
'manualEdit.invalidAttributes': "Invalid attributes JSON.",
'manualEdit.changes': "Changes",
'manualEdit.undo': "Undo",
'manualEdit.redo': "Redo",
'manualEdit.noChanges': "No manual edits yet.",
'manualEdit.imageUrl': "Image URL",
'manualEdit.altText': "Alt text",
'manualEdit.label': "Label",
'manualEdit.text': "Text",
'manualEdit.href': "Href",
'manualEdit.textColor': "Text color",
'manualEdit.background': "Background",
'manualEdit.fontSize': "Font size",
'manualEdit.weight': "Weight",
'manualEdit.align': "Align",
'manualEdit.padding': "Padding",
'manualEdit.margin': "Margin",
'manualEdit.radius': "Radius",
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'fileViewer.zoomOut': '縮小',
'fileViewer.zoomIn': '放大',
'fileViewer.resetZoom': '重設縮放',

View file

@ -664,6 +664,47 @@ export interface Dict {
'fileViewer.comment': string;
'fileViewer.edit': string;
'fileViewer.draw': string;
'manualEdit.layers': string;
'manualEdit.editableCount': string;
'manualEdit.title': string;
'manualEdit.selectLayer': string;
'manualEdit.empty': string;
'manualEdit.noClass': string;
'manualEdit.tabsAria': string;
'manualEdit.tabContent': string;
'manualEdit.tabStyle': string;
'manualEdit.tabAttributes': string;
'manualEdit.tabHtml': string;
'manualEdit.tabSource': string;
'manualEdit.attributesJson': string;
'manualEdit.selectedHtml': string;
'manualEdit.fullSource': string;
'manualEdit.applyContent': string;
'manualEdit.applyStyle': string;
'manualEdit.applyAttributes': string;
'manualEdit.applyHtml': string;
'manualEdit.applySource': string;
'manualEdit.invalidAttributes': string;
'manualEdit.changes': string;
'manualEdit.undo': string;
'manualEdit.redo': string;
'manualEdit.noChanges': string;
'manualEdit.imageUrl': string;
'manualEdit.altText': string;
'manualEdit.label': string;
'manualEdit.text': string;
'manualEdit.href': string;
'manualEdit.textColor': string;
'manualEdit.background': string;
'manualEdit.fontSize': string;
'manualEdit.weight': string;
'manualEdit.align': string;
'manualEdit.padding': string;
'manualEdit.margin': string;
'manualEdit.radius': string;
'manualEdit.border': string;
'manualEdit.width': string;
'manualEdit.minHeight': string;
'fileViewer.zoomOut': string;
'fileViewer.zoomIn': string;
'fileViewer.resetZoom': string;

View file

@ -10686,3 +10686,340 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
.toggle-switch-sm input:checked + .toggle-slider::before {
transform: translateX(13px);
}
/* Manual edit mode */
.manual-edit-workspace {
display: grid;
grid-template-columns: 240px minmax(420px, 1fr) 344px;
gap: 10px;
height: 100%;
min-height: 0;
padding: 10px;
background: var(--bg);
}
.manual-edit-canvas {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
}
.manual-edit-canvas iframe {
width: 100%;
height: 100%;
border: 0;
}
.manual-edit-layers,
.manual-edit-right {
min-height: 0;
}
.manual-edit-layers,
.manual-edit-modal,
.manual-edit-changes {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
}
.manual-edit-layers {
display: flex;
flex-direction: column;
overflow: hidden;
}
.manual-edit-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border);
}
.manual-edit-panel-head h3 {
margin: 0;
font-size: 13px;
}
.manual-edit-panel-head span {
color: var(--text-muted);
font-size: 11px;
}
.manual-edit-layer-list,
.manual-edit-history-list {
display: grid;
gap: 7px;
overflow: auto;
padding: 10px;
}
.manual-edit-layer-row {
display: grid;
gap: 3px;
width: 100%;
height: auto;
padding: 9px 10px;
text-align: left;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-panel);
}
.manual-edit-layer-row.selected {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel));
box-shadow: inset 3px 0 0 var(--accent);
}
.manual-edit-layer-row strong {
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
}
.manual-edit-layer-row span {
color: var(--text-muted);
font-size: 11px;
}
.manual-edit-right {
display: flex;
flex-direction: column;
gap: 10px;
}
.manual-edit-modal {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
}
.manual-edit-modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 13px;
border-bottom: 1px solid var(--border);
}
.manual-edit-modal-head span {
display: block;
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
letter-spacing: .03em;
text-transform: uppercase;
}
.manual-edit-modal-head h3 {
max-width: 220px;
margin: 2px 0 0;
overflow: hidden;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.manual-edit-modal-head em {
flex: 0 0 auto;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
font-size: 11px;
font-style: normal;
}
.manual-edit-empty {
margin: 12px;
color: var(--text-muted);
font-size: 12px;
}
.manual-edit-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 12px 12px 10px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
}
.manual-edit-meta strong,
.manual-edit-meta span,
.manual-edit-meta code {
display: block;
}
.manual-edit-meta span {
margin-top: 2px;
color: var(--text-muted);
font-size: 11px;
}
.manual-edit-meta code {
max-width: 120px;
overflow: hidden;
color: var(--text-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.manual-edit-tabs {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 5px;
padding: 0 12px 12px;
}
.manual-edit-tabs button {
min-height: 30px;
padding: 0 6px;
font-size: 11px;
text-transform: capitalize;
}
.manual-edit-tabs button.active {
color: var(--bg-panel);
border-color: var(--accent);
background: var(--accent);
}
.manual-edit-tab-body {
display: grid;
gap: 10px;
min-height: 0;
padding: 0 12px 12px;
overflow: auto;
}
.manual-edit-field {
display: grid;
gap: 6px;
}
.manual-edit-field span {
color: var(--text);
font-size: 12px;
font-weight: 650;
}
.manual-edit-field input,
.manual-edit-field textarea,
.manual-edit-field select {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 9px;
color: var(--text);
background: var(--bg-panel);
font: inherit;
}
.manual-edit-field textarea {
min-height: 110px;
resize: vertical;
line-height: 1.45;
}
.manual-edit-field.compact {
gap: 4px;
}
.manual-edit-field.compact input,
.manual-edit-field.compact select {
min-height: 32px;
padding: 6px 8px;
}
.manual-edit-code {
min-height: 150px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace !important;
font-size: 11px !important;
white-space: pre;
}
.manual-edit-code.tall {
min-height: 260px;
}
.manual-edit-style-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 9px;
}
.manual-edit-style-grid .wide {
grid-column: 1 / -1;
}
.manual-edit-error {
margin: 0 12px 10px;
padding: 8px 10px;
border: 1px solid var(--red-border);
border-radius: 6px;
color: var(--red);
background: var(--red-bg);
font-size: 12px;
}
.manual-edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: auto;
padding: 12px;
border-top: 1px solid var(--border);
background: var(--bg-subtle);
}
.manual-edit-actions .primary {
color: var(--bg-panel);
border-color: var(--accent);
background: var(--accent);
}
.manual-edit-changes {
flex: 1;
min-height: 0;
overflow: hidden;
}
.manual-edit-history-actions {
display: flex;
gap: 6px;
padding: 0 10px 8px;
}
.manual-edit-history-entry {
display: grid;
gap: 6px;
padding: 9px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
}
.manual-edit-history-entry strong {
font-size: 12px;
}
.manual-edit-history-entry code {
overflow-wrap: anywhere;
color: var(--text-muted);
font-size: 11px;
white-space: pre-wrap;
}

View file

@ -14,11 +14,19 @@
* { type: 'od:slide-state', active: number, count: number }
* after every navigation so the host can render its own counter / dots.
*/
import {
buildManualEditBridge,
buildManualEditBridgeStyle,
MANUAL_EDIT_DISCOVERY_SELECTOR,
MANUAL_EDIT_SOURCE_PATH_ATTR,
} from '../edit-mode/bridge';
export type SrcdocOptions = {
deck?: boolean;
baseHref?: string;
initialSlideIndex?: number;
commentBridge?: boolean;
editBridge?: boolean;
};
export function buildSrcdoc(
@ -37,10 +45,60 @@ export function buildSrcdoc(
</head>
<body>${html}</body>
</html>`;
const withBase = options.baseHref ? injectBaseHref(wrapped, options.baseHref) : wrapped;
const withSourcePaths = options.editBridge ? annotateManualEditSourcePaths(wrapped) : wrapped;
const withBase = options.baseHref ? injectBaseHref(withSourcePaths, options.baseHref) : withSourcePaths;
const withShim = injectSandboxShim(withBase);
const withDeck = options.deck ? injectDeckBridge(withShim, options.initialSlideIndex) : withShim;
return options.commentBridge ? injectCommentBridge(withDeck) : withDeck;
const withComment = options.commentBridge ? injectCommentBridge(withDeck) : withDeck;
return options.editBridge ? injectManualEditBridge(withComment) : withComment;
}
function annotateManualEditSourcePaths(doc: string): string {
if (typeof DOMParser === 'undefined') return doc;
try {
const parsed = new DOMParser().parseFromString(doc, 'text/html');
parsed.body.querySelectorAll(MANUAL_EDIT_DISCOVERY_SELECTOR).forEach((el) => {
if (el.hasAttribute('data-od-id')) return;
const path = sourcePathForElement(el);
if (path) el.setAttribute(MANUAL_EDIT_SOURCE_PATH_ATTR, path);
});
return serializeHtmlDocument(parsed);
} catch {
return doc;
}
}
function sourcePathForElement(el: Element): string {
const parts: number[] = [];
let node: Element | null = el;
while (node && node !== node.ownerDocument.body) {
const parent: Element | null = node.parentElement;
if (!parent) break;
parts.unshift(Array.prototype.indexOf.call(parent.children, node));
node = parent;
}
return parts.length ? `path-${parts.join('-')}` : '';
}
function serializeHtmlDocument(doc: Document): string {
const doctype = doc.doctype ? '<!doctype html>\n' : '';
return `${doctype}${doc.documentElement.outerHTML}`;
}
function injectManualEditBridge(doc: string): string {
const withStyle = injectBeforeHeadEnd(doc, buildManualEditBridgeStyle());
return injectBeforeBodyEnd(withStyle, buildManualEditBridge(true));
}
function injectBeforeHeadEnd(doc: string, payload: string): string {
if (/<\/head>/i.test(doc)) return doc.replace(/<\/head>/i, `${payload}</head>`);
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (m) => `${m}${payload}`);
return payload + doc;
}
function injectBeforeBodyEnd(doc: string, payload: string): string {
if (/<\/body>/i.test(doc)) return doc.replace(/<\/body>/i, `${payload}</body>`);
return doc + payload;
}
function injectBaseHref(doc: string, baseHref: string): string {
@ -72,7 +130,7 @@ function escapeAttr(value: string): string {
// in-memory shim BEFORE any user script runs so those decks degrade
// gracefully (position just doesn't persist across reloads).
function injectSandboxShim(doc: string): string {
const shim = `<script>(function(){
const shim = `<script data-od-sandbox-shim>(function(){
function makeStore(){
var data = {};
var api = {
@ -362,7 +420,7 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
? doc.replace(/<head[^>]*>/i, (m) => m + styleFix)
: styleFix + doc;
doc = docWithStyle;
const script = `<script>(function(){
const script = `<script data-od-deck-bridge>(function(){
var initialSlideIndex = ${safeInitialSlideIndex};
var didRestoreInitialSlide = initialSlideIndex <= 0;
function slides(){ return document.querySelectorAll('.slide'); }

View file

@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary } from '../../src/components/ManualEditPanel';
import type { ManualEditTarget } from '../../src/edit-mode/types';
const target: ManualEditTarget = {
id: 'hero-title',
kind: 'text',
label: 'Hero Title',
tagName: 'h1',
className: 'hero',
text: 'Original',
rect: { x: 0, y: 0, width: 120, height: 40 },
fields: { text: 'Original' },
attributes: { 'data-od-id': 'hero-title' },
styles: {
color: '',
backgroundColor: '',
fontSize: '',
fontWeight: '',
textAlign: '',
padding: '',
margin: '',
borderRadius: '',
border: '',
width: '',
minHeight: '',
},
outerHtml: '<h1 data-od-id="hero-title">Original</h1>',
};
describe('ManualEditPanel', () => {
let dom: JSDOM;
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>');
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
host = dom.window.document.querySelector('#root') as HTMLDivElement;
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
dom.window.close();
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'document');
Reflect.deleteProperty(globalThis, 'HTMLElement');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
it('opens with target metadata and calls selection from the layers rail', () => {
const onSelectTarget = vi.fn();
renderPanel({ onSelectTarget });
expect(host.textContent).toContain('Hero Title');
expect(host.textContent).toContain('hero-title');
click(buttonByText('Hero Title'));
expect(onSelectTarget).toHaveBeenCalledWith(target);
});
it('builds content patches from the active target', () => {
const onApplyPatch = vi.fn();
renderPanel({ onApplyPatch });
click(buttonByText('Apply Content'));
expect(onApplyPatch).toHaveBeenCalledWith(
{ id: 'hero-title', kind: 'set-text', value: 'Updated copy' },
'Content: Hero Title',
);
});
it('shows invalid attribute JSON without applying a write patch', () => {
const onApplyPatch = vi.fn();
const onError = vi.fn();
renderPanel({ onApplyPatch, onError, attributesText: '{bad' });
click(buttonByText('Attributes'));
click(buttonByText('Apply Attributes'));
expect(onError).toHaveBeenCalled();
expect(onApplyPatch).not.toHaveBeenCalled();
});
it('summarizes full-source history entries without rendering the full file', () => {
const source = '<html><body>' + 'x'.repeat(10_000) + '</body></html>';
expect(manualEditPatchSummary({ kind: 'set-full-source', source })).toBe(
JSON.stringify({ kind: 'set-full-source', bytes: source.length }),
);
expect(manualEditPatchSummary({ kind: 'set-full-source', source })).not.toContain('x'.repeat(100));
});
function renderPanel({
onSelectTarget = vi.fn(),
onApplyPatch = vi.fn(),
onError = vi.fn(),
attributesText = '{}',
}: {
onSelectTarget?: ReturnType<typeof vi.fn>;
onApplyPatch?: ReturnType<typeof vi.fn>;
onError?: ReturnType<typeof vi.fn>;
attributesText?: string;
}) {
const draft = {
...emptyManualEditDraft('<html></html>'),
text: 'Updated copy',
attributesText,
outerHtml: target.outerHtml,
};
act(() => {
root.render(
<ManualEditPanel
targets={[target]}
selectedTarget={target}
draft={draft}
history={[]}
error={null}
canUndo={false}
canRedo={false}
onSelectTarget={onSelectTarget}
onDraftChange={vi.fn()}
onApplyPatch={onApplyPatch}
onError={onError}
onCancelDraft={vi.fn()}
onUndo={vi.fn()}
onRedo={vi.fn()}
/>,
);
});
}
function buttonByText(text: string): HTMLButtonElement {
const buttons = Array.from(host.querySelectorAll('button'));
const button = buttons.find((item) => item.textContent?.includes(text));
if (!button) throw new Error(`Button not found: ${text}`);
return button as HTMLButtonElement;
}
function click(button: HTMLButtonElement): void {
act(() => {
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
});
}
});

View file

@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import {
buildManualEditBridge,
isMeaningfulManualEditElement,
isManualEditHostNode,
isSourceMappableManualEditElement,
manualEditDomPathForElement,
manualEditStableIdForElement,
} from '../../src/edit-mode/bridge';
describe('manual edit bridge target normalization', () => {
it('prefers explicit data-od-id over generated ids', () => {
const dom = new JSDOM('<main><h1 data-od-id="hero">Title</h1></main>');
const target = dom.window.document.querySelector('h1')!;
expect(manualEditStableIdForElement(target)).toBe('hero');
expect(target.getAttribute('data-od-runtime-id')).toBeNull();
});
it('generates stable DOM path ids for unannotated elements', () => {
const dom = new JSDOM('<main><section><p>First</p><p>Second</p></section></main>');
const target = dom.window.document.querySelectorAll('p')[1]!;
expect(manualEditDomPathForElement(target)).toBe('path-0-0-1');
expect(manualEditStableIdForElement(target)).toBe('path-0-0-1');
expect(manualEditStableIdForElement(target)).toBe('path-0-0-1');
expect(target.getAttribute('data-od-runtime-id')).toBe('path-0-0-1');
});
it('generates DOM path ids against source-shaped children, ignoring host shim nodes', () => {
const dom = new JSDOM(
'<script data-od-sandbox-shim></script><main><section><p>First</p><p>Second</p></section></main><script data-od-edit-bridge></script>',
);
const target = dom.window.document.querySelectorAll('p')[1]!;
expect(isManualEditHostNode(dom.window.document.querySelector('[data-od-sandbox-shim]')!)).toBe(true);
expect(manualEditDomPathForElement(target)).toBe('path-0-0-1');
});
it('discovers meaningful elements and ignores tiny or irrelevant elements', () => {
const dom = new JSDOM('<main><h1 data-od-source-path="path-0-0">Title</h1><script>1</script></main>');
const title = dom.window.document.querySelector('h1')!;
const script = dom.window.document.querySelector('script')!;
expect(isMeaningfulManualEditElement(title, { width: 80, height: 24 })).toBe(true);
expect(isMeaningfulManualEditElement(title, { width: 3, height: 24 })).toBe(false);
expect(isMeaningfulManualEditElement(script, { width: 80, height: 24 })).toBe(false);
});
it('does not expose path targets unless they carry a source path marker', () => {
const dom = new JSDOM('<main><h1>Runtime title</h1><p data-od-source-path="path-0-1">Source text</p></main>');
const runtimeTitle = dom.window.document.querySelector('h1')!;
const sourceText = dom.window.document.querySelector('p')!;
expect(isSourceMappableManualEditElement(runtimeTitle)).toBe(false);
expect(isSourceMappableManualEditElement(sourceText)).toBe(true);
expect(isMeaningfulManualEditElement(runtimeTitle, { width: 80, height: 24 })).toBe(false);
});
it('omits selected outerHTML from bulk target posts but includes it for selected targets', () => {
const bridge = buildManualEditBridge(true);
expect(bridge).toContain('targets.push(targetFrom(nodes[i], false))');
expect(bridge).toContain("target: targetFrom(el, true)");
expect(bridge).toContain('if (!isSourceMappable(nodes[i])) continue;');
expect(bridge).toContain('if (isPrimaryTarget(el)) return el;');
});
});

View file

@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import {
applyManualEditPatch,
readManualEditAttributes,
readManualEditFields,
readManualEditOuterHtml,
readManualEditStyles,
} from '../../src/edit-mode/source-patches';
const baseSource = `<!doctype html>
<html>
<head>
<style>:root { --brand: #111; }</style>
</head>
<body>
<main>
<h1 data-od-id="hero-title">Original title</h1>
<a data-od-id="cta" href="/start">Start</a>
<button data-od-id="button-cta">Start button</button>
<a data-od-id="nested-cta" href="/nested"><span>Buy now</span><svg viewBox="0 0 1 1"></svg></a>
<img data-od-id="hero-image" src="/old.png" alt="Old image">
<section data-od-id="card" class="hero" style="color: red; padding: 8px;" data-keep="yes">Card</section>
<p data-od-id="nested"><strong>Nested</strong> copy</p>
<p>Generated path text</p>
</main>
</body>
</html>`;
describe('manual edit source patches', () => {
beforeEach(() => {
const dom = new JSDOM('');
globalThis.DOMParser = dom.window.DOMParser;
globalThis.CSS = { escape: (value: string) => value.replace(/"/g, '\\"') } as typeof CSS;
});
afterEach(() => {
Reflect.deleteProperty(globalThis, 'DOMParser');
Reflect.deleteProperty(globalThis, 'CSS');
});
it('updates only the selected text target', () => {
const result = applyManualEditPatch(baseSource, { kind: 'set-text', id: 'hero-title', value: 'Edited title' });
expect(result.ok).toBe(true);
expect(readManualEditFields(result.source, 'hero-title').text).toBe('Edited title');
expect(readManualEditFields(result.source, 'cta').text).toBe('Start');
});
it('updates link label and href', () => {
const result = applyManualEditPatch(baseSource, { kind: 'set-link', id: 'cta', text: 'Buy now', href: '/buy' });
expect(result.ok).toBe(true);
expect(readManualEditFields(result.source, 'cta')).toEqual({ text: 'Buy now', href: '/buy' });
});
it('treats buttons as label-only text targets instead of persisting href attributes', () => {
const result = applyManualEditPatch(baseSource, { kind: 'set-text', id: 'button-cta', value: 'Buy button' });
expect(result.ok).toBe(true);
const html = readManualEditOuterHtml(result.source, 'button-cta');
expect(html).toContain('Buy button');
expect(html).not.toContain('href=');
expect(readManualEditFields(result.source, 'button-cta')).toEqual({ text: 'Buy button' });
});
it('preserves nested link markup when only href changes', () => {
const result = applyManualEditPatch(baseSource, { kind: 'set-link', id: 'nested-cta', text: 'Buy now', href: '/buy' });
expect(result.ok).toBe(true);
const html = readManualEditOuterHtml(result.source, 'nested-cta');
expect(html).toContain('href="/buy"');
expect(html).toContain('<span>Buy now</span>');
expect(html).toContain('<svg');
});
it('rejects label edits for links with nested markup', () => {
const result = applyManualEditPatch(baseSource, { kind: 'set-link', id: 'nested-cta', text: 'Purchase', href: '/buy' });
expect(result.ok).toBe(false);
expect(result.error).toContain('nested markup');
});
it('updates image src and alt', () => {
const result = applyManualEditPatch(baseSource, { kind: 'set-image', id: 'hero-image', src: '/new.png', alt: 'New image' });
expect(result.ok).toBe(true);
expect(readManualEditFields(result.source, 'hero-image')).toEqual({ src: '/new.png', alt: 'New image' });
});
it('adds and removes inline style properties', () => {
const result = applyManualEditPatch(baseSource, {
kind: 'set-style',
id: 'card',
styles: { color: '', backgroundColor: 'blue', fontSize: '24px' },
});
expect(result.ok).toBe(true);
const styles = readManualEditStyles(result.source, 'card');
expect(styles.color).toBe('');
expect(styles.backgroundColor).toBe('blue');
expect(styles.fontSize).toBe('24px');
expect(styles.padding).toBe('8px');
});
it('applies attributes additively and preserves class/style unless explicitly updated', () => {
const result = applyManualEditPatch(baseSource, {
kind: 'set-attributes',
id: 'card',
attributes: { 'aria-label': 'Hero card', 'data-empty': '', 'data-od-id': 'blocked' },
});
expect(result.ok).toBe(true);
const attrs = readManualEditAttributes(result.source, 'card');
expect(attrs['aria-label']).toBe('Hero card');
expect(attrs.class).toBe('hero');
expect(attrs.style).toContain('color: red');
expect(attrs['data-od-id']).toBe('card');
expect(attrs['data-empty']).toBeUndefined();
});
it('preserves data-od-id when selected outerHTML omits it', () => {
const result = applyManualEditPatch(baseSource, {
kind: 'set-outer-html',
id: 'card',
html: '<section class="replacement">Replaced</section>',
});
expect(result.ok).toBe(true);
const html = readManualEditOuterHtml(result.source, 'card');
expect(html).toContain('data-od-id="card"');
expect(html).toContain('class="replacement"');
});
it('replaces full source for snapshot-based undo history', () => {
const source = '<!doctype html><html><body><h1 data-od-id="hero-title">Snapshot</h1></body></html>';
const result = applyManualEditPatch(baseSource, { kind: 'set-full-source', source });
expect(result).toEqual({ ok: true, source });
});
it('updates CSS tokens in style tags', () => {
const result = applyManualEditPatch(baseSource, { kind: 'set-token', token: '--brand', value: '#f00' });
expect(result.ok).toBe(true);
expect(result.source).toContain('--brand: #f00;');
});
it('preserves fragment-shaped HTML when saving patches', () => {
const source = '<main><h1 data-od-id="hero-title">Original title</h1></main>';
const result = applyManualEditPatch(source, { kind: 'set-text', id: 'hero-title', value: 'Edited title' });
expect(result.ok).toBe(true);
expect(result.source).toBe('<main><h1 data-od-id="hero-title">Edited title</h1></main>');
expect(result.source).not.toContain('<!doctype');
expect(result.source).not.toContain('<html');
expect(result.source).not.toContain('<body');
});
it('preserves full documents with leading comments when saving patches', () => {
const source = [
'<!-- generated by open design -->',
'<!doctype html><html><head><style>:root { --brand: #111; }</style></head>',
'<body><main><h1 data-od-id="hero-title">Original title</h1></main></body></html>',
].join('\n');
const result = applyManualEditPatch(source, { kind: 'set-text', id: 'hero-title', value: 'Edited title' });
expect(result.ok).toBe(true);
expect(result.source).toContain('<!doctype html>');
expect(result.source).toContain('<html>');
expect(result.source).toContain('<head><style>:root { --brand: #111; }</style></head>');
expect(result.source).toContain('<h1 data-od-id="hero-title">Edited title</h1>');
});
it('addresses unannotated elements with generated DOM path ids', () => {
const result = applyManualEditPatch(baseSource, { kind: 'set-text', id: 'path-0-7', value: 'Path target' });
expect(result.ok).toBe(true);
expect(result.source).toContain('Path target');
});
it('rejects text patches for nested markup', () => {
const result = applyManualEditPatch(baseSource, { kind: 'set-text', id: 'nested', value: 'Flat text' });
expect(result.ok).toBe(false);
expect(result.error).toContain('nested markup');
});
});

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { JSDOM } from 'jsdom';
import { buildSrcdoc } from '../../src/runtime/srcdoc';
const deckHtml = `<!doctype html>
@ -61,4 +62,19 @@ describe('buildSrcdoc', () => {
expect(srcdoc).toContain("document.addEventListener('scroll', schedulePostTargets, true);");
expect(srcdoc).toContain('data-od-comment-bridge-style');
});
it('marks source-authored edit targets before runtime scripts can add nodes', () => {
const dom = new JSDOM('');
globalThis.DOMParser = dom.window.DOMParser;
const srcdoc = buildSrcdoc(
'<main><h1>Source title</h1><script>document.body.prepend(document.createElement("h1"));</script></main>',
{ editBridge: true },
);
Reflect.deleteProperty(globalThis, 'DOMParser');
expect(srcdoc).toContain('data-od-source-path="path-0"');
expect(srcdoc).toContain('data-od-source-path="path-0-0"');
expect(srcdoc).not.toContain('<script data-od-source-path=');
expect(srcdoc.indexOf('data-od-source-path="path-0"')).toBeLessThan(srcdoc.indexOf('document.body.prepend'));
});
});

View file

@ -0,0 +1,50 @@
# Manual Edit Mode Implementation Plan
Source requirement: `specs/current/manual-edit-mode-requirements.md`.
Base branch: `origin/main` at `72edd4fc6090a3fda4ed175bd35dca76099a82f2`.
Implementation branch: `codex/manual-edit-mode`.
## Goal
Migrate the accepted manual edit-mode prototype into the production Open Design web app.
The product boundary is fixed:
- `Edit` is manual HTML/CSS editing.
- `Comment` remains the AI-assisted scoped edit path.
- `Tweaks` is for global/token parameters.
- `Draw` is annotation input and does not mutate HTML/CSS in v1.
## Implementation Steps
1. Add `apps/web/src/edit-mode/` with typed targets, patches, history, iframe bridge, and source patch helpers.
2. Integrate manual edit state into the HTML branch of `FileViewer`.
3. Enable the existing `Edit` toolbar button only for HTML/deck-HTML artifacts.
4. Keep comment mode and edit mode mutually independent; do not send manual edits to chat or daemon agent runs.
5. Save manual patches through the existing project file write provider, then refresh preview source.
6. Add the accepted layout: layers rail, canvas-first preview, right edit modal, and changes panel.
7. Add English i18n keys plus conservative fallback locale entries.
8. Add focused tests for patch helpers and the main browser smoke path.
## Acceptance Criteria
- Manual edit mode starts from latest `origin/main`.
- User can select a preview element or a layer row.
- Content, style, attributes, selected HTML, and full source edits are source-backed.
- Undo and redo work during the session.
- Attribute edits do not delete unrelated attributes or prior style edits.
- Comment mode still works as the AI edit path.
- Share/export and deck navigation still work after the edit-mode changes.
## Verification Commands
```bash
pnpm --filter @open-design/web typecheck
pnpm --filter @open-design/web test
pnpm --filter @open-design/e2e test:ui -- --grep "manual edit"
pnpm typecheck
pnpm test
pnpm check:residual-js
```

View file

@ -0,0 +1,215 @@
import { expect, test } from '@playwright/test';
const STORAGE_KEY = 'open-design:config';
test.beforeEach(async ({ page }) => {
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
JSON.stringify({
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'mock',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: {},
}),
);
}, STORAGE_KEY);
await page.route('**/api/agents', async (route) => {
await route.fulfill({
json: {
agents: [
{
id: 'mock',
name: 'Mock Agent',
bin: 'mock-agent',
available: true,
version: 'test',
models: [{ id: 'default', label: 'Default' }],
},
],
},
});
});
});
test('manual edit mode applies content, style, attribute, HTML, source, undo, and redo patches', async ({ page }) => {
const projectId = await createEmptyProject(page, 'Manual edit smoke');
await seedHtmlArtifact(page, projectId, 'manual-edit.html', manualEditHtml());
await page.goto(`/projects/${projectId}/files/manual-edit.html`);
await openDesignFile(page, 'manual-edit.html');
await expect(page.getByTestId('artifact-preview-frame')).toBeVisible();
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByRole('heading', { name: 'Original Hero' })).toBeVisible();
await page.getByTestId('manual-edit-mode-toggle').click();
await frame.getByRole('heading', { name: 'Original Hero' }).click();
await expect(page.locator('.manual-edit-modal')).toContainText('Hero title');
await page.locator('.manual-edit-modal textarea').first().fill('Edited Hero');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expect(frame.getByRole('heading', { name: 'Edited Hero' })).toBeVisible();
await expectFileSource(page, projectId, 'manual-edit.html', ['Edited Hero']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Style', exact: true }).click();
await page.locator('.manual-edit-field').filter({ hasText: 'Font size' }).locator('input').fill('48px');
await page.getByRole('button', { name: 'Apply Style' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['font-size: 48px']);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Primary CTA' }).click();
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Content', exact: true }).click();
const contentFields = page.locator('.manual-edit-tab-body');
await contentFields.locator('textarea').fill('Launch now');
await contentFields.locator('input').fill('/launch');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expect(frame.getByRole('link', { name: 'Launch now' })).toHaveAttribute('href', /\/launch$/);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Hero image' }).click();
await contentFields.locator('input').first().fill('/edited.png');
await contentFields.locator('input').nth(1).fill('Edited alt');
await page.getByRole('button', { name: 'Apply Content' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['/edited.png', 'Edited alt']);
await page.locator('.manual-edit-layer-row').filter({ hasText: 'Hero title' }).click();
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Attributes', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill('{"aria-label":"Edited headline"}');
await page.getByRole('button', { name: 'Apply Attributes' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['aria-label="Edited headline"', 'font-size: 48px']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Html', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill('<h1 class="replacement">HTML Hero</h1>');
await page.getByRole('button', { name: 'Apply HTML' }).click();
await expectFileSource(page, projectId, 'manual-edit.html', ['data-od-id="hero-title"', 'HTML Hero']);
await page.locator('.manual-edit-tabs').getByRole('tab', { name: 'Source', exact: true }).click();
await page.locator('.manual-edit-tab-body textarea').fill(manualEditHtml().replace('Original Hero', 'Full Source Hero'));
await page.getByRole('button', { name: 'Apply Source' }).click();
await expect(frame.getByRole('heading', { name: 'Full Source Hero' })).toBeVisible();
await page.getByRole('button', { name: 'Undo' }).click();
await expect(frame.getByRole('heading', { name: 'HTML Hero' })).toBeVisible();
await page.getByRole('button', { name: 'Redo' }).click();
await expect(frame.getByRole('heading', { name: 'Full Source Hero' })).toBeVisible();
await page.getByRole('button', { name: /Tweaks/ }).click();
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
await frame.getByRole('heading', { name: 'Full Source Hero' }).click();
await expect(page.getByTestId('comment-popover')).toBeVisible();
await page.getByRole('button', { name: /^Share$/ }).click();
await expect(page.getByRole('menuitem', { name: /Export as PDF/ })).toBeVisible();
});
test('manual edit mode keeps deck navigation available for deck-shaped HTML', async ({ page }) => {
const projectId = await createEmptyProject(page, 'Manual edit deck smoke');
await seedHtmlArtifact(page, projectId, 'manual-deck.html', deckHtml());
await page.goto(`/projects/${projectId}/files/manual-deck.html`);
await openDesignFile(page, 'manual-deck.html');
const frame = page.frameLocator('[data-testid="artifact-preview-frame"]');
await expect(frame.getByText('Slide One')).toBeVisible();
await page.getByLabel('Next slide').click();
await expect(frame.getByText('Slide Two')).toBeVisible();
});
async function createEmptyProject(page: Parameters<typeof test>[0]['page'], name: string): Promise<string> {
await page.goto('/');
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await page.getByTestId('new-project-name').fill(name);
await page.getByTestId('create-project').click();
await expect(page).toHaveURL(/\/projects\//);
const current = new URL(page.url());
const [, projects, projectId] = current.pathname.split('/');
if (projects !== 'projects' || !projectId) throw new Error(`unexpected project route: ${current.pathname}`);
return projectId;
}
async function seedHtmlArtifact(
page: Parameters<typeof test>[0]['page'],
projectId: string,
fileName: string,
content: string,
) {
const resp = await page.request.post(`/api/projects/${projectId}/files`, {
data: {
name: fileName,
content,
artifactManifest: {
version: 1,
kind: 'html',
title: fileName,
entry: fileName,
renderer: 'html',
exports: ['html'],
},
},
});
expect(resp.ok()).toBeTruthy();
}
async function openDesignFile(page: Parameters<typeof test>[0]['page'], fileName: string) {
await page.getByRole('button', { name: new RegExp(fileName.replace('.', '\\.')) }).click();
await page.getByTestId('design-file-preview').getByRole('button', { name: 'Open' }).click();
}
async function expectFileSource(
page: Parameters<typeof test>[0]['page'],
projectId: string,
fileName: string,
snippets: string[],
) {
await expect
.poll(async () => {
const resp = await page.request.get(`/api/projects/${projectId}/files/${fileName}`);
if (!resp.ok()) return false;
const source = await resp.text();
return snippets.every((snippet) => source.includes(snippet));
})
.toBe(true);
}
function manualEditHtml(): string {
return `<!doctype html>
<html>
<head><meta charset="utf-8"><title>Manual Edit</title></head>
<body>
<main>
<section data-od-id="hero" data-od-label="Hero section">
<h1 data-od-id="hero-title" data-od-label="Hero title">Original Hero</h1>
<a data-od-id="cta" data-od-label="Primary CTA" href="/start">Start now</a>
<img data-od-id="hero-image" data-od-label="Hero image" src="/hero.png" alt="Hero" style="width:64px;height:64px;">
</section>
</main>
</body>
</html>`;
}
function deckHtml(): string {
return `<!doctype html>
<html>
<body>
<section class="slide" data-od-id="slide-1"><h1>Slide One</h1></section>
<section class="slide" data-od-id="slide-2" hidden><h1>Slide Two</h1></section>
<script>
let active = 0;
const slides = Array.from(document.querySelectorAll('.slide'));
function render() { slides.forEach((slide, index) => { slide.hidden = index !== active; }); }
window.addEventListener('message', (event) => {
if (!event.data || event.data.type !== 'od:slide') return;
if (event.data.action === 'next') active = Math.min(slides.length - 1, active + 1);
if (event.data.action === 'prev') active = Math.max(0, active - 1);
render();
window.parent.postMessage({ type: 'od:slide-state', active, count: slides.length }, '*');
});
render();
window.parent.postMessage({ type: 'od:slide-state', active, count: slides.length }, '*');
</script>
</body>
</html>`;
}

View file

@ -193,6 +193,9 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
devDependencies:
'@types/jsdom':
specifier: ^28.0.1
version: 28.0.1
'@types/node':
specifier: ^20.17.10
version: 20.19.39
@ -202,6 +205,9 @@ importers:
'@types/react-dom':
specifier: ^18.3.1
version: 18.3.7(@types/react@18.3.28)
jsdom:
specifier: ^29.1.0
version: 29.1.0
typescript:
specifier: ^5.6.3
version: 5.9.3
@ -1510,6 +1516,9 @@ packages:
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/jsdom@28.0.1':
resolution: {integrity: sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==}
'@types/keyv@3.1.4':
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
@ -1575,6 +1584,9 @@ packages:
'@types/serve-static@1.15.10':
resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
@ -4002,6 +4014,9 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici-types@7.25.0:
resolution: {integrity: sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==}
undici@6.25.0:
resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==}
engines: {node: '>=18.17'}
@ -5422,6 +5437,13 @@ snapshots:
'@types/http-errors@2.0.5': {}
'@types/jsdom@28.0.1':
dependencies:
'@types/node': 20.19.39
'@types/tough-cookie': 4.0.5
parse5: 7.3.0
undici-types: 7.25.0
'@types/keyv@3.1.4':
dependencies:
'@types/node': 20.19.39
@ -5503,6 +5525,8 @@ snapshots:
'@types/node': 20.19.39
'@types/send': 0.17.6
'@types/tough-cookie@4.0.5': {}
'@types/unist@3.0.3': {}
'@types/verror@1.10.11':
@ -8587,6 +8611,8 @@ snapshots:
undici-types@7.16.0: {}
undici-types@7.25.0: {}
undici@6.25.0: {}
undici@7.25.0: {}

View file

@ -0,0 +1,544 @@
# Open Design Manual Edit Mode Requirements
## Purpose
This document records the accepted manual edit-mode model from `apps/edit-mode-demo` so it can be migrated into the main Open Design web app.
The key product decision is:
- `Comment AI` is the AI-assisted editing path.
- `Edit` is the manual HTML/CSS editing path.
- `Tweaks` is the global parameter/token editing path.
- `Draw` is a visual annotation path, not direct source editing.
Manual edit mode must let users modify the rendered page directly while keeping the project source file as the only source of truth.
## Product Boundary
### Edit Mode
Edit mode is a manual editor for the current artifact.
Users should be able to:
- Select elements from the live preview canvas.
- Select elements from a left-side layer list.
- Edit element content.
- Edit element style.
- Edit element attributes.
- Edit selected element HTML.
- Edit the full artifact source when necessary.
- Apply, cancel, undo, redo, reset, and inspect changes.
Edit mode must not call an AI agent automatically. Any AI-assisted change belongs to `Comment AI`.
### Comment AI Mode
Comment AI mode is the scoped agent-edit path:
- User selects a preview element.
- User writes an instruction.
- The comment is attached to chat.
- Agent patches source.
- User reviews the result.
This feature must remain separate from manual edit mode.
### Tweaks Mode
Tweaks mode is for global or generated parameters:
- CSS tokens.
- Theme parameters.
- Density.
- Type scale.
- Accent colors.
Tweaks can share the same patch/history infrastructure as edit mode, but the UX should be global, not selected-element specific.
### Draw Mode
Draw mode is for annotation and review input. It should not mutate HTML/CSS directly in v1.
## Layout Requirements
The accepted layout uses a three-region editor composition.
### Left Rail
Purpose: mode switching and layer navigation.
Required sections:
- Product/header block.
- Mode buttons:
- `Preview`
- `Edit`
- `Comment AI`
- `Tweaks`
- `Draw`
- `Layers` list showing selectable elements from the artifact.
Layer rows must show:
- Human-readable label.
- Element kind.
- Stable id or generated path.
- Selected state.
The layer list should prioritize meaningful elements but may include generated container layers when source elements do not have explicit ids.
### Center Canvas
Purpose: live artifact preview.
Required behavior:
- Render artifact in sandboxed iframe.
- Preserve Open Design's existing preview model.
- In edit mode, selectable elements show subtle outlines.
- Hovered/selectable elements should feel discoverable without overwhelming the artifact.
- Center toolbar includes:
- active mode/status
- source line count or file identity
- undo
- redo
- show/hide source
- reset
The canvas stays primary. The editor should not feel like a code-only page.
### Right Edit Modal
Purpose: focused properties editor for the selected element.
Required parts:
- Modal header:
- small kicker: `Manual editor`
- selected element label
- element kind badge
- Selected element metadata:
- tag name
- element id/path
- class name if present
- Tabs:
- `Content`
- `Style`
- `Attributes`
- `Html`
- `Source`
- Pinned action footer:
- `Cancel`
- tab-specific apply button
The modal should be visually separated from the canvas and left rail, using a stronger shadow and clear header/footer zones.
### Changes Panel
Purpose: lightweight patch history.
Required behavior:
- Shows patch count.
- Shows newest changes first.
- Shows readable patch label.
- Shows raw patch payload for debugging during development.
- In production, raw payload can be folded behind a details control.
## Selection Model
The preview iframe is the selection surface, not the state owner.
Selection bridge requirements:
- Inject an edit bridge into the iframe `srcDoc`.
- Host sends `od-edit-mode` to enable or disable edit mode.
- Iframe sends:
- `od-edit-targets`
- `od-edit-select`
- The bridge must discover meaningful body elements.
- Explicitly annotated elements are preferred:
- `data-od-id`
- `data-od-edit`
- `data-od-label`
- If no explicit id exists, generate a stable DOM-path id such as `path-0-1-2`.
Supported target kinds:
- `text`
- `link`
- `image`
- `container`
- `token`
Each target should include:
- `id`
- `kind`
- `label`
- `tagName`
- `className`
- `text`
- `rect`
- `fields`
- `attributes`
- `styles`
- `outerHtml`
## Editing Capabilities
### Content Tab
For text-like elements:
- Edit text content.
- Apply via `set-text`.
For links/buttons:
- Edit label.
- Edit `href` when applicable.
- Apply via `set-link`.
For images:
- Edit `src`.
- Edit `alt`.
- Apply via `set-image`.
Container text editing is allowed in the prototype, but production should be careful: editing container `textContent` may collapse child structure. For production, prefer content editing only for leaf or mostly-text elements.
### Style Tab
Style edits should write inline styles to the selected element in v1.
Supported fields:
- text color
- background color
- font size
- font weight
- text alignment
- padding
- margin
- border radius
- border
- width
- min height
Apply via `set-style`.
Rules:
- Empty style values remove that inline property.
- Non-empty values set the corresponding CSS property.
- Browser-normalized CSS serialization is acceptable.
- Style controls should be small and scannable.
Future production improvement:
- Prefer CSS variable/token editing when the artifact exposes safe tokens.
- Offer class/style extraction later, not in v1.
### Attributes Tab
Attributes are edited as JSON in the prototype.
Apply via `set-attributes`.
Rules:
- Attribute updates are additive/update-only by default.
- Omitted attributes must not be deleted.
- Empty string values remove that attribute.
- Protected attributes must not be removed or overwritten by ordinary attribute edits:
- `data-od-id`
- `data-od-edit`
- `data-od-label`
- runtime-only ids
- Invalid attribute names are ignored.
This rule is important because attribute edits must not accidentally remove style/content changes made earlier.
### Html Tab
Allows editing the selected element's `outerHTML`.
Apply via `set-outer-html`.
Rules:
- Replacement HTML must parse to one element.
- Preserve existing `data-od-id` when replacement omits it.
- Preserve existing `data-od-edit` when replacement omits it.
- Do not use this as the default editing path for casual users.
- Treat it as an advanced escape hatch.
### Source Tab
Allows editing the full artifact source.
Apply via `set-full-source`.
Rules:
- This is an advanced escape hatch.
- It should be available when the inspector cannot express the desired edit.
- Production should add validation and recovery before writing the file.
## Patch Model
The source file is the source of truth.
Patch types:
```ts
type EditPatch =
| { id: string; kind: 'set-text'; value: string }
| { id: string; kind: 'set-link'; text: string; href: string }
| { id: string; kind: 'set-image'; src: string; alt: string }
| { id: string; kind: 'set-token'; token: string; value: string }
| { id: string; kind: 'set-style'; styles: Partial<EditableStyles> }
| { id: string; kind: 'set-attributes'; attributes: Record<string, string> }
| { id: string; kind: 'set-outer-html'; html: string }
| { kind: 'set-full-source'; source: string };
```
History entry shape:
```ts
interface EditHistoryEntry {
id: string;
label: string;
patch: EditPatch;
beforeSource: string;
afterSource: string;
createdAt: number;
}
```
Undo/redo can initially use full-source snapshots. Later it can be optimized to patch inversion.
## Source Patching Rules
Source patching should be handled outside the iframe.
Patch flow:
1. User selects target from iframe or layer list.
2. Host reads the current source document.
3. Host fills inspector draft from source and target metadata.
4. User edits fields.
5. User applies a patch.
6. Patch transforms the source string.
7. Host updates source state.
8. Preview iframe reloads from updated `srcDoc`.
9. History records before/after source.
Production migration should move this from demo state into project file persistence:
- Read active project file.
- Apply patch.
- Save file through existing project file API/provider.
- Refresh preview.
- Add history entry.
## Validation Requirements
Minimum v1 validation:
- Do not apply patches if target cannot be found.
- Reject invalid attributes.
- Preserve protected attributes.
- Reject `outerHTML` replacement that does not produce one element.
- Preserve selected id after HTML replacement where possible.
- Keep undo/redo usable after every patch.
Recommended validation before production:
- HTML parse check.
- Basic artifact lint after full-source or HTML edits.
- Detect and warn if editing container text would erase child markup.
- Warn when image URL is empty.
- Warn when `href` is empty or malformed.
## Persistence Requirements
The prototype stores source in React state. Production must persist to the active project file.
Required production persistence behavior:
- Patch the active file content.
- Save through existing project file write API.
- Refresh file list and active tab content.
- Refresh preview.
- Keep current selection if the selected id still exists.
- Clear selection if the target no longer exists.
- Record change history in memory initially.
Optional later:
- Persist edit history per conversation/project.
- Add named snapshots.
- Add compare/diff view.
## Accessibility and Interaction Requirements
Required:
- Mode buttons use tab semantics.
- Inspector tabs use tab semantics.
- Inputs have labels.
- Keyboard users can apply/cancel from the inspector.
- Undo/redo buttons reflect disabled state.
- Selected layer is visually distinct.
Recommended:
- Keyboard shortcut for edit mode.
- Escape cancels active draft.
- Enter or Cmd/Ctrl+Enter applies draft where safe.
- Search/filter layers.
## Visual Design Requirements
The accepted design direction:
- Quiet editor UI.
- Canvas-first.
- Figma-like structure:
- left layers
- center canvas
- right properties modal
- Open Design-specific mode rail:
- Preview
- Edit
- Comment AI
- Tweaks
- Draw
- Right modal should feel focused and slightly elevated.
- Avoid marketing-page styling.
- Avoid decorative gradients or large hero styling.
- Keep controls dense but readable.
- Use 8px radius or less.
## Migration Targets
Prototype files to migrate from:
- `apps/edit-mode-demo/src/editTypes.ts`
- `apps/edit-mode-demo/src/editBridge.ts`
- `apps/edit-mode-demo/src/sourcePatches.ts`
- `apps/edit-mode-demo/src/EditModeDemo.tsx`
- `apps/edit-mode-demo/app/styles.css`
Likely production destinations:
- `apps/web/src/edit-mode/types.ts`
- `apps/web/src/edit-mode/bridge.ts`
- `apps/web/src/edit-mode/sourcePatches.ts`
- `apps/web/src/components/EditModePanel.tsx`
- `apps/web/src/components/EditLayersPanel.tsx`
- `apps/web/src/components/FileViewer.tsx`
- `apps/web/src/index.css`
Existing Open Design integration points:
- `FileViewer` already owns preview iframe and mode toolbar.
- Existing comment mode already injects a preview bridge.
- Existing project file providers already read/write project files.
- Existing tab state already tracks active files.
- Existing artifact preview already rebuilds `srcDoc`.
## Phased Implementation Plan
### Phase 1: Source-Backed Manual Edit Infrastructure
Deliver:
- Edit target types.
- Edit patch types.
- Source patch helpers.
- Iframe edit bridge.
- Target discovery and selection.
- Manual edit mode state in `FileViewer`.
Exit criteria:
- Selecting a target in preview populates inspector data.
- Applying `set-text`, `set-link`, `set-image`, and `set-style` updates the project file.
- Preview refreshes after save.
### Phase 2: UI Migration
Deliver:
- Left layers list for active artifact.
- Right edit modal.
- Inspector tabs.
- Action footer.
- Changes panel or lightweight edit log.
Exit criteria:
- The production app matches the accepted demo layout.
- `Comment AI` and `Edit` are visually and behaviorally distinct.
### Phase 3: Advanced Source Controls
Deliver:
- Attributes JSON tab.
- Selected element HTML tab.
- Full source tab.
- Basic validation and error display.
Exit criteria:
- Advanced edits are possible without breaking simple edit workflows.
- Invalid input does not silently corrupt source.
### Phase 4: Robustness
Deliver:
- Undo/redo.
- Keep selection after patch when id survives.
- Target missing state.
- Artifact lint after risky edits.
- Optional diff preview.
Exit criteria:
- Manual edit mode can be used repeatedly on a real generated artifact without losing work.
## Non-Goals for v1
- Drag-and-drop layout editing.
- Freeform vector editing.
- Multi-user collaboration.
- Auto-layout system.
- Component extraction.
- Class-based CSS refactoring.
- AI agent calls from manual edit mode.
These can be designed later after the source-backed manual edit loop is stable.
## Acceptance Checklist
- [ ] `Edit` mode does not call AI.
- [ ] `Comment AI` remains the only agent-assisted edit path.
- [ ] Preview element selection works.
- [ ] Layer list selection works.
- [ ] Right edit modal shows selected layer identity.
- [ ] Content edits work.
- [ ] Style edits work.
- [ ] Attribute edits work without deleting unrelated attributes.
- [ ] Selected HTML edits work.
- [ ] Full source edits work.
- [ ] Undo/redo works.
- [ ] Source view reflects applied changes.
- [ ] Preview refreshes after applied changes.
- [ ] Invalid patches do not corrupt the artifact.
- [ ] UI remains canvas-first and simple.