mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Implement manual edit mode (#620)
This commit is contained in:
parent
33255a8fdf
commit
8eb9b1b506
34 changed files with 3539 additions and 12 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -520,6 +520,7 @@ export function FileWorkspace({
|
|||
onSavePreviewComment={onSavePreviewComment}
|
||||
onRemovePreviewComment={onRemovePreviewComment}
|
||||
onSendBoardCommentAttachments={onSendBoardCommentAttachments}
|
||||
onFileSaved={onRefreshFiles}
|
||||
/>
|
||||
) : (
|
||||
<div className="viewer-empty">
|
||||
|
|
|
|||
381
apps/web/src/components/ManualEditPanel.tsx
Normal file
381
apps/web/src/components/ManualEditPanel.tsx
Normal 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)])),
|
||||
};
|
||||
}
|
||||
204
apps/web/src/edit-mode/bridge.ts
Normal file
204
apps/web/src/edit-mode/bridge.ts
Normal 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>`;
|
||||
}
|
||||
242
apps/web/src/edit-mode/source-patches.ts
Normal file
242
apps/web/src/edit-mode/source-patches.ts
Normal 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);
|
||||
}
|
||||
90
apps/web/src/edit-mode/types.ts
Normal file
90
apps/web/src/edit-mode/types.ts
Normal 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: '',
|
||||
};
|
||||
}
|
||||
|
|
@ -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': 'إعادة تعيين الزوم',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'بازنشانی زوم',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'ズームをリセット',
|
||||
|
|
|
|||
|
|
@ -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': '배율 초기화',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Сбросить масштаб',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'Скинути масштаб',
|
||||
|
|
|
|||
|
|
@ -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': '重置缩放',
|
||||
|
|
|
|||
|
|
@ -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': '重設縮放',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'); }
|
||||
|
|
|
|||
154
apps/web/tests/components/ManualEditPanel.test.tsx
Normal file
154
apps/web/tests/components/ManualEditPanel.test.tsx
Normal 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 }));
|
||||
});
|
||||
}
|
||||
});
|
||||
69
apps/web/tests/edit-mode/bridge.test.ts
Normal file
69
apps/web/tests/edit-mode/bridge.test.ts
Normal 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;');
|
||||
});
|
||||
});
|
||||
188
apps/web/tests/edit-mode/source-patches.test.ts
Normal file
188
apps/web/tests/edit-mode/source-patches.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
50
docs/plans/manual-edit-mode-implementation.md
Normal file
50
docs/plans/manual-edit-mode-implementation.md
Normal 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
|
||||
```
|
||||
215
e2e/specs/manual-edit-mode.spec.ts
Normal file
215
e2e/specs/manual-edit-mode.spec.ts
Normal 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>`;
|
||||
}
|
||||
|
|
@ -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: {}
|
||||
|
|
|
|||
544
specs/current/manual-edit-mode-requirements.md
Normal file
544
specs/current/manual-edit-mode-requirements.md
Normal 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.
|
||||
Loading…
Reference in a new issue