mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Implement manual edit inspector (#1448)
* feat(web): tweaks palette popover with HSL hue-shift recoloring Adds a Tweaks color-palette popover to the HTML preview toolbar. Selecting a palette re-skins the iframe in place via a srcDoc-side bridge that walks the DOM and shifts every chromatic paint to the target hue while preserving each color's saturation and lightness — pale tints stay pale, bold CTAs stay bold, just in the new color family. Mono-noir desaturates instead of shifting. - runtime/srcdoc: new injectPaletteBridge + paletteBridge / initialPalette options - file-viewer-render-mode: paletteActive flips URL-load back to srcDoc so the bridge can be injected - FileViewer: state, popover, postMessage wiring, srcDoc + useUrlLoadPreview integration - PaletteTweaks: popover UI with Original + Coral / Electric / Acid forest / Risograph / Mono noir - PreviewDrawOverlay: stub pass-through until the draw branch lands * feat(web): hide finalize-design toolbar from project header * test(e2e): skip project actions toolbar flow after toolbar removal * Polish manual edit inspector * Implement manual edit inspector * Fix manual edit review regressions * Fix FileViewer CI regressions * Fix remaining manual edit review issues * Flush manual edit styles before draw exit * Restore Critique Theater styles * Accept pixel line-height manual edits --------- Co-authored-by: qiongyu1999 <2694684348@qq.com>
This commit is contained in:
parent
e3a848a33a
commit
6736310a01
16 changed files with 1847 additions and 691 deletions
|
|
@ -410,4 +410,3 @@ export function escapeHtmlAttr(value: string): string {
|
|||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,18 +78,25 @@ import type {
|
|||
import { ManualEditPanel, emptyManualEditDraft, type ManualEditDraft } from './ManualEditPanel';
|
||||
import {
|
||||
applyManualEditPatch,
|
||||
isManualEditFullHtmlDocument,
|
||||
readManualEditAttributes,
|
||||
readManualEditFields,
|
||||
readManualEditOuterHtml,
|
||||
readManualEditStyles,
|
||||
} from '../edit-mode/source-patches';
|
||||
import type { ManualEditBridgeMessage, ManualEditHistoryEntry, ManualEditPatch, ManualEditStyles, ManualEditTarget } from '../edit-mode/types';
|
||||
import { MANUAL_EDIT_STYLE_PROPS, type ManualEditBridgeMessage, type ManualEditHistoryEntry, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../edit-mode/types';
|
||||
import { isRenderableSketchJson, SketchPreview } from './SketchPreview';
|
||||
|
||||
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
||||
type SlideState = { active: number; count: number };
|
||||
type BoardTool = 'inspect' | 'pod';
|
||||
type StrokePoint = { x: number; y: number };
|
||||
export type ManualEditPendingStyleSave = {
|
||||
id: string;
|
||||
styles: Partial<ManualEditStyles>;
|
||||
label: string;
|
||||
version: number;
|
||||
};
|
||||
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
|
||||
type PreviewCanvasSize = { width: number; height: number };
|
||||
type PreviewViewportPreset = {
|
||||
|
|
@ -215,6 +222,57 @@ const DEPLOY_PROVIDER_OPTIONS: DeployProviderOption[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function mergeManualEditInspectorStyles(
|
||||
sourceStyles: ManualEditStyles,
|
||||
previewStyles: ManualEditStyles,
|
||||
): ManualEditStyles {
|
||||
return MANUAL_EDIT_STYLE_PROPS.reduce<ManualEditStyles>((acc, key) => {
|
||||
const sourceValue = sourceStyles[key]?.trim();
|
||||
const previewValue = previewStyles[key]?.trim();
|
||||
const value = sourceValue || previewValue || '';
|
||||
acc[key] = manualEditInspectorStyleValue(key, value);
|
||||
return acc;
|
||||
}, {} as ManualEditStyles);
|
||||
}
|
||||
|
||||
function manualEditInspectorStyleValue(key: keyof ManualEditStyles, value: string): string {
|
||||
if (!value) return '';
|
||||
if (key === 'color' || key === 'backgroundColor' || key === 'borderColor') {
|
||||
return normalizeManualEditInspectorColor(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeManualEditInspectorColor(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
|
||||
const r = trimmed[1]!, g = trimmed[2]!, b = trimmed[3]!;
|
||||
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
||||
}
|
||||
const rgba = trimmed.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
|
||||
if (!rgba) return trimmed;
|
||||
if (rgba[4] !== undefined && Number(rgba[4]) === 0) return '';
|
||||
const toHex = (raw: string) => Math.max(0, Math.min(255, Math.round(Number(raw))))
|
||||
.toString(16)
|
||||
.padStart(2, '0');
|
||||
return `#${toHex(rgba[1]!)}${toHex(rgba[2]!)}${toHex(rgba[3]!)}`;
|
||||
}
|
||||
|
||||
function manualEditPersistedValueMatchesSavedSnapshot(
|
||||
key: keyof ManualEditStyles,
|
||||
persistedValue: string,
|
||||
savedValue: string,
|
||||
): boolean {
|
||||
return canonicalManualEditStyleValue(key, persistedValue) === canonicalManualEditStyleValue(key, savedValue);
|
||||
}
|
||||
|
||||
function canonicalManualEditStyleValue(key: keyof ManualEditStyles, value: string): string {
|
||||
const normalized = manualEditInspectorStyleValue(key, value).trim();
|
||||
if (!normalized) return '';
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
|
||||
function getDeployProviderOption(providerId: WebDeployProviderId): DeployProviderOption {
|
||||
return DEPLOY_PROVIDER_OPTIONS.find((option) => option.id === providerId) ?? DEPLOY_PROVIDER_OPTIONS[0]!;
|
||||
}
|
||||
|
|
@ -379,6 +437,34 @@ function previewScaleShellStyle(
|
|||
};
|
||||
}
|
||||
|
||||
function manualEditPreviewShellStyle(
|
||||
viewport: PreviewViewportId,
|
||||
previewScale: number,
|
||||
frozenWidth: number | null,
|
||||
): CSSProperties & Record<string, string | number> {
|
||||
if (viewport === 'desktop' && frozenWidth) {
|
||||
return {
|
||||
width: `${frozenWidth / previewScale}px`,
|
||||
height: `${100 / previewScale}%`,
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: '0 0',
|
||||
};
|
||||
}
|
||||
return previewScaleShellStyle(viewport, previewScale);
|
||||
}
|
||||
|
||||
export function cancelManualEditPendingStyleSnapshot(
|
||||
pending: ManualEditPendingStyleSave | null,
|
||||
id: string,
|
||||
keys: Array<keyof ManualEditStyles>,
|
||||
): ManualEditPendingStyleSave | null {
|
||||
if (!pending || pending.id !== id || keys.length === 0) return pending;
|
||||
const nextStyles = { ...pending.styles };
|
||||
for (const key of keys) delete nextStyles[key];
|
||||
if (Object.keys(nextStyles).length === 0) return null;
|
||||
return { ...pending, styles: nextStyles };
|
||||
}
|
||||
|
||||
function usePreviewCanvasSize<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [size, setSize] = useState<PreviewCanvasSize | undefined>(undefined);
|
||||
|
|
@ -3353,7 +3439,7 @@ function HtmlViewer({
|
|||
const [inTabPresent, setInTabPresent] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [boardMode, setBoardMode] = useState(false);
|
||||
const boardTool: BoardTool = 'inspect';
|
||||
const [boardTool, setBoardTool] = useState<BoardTool>('inspect');
|
||||
const [inspectMode, setInspectMode] = useState(false);
|
||||
const [palettePopoverOpen, setPalettePopoverOpen] = useState(false);
|
||||
const [selectedPalette, setSelectedPalette] = useState<PaletteId | null>(null);
|
||||
|
|
@ -3364,21 +3450,31 @@ function HtmlViewer({
|
|||
const [openHintBox, setOpenHintBox] = useState(true);
|
||||
const [manualEditMode, setManualEditModeRaw] = useState(false);
|
||||
const [manualEditFrozenSource, setManualEditFrozenSource] = useState<string | null>(null);
|
||||
const [manualEditViewportWidth, setManualEditViewportWidth] = useState<number | null>(null);
|
||||
const setManualEditMode = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
|
||||
setManualEditModeRaw((prev) => {
|
||||
const value = typeof next === 'function' ? (next as (p: boolean) => boolean)(prev) : next;
|
||||
if (value !== prev && !value) setManualEditFrozenSource(null);
|
||||
if (value !== prev && !value) {
|
||||
setManualEditFrozenSource(null);
|
||||
setManualEditViewportWidth(null);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}, []);
|
||||
const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([]);
|
||||
const [selectedManualEditTarget, setSelectedManualEditTarget] = useState<ManualEditTarget | null>(null);
|
||||
const selectedManualEditTargetIdRef = useRef<string | 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);
|
||||
const manualEditPendingStyleRef = useRef<ManualEditPendingStyleSave | null>(null);
|
||||
const manualEditStyleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const manualEditPreviewVersionRef = useRef(0);
|
||||
const sourceRef = useRef<string | null>(source);
|
||||
const sourceFileKeyRef = useRef<string | null>(null);
|
||||
const templateNameId = useId();
|
||||
const templateDescriptionId = useId();
|
||||
// Opt back into the legacy inline-asset srcDoc path via `?forceInline=1`
|
||||
|
|
@ -3559,14 +3655,25 @@ function HtmlViewer({
|
|||
}, [liveCommentTargets]);
|
||||
|
||||
useEffect(() => {
|
||||
const sourceFileKey = `${projectId}\0${file.name}\0${liveHtml === undefined ? 'raw' : 'live'}`;
|
||||
if (liveHtml !== undefined) {
|
||||
sourceFileKeyRef.current = sourceFileKey;
|
||||
setSource(liveHtml);
|
||||
sourceRef.current = liveHtml;
|
||||
return;
|
||||
}
|
||||
setSource(null);
|
||||
const fileChanged = sourceFileKeyRef.current !== sourceFileKey;
|
||||
sourceFileKeyRef.current = sourceFileKey;
|
||||
if (fileChanged) {
|
||||
setSource(null);
|
||||
sourceRef.current = null;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fetchProjectFileText(projectId, file.name).then((text) => {
|
||||
if (!cancelled) setSource(text);
|
||||
if (!cancelled) {
|
||||
setSource(text);
|
||||
sourceRef.current = text;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
|
@ -3614,6 +3721,7 @@ function HtmlViewer({
|
|||
const previewSource = (manualEditMode && manualEditFrozenSource !== null)
|
||||
? manualEditFrozenSource
|
||||
: livePreviewSource;
|
||||
const manualEditPageStylesEnabled = typeof source === 'string' && isManualEditFullHtmlDocument(source);
|
||||
const drawClickSelectionMode = drawOverlayOpen && drawOverlayMode === 'click' && !manualEditMode;
|
||||
// When we URL-load the iframe directly, skip every in-host inlining /
|
||||
// srcDoc-rebuilding step. The browser does the asset resolution itself,
|
||||
|
|
@ -3694,13 +3802,21 @@ function HtmlViewer({
|
|||
const win = iframeRef.current?.contentWindow;
|
||||
if (!win) return;
|
||||
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
|
||||
}, [manualEditMode, srcDoc]);
|
||||
postSelectedManualEditTargetToIframe(manualEditMode ? selectedManualEditTarget?.id ?? null : null);
|
||||
}, [manualEditMode, selectedManualEditTarget?.id, srcDoc]);
|
||||
|
||||
const previewStyleToIframe = useCallback((id: string, styles: Partial<ManualEditStyles>) => {
|
||||
const previewStyleToIframe = useCallback((id: string, styles: Partial<ManualEditStyles>, version: number) => {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
if (!win) return false;
|
||||
win.postMessage({ type: 'od-edit-preview-style', id, styles, version }, '*');
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
function postSelectedManualEditTargetToIframe(id: string | null) {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
if (!win) return;
|
||||
win.postMessage({ type: 'od-edit-preview-style', id, styles }, '*');
|
||||
}, []);
|
||||
win.postMessage({ type: 'od-edit-selected-target', id }, '*');
|
||||
}
|
||||
|
||||
function syncBridgeModes() {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
|
|
@ -3711,6 +3827,7 @@ function HtmlViewer({
|
|||
mode: drawClickSelectionMode ? 'picker' : boardTool,
|
||||
}, '*');
|
||||
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
|
||||
postSelectedManualEditTargetToIframe(manualEditMode ? selectedManualEditTarget?.id ?? null : null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -3789,12 +3906,17 @@ function HtmlViewer({
|
|||
setInspectError(null);
|
||||
setQueuedBoardNotes([]);
|
||||
setStrokePoints([]);
|
||||
setManualEditFrozenSource(null);
|
||||
setManualEditViewportWidth(null);
|
||||
setManualEditTargets([]);
|
||||
setSelectedManualEditTarget(null);
|
||||
selectedManualEditTargetIdRef.current = null;
|
||||
setManualEditDraft(emptyManualEditDraft());
|
||||
setManualEditHistory([]);
|
||||
setManualEditUndone([]);
|
||||
setManualEditError(null);
|
||||
manualEditPendingStyleRef.current = null;
|
||||
clearManualEditStyleTimer();
|
||||
}, [file.name]);
|
||||
|
||||
// Selecting a new file or turning inspect off resets the panel target.
|
||||
|
|
@ -3828,12 +3950,17 @@ function HtmlViewer({
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
sourceRef.current = source;
|
||||
if (source == null) return;
|
||||
setManualEditDraft((current) => (
|
||||
current.fullSource === source ? current : { ...current, fullSource: source }
|
||||
));
|
||||
}, [source]);
|
||||
|
||||
useEffect(() => {
|
||||
selectedManualEditTargetIdRef.current = selectedManualEditTarget?.id ?? null;
|
||||
}, [selectedManualEditTarget?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectionMode = boardMode || drawClickSelectionMode;
|
||||
if (!selectionMode) {
|
||||
|
|
@ -3960,6 +4087,11 @@ function HtmlViewer({
|
|||
setManualEditTargets([]);
|
||||
setSelectedManualEditTarget(null);
|
||||
setManualEditError(null);
|
||||
manualEditPendingStyleRef.current = null;
|
||||
if (manualEditStyleTimerRef.current) {
|
||||
clearTimeout(manualEditStyleTimerRef.current);
|
||||
manualEditStyleTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
function onMessage(ev: MessageEvent) {
|
||||
|
|
@ -3968,21 +4100,126 @@ function HtmlViewer({
|
|||
if (!data?.type) return;
|
||||
if (data.type === 'od-edit-targets' && Array.isArray(data.targets)) {
|
||||
setManualEditTargets(data.targets);
|
||||
// Target broadcasts can be briefly empty while the iframe/save path is
|
||||
// settling; keep the user's inspector selection unless a fresh copy is
|
||||
// available to update its metadata.
|
||||
setSelectedManualEditTarget((current) =>
|
||||
current ? data.targets.find((target) => target.id === current.id) ?? null : current,
|
||||
current ? data.targets.find((target) => target.id === current.id) ?? current : current,
|
||||
);
|
||||
const selectedId = selectedManualEditTargetIdRef.current;
|
||||
if (selectedId) setTimeout(() => postSelectedManualEditTargetToIframe(selectedId), 0);
|
||||
return;
|
||||
}
|
||||
if (data.type === 'od-edit-select') {
|
||||
selectManualEditTarget(data.target);
|
||||
void selectManualEditTarget(data.target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [manualEditMode, source]);
|
||||
|
||||
function selectManualEditTarget(target: ManualEditTarget) {
|
||||
const base = source ?? '';
|
||||
function nextManualEditPreviewVersion(): number {
|
||||
manualEditPreviewVersionRef.current += 1;
|
||||
return manualEditPreviewVersionRef.current;
|
||||
}
|
||||
|
||||
function inspectorManualEditStyles(target: ManualEditTarget, baseSource: string): ManualEditStyles {
|
||||
const inlineStyles = readManualEditStyles(baseSource, target.id);
|
||||
return mergeManualEditInspectorStyles(inlineStyles, target.styles);
|
||||
}
|
||||
|
||||
function reconcileManualEditStyleSave(
|
||||
id: string,
|
||||
savedStyles: Partial<ManualEditStyles>,
|
||||
savedSource: string,
|
||||
) {
|
||||
if (id !== '__body__' && !readManualEditOuterHtml(savedSource, id)) {
|
||||
setManualEditError('The selected target no longer exists in the saved source. Refreshing the preview.');
|
||||
setSelectedManualEditTarget(null);
|
||||
setManualEditFrozenSource(null);
|
||||
setReloadKey((key) => key + 1);
|
||||
return;
|
||||
}
|
||||
const sourceStyles = readManualEditStyles(savedSource, id);
|
||||
const supersededStyles = manualEditPendingStyleRef.current?.id === id
|
||||
? manualEditPendingStyleRef.current.styles
|
||||
: {};
|
||||
const repairStyles: Partial<ManualEditStyles> = {};
|
||||
for (const key of Object.keys(savedStyles) as Array<keyof ManualEditStyles>) {
|
||||
if (Object.prototype.hasOwnProperty.call(supersededStyles, key)) continue;
|
||||
const sourceValue = manualEditInspectorStyleValue(key, sourceStyles[key] ?? '');
|
||||
const savedValue = savedStyles[key] ?? '';
|
||||
if (manualEditPersistedValueMatchesSavedSnapshot(key, sourceValue, savedValue)) continue;
|
||||
repairStyles[key] = sourceValue;
|
||||
}
|
||||
if (Object.keys(repairStyles).length === 0) return;
|
||||
previewStyleToIframe(id, repairStyles, nextManualEditPreviewVersion());
|
||||
setManualEditDraft((current) => ({
|
||||
...current,
|
||||
styles: { ...current.styles, ...repairStyles },
|
||||
}));
|
||||
setManualEditError('Saved styles differed from the active preview. Reconciled the selected target from source.');
|
||||
}
|
||||
|
||||
function scheduleManualEditStyleSave() {
|
||||
if (manualEditStyleTimerRef.current) clearTimeout(manualEditStyleTimerRef.current);
|
||||
manualEditStyleTimerRef.current = setTimeout(() => {
|
||||
manualEditStyleTimerRef.current = null;
|
||||
void flushManualEditStyleSave();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function clearManualEditStyleTimer() {
|
||||
if (!manualEditStyleTimerRef.current) return;
|
||||
clearTimeout(manualEditStyleTimerRef.current);
|
||||
manualEditStyleTimerRef.current = null;
|
||||
}
|
||||
|
||||
function cancelManualEditPendingStyles(id: string, keys: Array<keyof ManualEditStyles>) {
|
||||
const nextPending = cancelManualEditPendingStyleSnapshot(manualEditPendingStyleRef.current, id, keys);
|
||||
if (!nextPending) {
|
||||
manualEditPendingStyleRef.current = null;
|
||||
clearManualEditStyleTimer();
|
||||
return;
|
||||
}
|
||||
manualEditPendingStyleRef.current = nextPending;
|
||||
}
|
||||
|
||||
async function handleManualEditStyleChange(id: string, styles: Partial<ManualEditStyles>, label: string) {
|
||||
const version = nextManualEditPreviewVersion();
|
||||
const currentPending = manualEditPendingStyleRef.current;
|
||||
const pendingStyles = currentPending?.id === id
|
||||
? { ...currentPending.styles, ...styles }
|
||||
: styles;
|
||||
const pending: ManualEditPendingStyleSave = { id, styles: pendingStyles, label, version };
|
||||
manualEditPendingStyleRef.current = pending;
|
||||
setManualEditError(null);
|
||||
previewStyleToIframe(id, styles, version);
|
||||
scheduleManualEditStyleSave();
|
||||
}
|
||||
|
||||
async function flushManualEditStyleSave(): Promise<boolean> {
|
||||
const pending = manualEditPendingStyleRef.current;
|
||||
if (!pending) return true;
|
||||
if (manualEditSavingRef.current) {
|
||||
scheduleManualEditStyleSave();
|
||||
return false;
|
||||
}
|
||||
manualEditPendingStyleRef.current = null;
|
||||
return applyManualEdit({ id: pending.id, kind: 'set-style', styles: pending.styles }, pending.label);
|
||||
}
|
||||
|
||||
async function exitManualEditModeAfterFlush(): Promise<boolean> {
|
||||
const ok = await flushManualEditStyleSave();
|
||||
if (!ok) return false;
|
||||
setManualEditMode(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function selectManualEditTarget(target: ManualEditTarget) {
|
||||
if (!(await flushManualEditStyleSave())) return;
|
||||
const base = sourceRef.current ?? '';
|
||||
const fields = readManualEditFields(base, target.id);
|
||||
setSelectedManualEditTarget(target);
|
||||
setManualEditDraft({
|
||||
|
|
@ -3990,7 +4227,7 @@ function HtmlViewer({
|
|||
href: fields.href ?? target.fields.href ?? '',
|
||||
src: fields.src ?? target.fields.src ?? '',
|
||||
alt: fields.alt ?? target.fields.alt ?? '',
|
||||
styles: readManualEditStyles(base, target.id),
|
||||
styles: inspectorManualEditStyles(target, base),
|
||||
attributesText: JSON.stringify(readManualEditAttributes(base, target.id), null, 2),
|
||||
outerHtml: readManualEditOuterHtml(base, target.id) || target.outerHtml,
|
||||
fullSource: base,
|
||||
|
|
@ -3998,29 +4235,36 @@ function HtmlViewer({
|
|||
setManualEditError(null);
|
||||
}
|
||||
|
||||
async function applyManualEdit(patch: ManualEditPatch, label: string) {
|
||||
if (manualEditSavingRef.current) return;
|
||||
if (source == null) return;
|
||||
async function clearManualEditTargetSelection() {
|
||||
if (!(await flushManualEditStyleSave())) return;
|
||||
setSelectedManualEditTarget(null);
|
||||
setManualEditDraft(emptyManualEditDraft(sourceRef.current ?? ''));
|
||||
setManualEditError(null);
|
||||
}
|
||||
|
||||
async function applyManualEdit(patch: ManualEditPatch, label: string): Promise<boolean> {
|
||||
if (manualEditSavingRef.current) return false;
|
||||
if (sourceRef.current == null) return false;
|
||||
manualEditSavingRef.current = true;
|
||||
setManualEditSaving(true);
|
||||
setManualEditError(null);
|
||||
try {
|
||||
const baseSource = source;
|
||||
const baseSource = sourceRef.current;
|
||||
const result = applyManualEditPatch(baseSource, patch);
|
||||
if (!result.ok) {
|
||||
setManualEditError(result.error ?? 'Could not apply edit.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!(await confirmManualEditHistorySource(
|
||||
baseSource,
|
||||
'The file changed outside manual edit mode. Refreshing before applying manual edits.',
|
||||
))) return;
|
||||
))) return false;
|
||||
const saved = await writeProjectTextFile(projectId, file.name, result.source, {
|
||||
artifactManifest: file.artifactManifest,
|
||||
});
|
||||
if (!saved) {
|
||||
setManualEditError('Could not save the edited file.');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const entry: ManualEditHistoryEntry = {
|
||||
id: `${Date.now()}-${manualEditHistory.length}`,
|
||||
|
|
@ -4031,14 +4275,20 @@ function HtmlViewer({
|
|||
createdAt: Date.now(),
|
||||
};
|
||||
setSource(result.source);
|
||||
sourceRef.current = result.source;
|
||||
setInlinedSource(null);
|
||||
setManualEditHistory((current) => [entry, ...current]);
|
||||
setManualEditUndone([]);
|
||||
setManualEditDraft((current) => ({ ...current, fullSource: result.source }));
|
||||
if (patch.kind === 'set-style') {
|
||||
reconcileManualEditStyleSave(patch.id, patch.styles, result.source);
|
||||
}
|
||||
await onFileSaved?.();
|
||||
return true;
|
||||
} finally {
|
||||
manualEditSavingRef.current = false;
|
||||
setManualEditSaving(false);
|
||||
if (manualEditPendingStyleRef.current) scheduleManualEditStyleSave();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4049,9 +4299,11 @@ function HtmlViewer({
|
|||
});
|
||||
if (persisted == null || persisted === expectedSource) return true;
|
||||
setSource(persisted);
|
||||
sourceRef.current = persisted;
|
||||
setInlinedSource(null);
|
||||
setManualEditHistory([]);
|
||||
setManualEditUndone([]);
|
||||
manualEditPendingStyleRef.current = null;
|
||||
setManualEditDraft((current) => ({ ...current, fullSource: persisted }));
|
||||
setManualEditError(message);
|
||||
return false;
|
||||
|
|
@ -4076,6 +4328,7 @@ function HtmlViewer({
|
|||
return;
|
||||
}
|
||||
setSource(latest.beforeSource);
|
||||
sourceRef.current = latest.beforeSource;
|
||||
setInlinedSource(null);
|
||||
setManualEditHistory(rest);
|
||||
setManualEditUndone((current) => [latest, ...current]);
|
||||
|
|
@ -4106,6 +4359,7 @@ function HtmlViewer({
|
|||
return;
|
||||
}
|
||||
setSource(latest.afterSource);
|
||||
sourceRef.current = latest.afterSource;
|
||||
setInlinedSource(null);
|
||||
setManualEditUndone(rest);
|
||||
setManualEditHistory((current) => [latest, ...current]);
|
||||
|
|
@ -4565,6 +4819,12 @@ function HtmlViewer({
|
|||
setStrokePoints([]);
|
||||
}
|
||||
|
||||
function activateBoard(tool: BoardTool) {
|
||||
setBoardTool(tool);
|
||||
setDrawOverlayOpen(false);
|
||||
setBoardMode(true);
|
||||
}
|
||||
|
||||
function queueCurrentDraft() {
|
||||
const note = commentDraft.trim();
|
||||
if (!note) return;
|
||||
|
|
@ -4704,6 +4964,7 @@ function HtmlViewer({
|
|||
if (state === 'failed') return t('fileViewer.deployLinkFailed');
|
||||
return t('fileViewer.deployLinkPreparingLabel');
|
||||
};
|
||||
const boardAvailable = mode === 'preview' && source !== null;
|
||||
|
||||
return (
|
||||
<div className="viewer html-viewer">
|
||||
|
|
@ -4821,8 +5082,11 @@ function HtmlViewer({
|
|||
setInspectMode(false);
|
||||
setDrawOverlayOpen(false);
|
||||
setMode('preview');
|
||||
setManualEditViewportWidth(previewBodyRef.current?.clientWidth ?? null);
|
||||
setManualEditMode(true);
|
||||
return;
|
||||
}
|
||||
setManualEditMode((value) => !value);
|
||||
void exitManualEditModeAfterFlush();
|
||||
}}
|
||||
>
|
||||
<Icon name="edit" size={13} />
|
||||
|
|
@ -4836,15 +5100,25 @@ function HtmlViewer({
|
|||
aria-pressed={drawOverlayOpen}
|
||||
onClick={() => {
|
||||
const next = !drawOverlayOpen;
|
||||
if (next) {
|
||||
setManualEditMode(false);
|
||||
if (!next) {
|
||||
setDrawOverlayOpen(false);
|
||||
return;
|
||||
}
|
||||
const activateDraw = () => {
|
||||
setBoardMode(false);
|
||||
clearBoardComposer();
|
||||
setInspectMode(false);
|
||||
setDrawOverlayMode('draw');
|
||||
setMode('preview');
|
||||
setDrawOverlayOpen(true);
|
||||
};
|
||||
if (manualEditMode) {
|
||||
void exitManualEditModeAfterFlush().then((ok) => {
|
||||
if (ok) activateDraw();
|
||||
});
|
||||
return;
|
||||
}
|
||||
setDrawOverlayOpen(next);
|
||||
activateDraw();
|
||||
}}
|
||||
>
|
||||
<Icon name="draw" size={13} />
|
||||
|
|
@ -5123,13 +5397,20 @@ function HtmlViewer({
|
|||
canUndo={manualEditHistory.length > 0}
|
||||
canRedo={manualEditUndone.length > 0}
|
||||
busy={manualEditSaving}
|
||||
pageStylesEnabled={manualEditPageStylesEnabled}
|
||||
onSelectTarget={selectManualEditTarget}
|
||||
onDraftChange={setManualEditDraft}
|
||||
onPreviewStyle={previewStyleToIframe}
|
||||
onStyleChange={(id, styles, label) => {
|
||||
void handleManualEditStyleChange(id, styles, label);
|
||||
}}
|
||||
onInvalidStyle={cancelManualEditPendingStyles}
|
||||
onApplyPatch={(patch, label) => {
|
||||
void applyManualEdit(patch, label);
|
||||
}}
|
||||
onError={setManualEditError}
|
||||
onClearSelection={() => {
|
||||
void clearManualEditTargetSelection();
|
||||
}}
|
||||
onCancelDraft={() => {
|
||||
if (selectedManualEditTarget) selectManualEditTarget(selectedManualEditTarget);
|
||||
}}
|
||||
|
|
@ -5143,7 +5424,11 @@ function HtmlViewer({
|
|||
) : null}
|
||||
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
|
||||
<div
|
||||
style={previewScaleShellStyle(previewViewport, previewScale)}
|
||||
style={
|
||||
manualEditMode
|
||||
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
|
||||
: previewScaleShellStyle(previewViewport, previewScale)
|
||||
}
|
||||
>
|
||||
<PreviewDrawOverlay
|
||||
active={drawOverlayOpen}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import { emptyManualEditStyles, type ManualEditHistoryEntry, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../edit-mode/types';
|
||||
|
||||
export interface ManualEditDraft {
|
||||
|
|
@ -13,8 +12,6 @@ export interface ManualEditDraft {
|
|||
fullSource: string;
|
||||
}
|
||||
|
||||
export type ManualEditTab = 'content' | 'style' | 'attributes' | 'html' | 'source';
|
||||
|
||||
export function emptyManualEditDraft(source = ''): ManualEditDraft {
|
||||
return {
|
||||
text: '', href: '', src: '', alt: '',
|
||||
|
|
@ -24,22 +21,15 @@ export function emptyManualEditDraft(source = ''): ManualEditDraft {
|
|||
}
|
||||
|
||||
export function ManualEditPanel({
|
||||
targets,
|
||||
selectedTarget,
|
||||
draft,
|
||||
history,
|
||||
error,
|
||||
canUndo,
|
||||
canRedo,
|
||||
busy = false,
|
||||
onSelectTarget,
|
||||
onDraftChange,
|
||||
onPreviewStyle,
|
||||
onApplyPatch,
|
||||
onStyleChange,
|
||||
onInvalidStyle,
|
||||
onError,
|
||||
onCancelDraft,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onClearSelection,
|
||||
pageStylesEnabled = true,
|
||||
}: {
|
||||
targets: ManualEditTarget[];
|
||||
selectedTarget: ManualEditTarget | null;
|
||||
|
|
@ -49,241 +39,123 @@ export function ManualEditPanel({
|
|||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
busy?: boolean;
|
||||
pageStylesEnabled?: boolean;
|
||||
onSelectTarget: (target: ManualEditTarget) => void;
|
||||
onDraftChange: (draft: ManualEditDraft) => void;
|
||||
onPreviewStyle?: (id: string, styles: Partial<ManualEditStyles>) => void;
|
||||
onStyleChange?: (id: string, styles: Partial<ManualEditStyles>, label: string) => void;
|
||||
onInvalidStyle?: (id: string, keys: Array<keyof ManualEditStyles>) => void;
|
||||
onApplyPatch: (patch: ManualEditPatch, label: string) => void;
|
||||
onError: (message: string) => void;
|
||||
onClearSelection: () => void;
|
||||
onCancelDraft: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [tab, setTab] = useState<ManualEditTab>('style');
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
useEffect(() => { setTab('style'); }, [selectedTarget?.id]);
|
||||
|
||||
// Live preview: every style change goes to the iframe via postMessage so the
|
||||
// canvas updates instantly without an iframe reload. The 1.5s debounced
|
||||
// source-rewrite still happens via onApplyPatch for persistence.
|
||||
const lastPreviewedRef = useRef<string>('');
|
||||
const lastAppliedRef = useRef<string>('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!selectedTarget || tab !== 'style') return;
|
||||
const key = JSON.stringify(draft.styles);
|
||||
if (key !== lastPreviewedRef.current) {
|
||||
lastPreviewedRef.current = key;
|
||||
onPreviewStyle?.(selectedTarget.id, draft.styles);
|
||||
}
|
||||
if (key === lastAppliedRef.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
lastAppliedRef.current = key;
|
||||
onApplyPatch({ id: selectedTarget.id, kind: 'set-style', styles: draft.styles }, `Style: ${selectedTarget.label}`);
|
||||
}, 1500);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [draft.styles, selectedTarget?.id, tab, onPreviewStyle, onApplyPatch]);
|
||||
|
||||
useEffect(() => {
|
||||
lastAppliedRef.current = JSON.stringify(draft.styles);
|
||||
lastPreviewedRef.current = lastAppliedRef.current;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedTarget?.id]);
|
||||
|
||||
const targetForInspector = selectedTarget;
|
||||
const changeTargetStyle = (key: keyof ManualEditStyles, value: string) => {
|
||||
const nextStyles = { ...draft.styles, [key]: value };
|
||||
onDraftChange({ ...draft, styles: nextStyles });
|
||||
if (!targetForInspector) return;
|
||||
const normalized = normalizeManualEditStyles({ [key]: value }, {
|
||||
layoutEnabled: targetForInspector.isLayoutContainer,
|
||||
});
|
||||
if (!normalized.ok) {
|
||||
onError(normalized.error);
|
||||
onInvalidStyle?.(targetForInspector.id, [key]);
|
||||
return;
|
||||
}
|
||||
onError('');
|
||||
onStyleChange?.(targetForInspector.id, normalized.styles, `Style: ${targetForInspector.label}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="manual-edit-right">
|
||||
<section className="manual-edit-modal cc-panel">
|
||||
{targetForInspector && tab === 'style' ? (
|
||||
{targetForInspector ? (
|
||||
<StyleInspector
|
||||
styles={draft.styles}
|
||||
onChange={(styles) => onDraftChange({ ...draft, styles })}
|
||||
layoutEnabled={targetForInspector.isLayoutContainer}
|
||||
onClearSelection={onClearSelection}
|
||||
onChange={changeTargetStyle}
|
||||
/>
|
||||
) : !targetForInspector ? (
|
||||
<PageInspector onPreviewStyle={onPreviewStyle} onApplyPatch={onApplyPatch} />
|
||||
) : tab === 'content' ? (
|
||||
<ContentEditor target={targetForInspector} draft={draft} onDraftChange={onDraftChange} />
|
||||
) : 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>
|
||||
) : 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>
|
||||
) : 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>
|
||||
<PageInspector
|
||||
enabled={pageStylesEnabled}
|
||||
onStyleChange={(styles) => {
|
||||
const normalized = normalizeManualEditStyles(styles, { layoutEnabled: true });
|
||||
if (!normalized.ok) {
|
||||
onError(normalized.error);
|
||||
onInvalidStyle?.('__body__', Object.keys(styles) as Array<keyof ManualEditStyles>);
|
||||
return;
|
||||
}
|
||||
onError('');
|
||||
onStyleChange?.('__body__', normalized.styles, 'Page styles');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{error ? <div className="manual-edit-error">{error}</div> : null}
|
||||
|
||||
{targetForInspector ? (
|
||||
<div className="cc-advanced">
|
||||
<button type="button" className="cc-advanced-toggle" onClick={() => setAdvancedOpen((v) => !v)}>
|
||||
{advancedOpen ? '▾' : '▸'} Advanced
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<>
|
||||
<div className="manual-edit-tabs" role="tablist">
|
||||
<EditTabButton label={t('manualEdit.tabStyle')} tab="style" active={tab === 'style'} onClick={setTab} />
|
||||
<EditTabButton label={t('manualEdit.tabContent')} tab="content" active={tab === 'content'} 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-actions">
|
||||
<button type="button" onClick={onCancelDraft} disabled={busy}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={onUndo} disabled={busy || !canUndo}>↶</button>
|
||||
<button type="button" onClick={onRedo} disabled={busy || !canRedo}>↷</button>
|
||||
{tab === 'content' ? (
|
||||
<button type="button" className="primary" disabled={busy}
|
||||
onClick={() => onApplyPatch(contentPatch(targetForInspector, draft), `Content: ${targetForInspector.label}`)}>
|
||||
{t('manualEdit.applyContent')}
|
||||
</button>
|
||||
) : null}
|
||||
{tab === 'attributes' ? (
|
||||
<button type="button" className="primary" disabled={busy}
|
||||
onClick={() => {
|
||||
try { onApplyPatch(parseAttributesPatch(targetForInspector.id, draft.attributesText), `Attributes: ${targetForInspector.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: targetForInspector.id, kind: 'set-outer-html', html: draft.outerHtml }, `HTML: ${targetForInspector.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>
|
||||
<details className="cc-disclosure">
|
||||
<summary>Layers ({targets.length})</summary>
|
||||
<div className="manual-edit-layer-list">
|
||||
{targets.map((target) => (
|
||||
<button key={target.id} type="button"
|
||||
className={`manual-edit-layer-row ${targetForInspector?.id === target.id ? 'selected' : ''}`}
|
||||
onClick={() => onSelectTarget(target)}>
|
||||
<strong>{target.label}</strong>
|
||||
<span>{target.kind} - {target.id}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<details className="cc-disclosure">
|
||||
<summary>Changes ({history.length})</summary>
|
||||
{history.length === 0 ? (
|
||||
<div className="manual-edit-empty">No changes yet.</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>
|
||||
)}
|
||||
</details>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PageInspector({
|
||||
onPreviewStyle, onApplyPatch,
|
||||
enabled,
|
||||
onStyleChange,
|
||||
}: {
|
||||
onPreviewStyle?: (id: string, styles: Partial<ManualEditStyles>) => void;
|
||||
onApplyPatch: (patch: ManualEditPatch, label: string) => void;
|
||||
enabled: boolean;
|
||||
onStyleChange: (styles: Partial<ManualEditStyles>) => void;
|
||||
}) {
|
||||
const [bg, setBg] = useState('');
|
||||
const [font, setFont] = useState('');
|
||||
const [size, setSize] = useState('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastAppliedRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const styles: Partial<ManualEditStyles> = {
|
||||
backgroundColor: bg,
|
||||
fontFamily: font,
|
||||
fontSize: size ? (/^\d+(\.\d+)?$/.test(size.trim()) ? `${size.trim()}px` : size.trim()) : '',
|
||||
};
|
||||
const key = JSON.stringify(styles);
|
||||
onPreviewStyle?.('__body__', styles);
|
||||
if (key === lastAppliedRef.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
lastAppliedRef.current = key;
|
||||
onApplyPatch({ id: '__body__', kind: 'set-style', styles }, 'Page styles');
|
||||
}, 1500);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bg, font, size]);
|
||||
const update = (next: { bg?: string; font?: string; size?: string }) => {
|
||||
if ('bg' in next) {
|
||||
const value = next.bg ?? '';
|
||||
setBg(value);
|
||||
onStyleChange({ backgroundColor: value });
|
||||
}
|
||||
if ('font' in next) {
|
||||
const value = next.font ?? '';
|
||||
setFont(value);
|
||||
onStyleChange({ fontFamily: value });
|
||||
}
|
||||
if ('size' in next) {
|
||||
const value = next.size ?? '';
|
||||
setSize(value);
|
||||
onStyleChange({ fontSize: value });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cc-inspector">
|
||||
<Section title="PAGE">
|
||||
<ColorRow label="Background" value={bg} onChange={setBg} />
|
||||
<DropdownRow label="Font" value={font} onChange={setFont} options={['', 'Space Grotesk', 'Inter', 'Times', 'Arial']} placeholder="Space Grotesk" />
|
||||
<UnitRow label="Base size" value={size} onChange={setSize} unit="px" />
|
||||
{enabled ? (
|
||||
<>
|
||||
<ColorRow label="Background" value={bg} onChange={(value) => update({ bg: value })} />
|
||||
<FontRow value={font} onChange={(value) => update({ font: value })} />
|
||||
<UnitRow label="Base size" value={size} onChange={(value) => update({ size: value })} unit="px" autoUnit />
|
||||
</>
|
||||
) : (
|
||||
<p className="cc-section-hint">Page styles are available only for full HTML documents.</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const FONT_OPTS = ['', 'Space Grotesk', 'Inter', 'Times', 'Arial', 'Roboto', 'Helvetica', 'Georgia', 'monospace'];
|
||||
const FONT_OPTS = [
|
||||
{ label: 'inherit', value: '' },
|
||||
{ label: 'Space Grotesk', value: '"Space Grotesk", Inter, system-ui, sans-serif' },
|
||||
{ label: 'Inter', value: 'Inter, system-ui, sans-serif' },
|
||||
{ label: 'Times', value: '"Times New Roman", Times, serif' },
|
||||
{ label: 'Arial', value: 'Arial, Helvetica, sans-serif' },
|
||||
{ label: 'Roboto', value: 'Roboto, Arial, sans-serif' },
|
||||
{ label: 'Helvetica', value: 'Helvetica, Arial, sans-serif' },
|
||||
{ label: 'Georgia', value: 'Georgia, serif' },
|
||||
{ label: 'monospace', value: 'SFMono-Regular, Consolas, "Liberation Mono", monospace' },
|
||||
] as const;
|
||||
const WEIGHT_OPTS = ['', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
|
||||
const ALIGN_OPTS = ['', 'left', 'center', 'right', 'justify', 'start', 'end'];
|
||||
const DIRECTION_OPTS = ['', 'row', 'column', 'row-reverse', 'column-reverse'];
|
||||
|
|
@ -305,19 +177,127 @@ const EDITOR_SWATCH_COLORS = [
|
|||
'#ec4899',
|
||||
] as const;
|
||||
|
||||
type NormalizeResult =
|
||||
| { ok: true; styles: Partial<ManualEditStyles> }
|
||||
| { ok: false; error: string };
|
||||
|
||||
const PX_STYLE_PROPS = new Set<keyof ManualEditStyles>([
|
||||
'fontSize', 'letterSpacing', 'width', 'height', 'minHeight', 'gap',
|
||||
'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
||||
'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
|
||||
'border', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
|
||||
'borderRadius',
|
||||
]);
|
||||
const COLOR_STYLE_PROPS = new Set<keyof ManualEditStyles>(['color', 'backgroundColor', 'borderColor']);
|
||||
const SELECT_STYLE_OPTIONS: Partial<Record<keyof ManualEditStyles, ReadonlyArray<string>>> = {
|
||||
fontFamily: FONT_OPTS.map((option) => option.value),
|
||||
fontWeight: WEIGHT_OPTS,
|
||||
textAlign: ALIGN_OPTS,
|
||||
flexDirection: DIRECTION_OPTS,
|
||||
justifyContent: JUSTIFY_OPTS,
|
||||
alignItems: ITEMS_OPTS,
|
||||
borderStyle: BORDER_STYLE_OPTS,
|
||||
};
|
||||
const LAYOUT_STYLE_PROPS = new Set<keyof ManualEditStyles>(['gap', 'flexDirection', 'justifyContent', 'alignItems']);
|
||||
|
||||
export function normalizeManualEditStyles(
|
||||
styles: Partial<ManualEditStyles>,
|
||||
{ layoutEnabled }: { layoutEnabled: boolean },
|
||||
): NormalizeResult {
|
||||
const normalized: Partial<ManualEditStyles> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(styles) as Array<[keyof ManualEditStyles, string]>) {
|
||||
if (LAYOUT_STYLE_PROPS.has(rawKey) && !layoutEnabled) continue;
|
||||
const value = rawValue.trim();
|
||||
if (value === '') {
|
||||
normalized[rawKey] = '';
|
||||
continue;
|
||||
}
|
||||
if (PX_STYLE_PROPS.has(rawKey)) {
|
||||
const px = normalizePxValue(value);
|
||||
if (!px) return { ok: false, error: `${styleLabel(rawKey)} must be a number or px value.` };
|
||||
normalized[rawKey] = px;
|
||||
continue;
|
||||
}
|
||||
if (COLOR_STYLE_PROPS.has(rawKey)) {
|
||||
const color = normalizeHexColor(value);
|
||||
if (!color) return { ok: false, error: `${styleLabel(rawKey)} must be a hex color.` };
|
||||
normalized[rawKey] = color;
|
||||
continue;
|
||||
}
|
||||
if (rawKey === 'opacity') {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return { ok: false, error: 'Opacity must be a number.' };
|
||||
normalized.opacity = String(Math.max(0, Math.min(1, n)));
|
||||
continue;
|
||||
}
|
||||
if (rawKey === 'lineHeight') {
|
||||
const lineHeight = normalizeLineHeightValue(value);
|
||||
if (!lineHeight) return { ok: false, error: 'Line height must be a positive number or px value.' };
|
||||
normalized.lineHeight = lineHeight;
|
||||
continue;
|
||||
}
|
||||
const options = SELECT_STYLE_OPTIONS[rawKey];
|
||||
if (options) {
|
||||
if (!options.includes(value)) return { ok: false, error: `${styleLabel(rawKey)} has an unsupported value.` };
|
||||
normalized[rawKey] = value;
|
||||
continue;
|
||||
}
|
||||
normalized[rawKey] = value;
|
||||
}
|
||||
return { ok: true, styles: normalized };
|
||||
}
|
||||
|
||||
function normalizePxValue(value: string): string | null {
|
||||
if (/^-?\d+(\.\d+)?$/.test(value)) return `${value}px`;
|
||||
if (/^-?\d+(\.\d+)?px$/i.test(value)) return value.toLowerCase();
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeLineHeightValue(value: string): string | null {
|
||||
if (/^\d+(\.\d+)?$/.test(value)) {
|
||||
const n = Number(value);
|
||||
return n > 0 ? String(n) : null;
|
||||
}
|
||||
if (/^\d+(\.\d+)?px$/i.test(value)) {
|
||||
const n = Number(value.slice(0, -2));
|
||||
return n > 0 ? value.toLowerCase() : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeHexColor(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
if (/^#[0-9a-f]{3}$/i.test(trimmed)) {
|
||||
const r = trimmed[1]!, g = trimmed[2]!, b = trimmed[3]!;
|
||||
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function styleLabel(key: keyof ManualEditStyles): string {
|
||||
return key.replace(/[A-Z]/g, (match) => ` ${match.toLowerCase()}`);
|
||||
}
|
||||
|
||||
function StyleInspector({
|
||||
styles, onChange,
|
||||
styles, layoutEnabled, onClearSelection, onChange,
|
||||
}: {
|
||||
styles: ManualEditStyles;
|
||||
onChange: (styles: ManualEditStyles) => void;
|
||||
layoutEnabled: boolean;
|
||||
onClearSelection: () => void;
|
||||
onChange: (key: keyof ManualEditStyles, value: string) => void;
|
||||
}) {
|
||||
const u = (key: keyof ManualEditStyles, value: string) => onChange({ ...styles, [key]: value });
|
||||
const um = (patch: Partial<ManualEditStyles>) => onChange({ ...styles, ...patch });
|
||||
const u = (key: keyof ManualEditStyles, value: string) => onChange(key, value);
|
||||
|
||||
return (
|
||||
<div className="cc-inspector">
|
||||
<div className="cc-inspector-nav">
|
||||
<button type="button" className="cc-inspector-page" onClick={onClearSelection} aria-label="Show page inspector">
|
||||
Page
|
||||
</button>
|
||||
</div>
|
||||
<Section title="TYPOGRAPHY">
|
||||
<DropdownRow label="Font" value={styles.fontFamily} onChange={(v) => u('fontFamily', v)} options={FONT_OPTS} placeholder="inherit" />
|
||||
<FontRow value={styles.fontFamily} onChange={(v) => u('fontFamily', v)} />
|
||||
<PairRow>
|
||||
<UnitRow label="Size" value={styles.fontSize} onChange={(v) => u('fontSize', v)} unit="px" autoUnit />
|
||||
<DropdownRow label="Weight" value={styles.fontWeight} onChange={(v) => u('fontWeight', v)} options={WEIGHT_OPTS} />
|
||||
|
|
@ -339,14 +319,17 @@ function StyleInspector({
|
|||
</PairRow>
|
||||
</Section>
|
||||
|
||||
<Section title="LAYOUT">
|
||||
<Section title="LAYOUT" inactive={!layoutEnabled}>
|
||||
{!layoutEnabled ? (
|
||||
<p className="cc-section-hint">Select a container or group to edit layout.</p>
|
||||
) : null}
|
||||
<PairRow>
|
||||
<UnitRow label="Gap" value={styles.gap} onChange={(v) => u('gap', v)} unit="px" autoUnit />
|
||||
<DropdownRow label="Direction" value={styles.flexDirection} onChange={(v) => u('flexDirection', v)} options={DIRECTION_OPTS} />
|
||||
<UnitRow label="Gap" value={styles.gap} onChange={(v) => u('gap', v)} unit="px" autoUnit disabled={!layoutEnabled} />
|
||||
<DropdownRow label="Direction" value={styles.flexDirection} onChange={(v) => u('flexDirection', v)} options={DIRECTION_OPTS} disabled={!layoutEnabled} />
|
||||
</PairRow>
|
||||
<PairRow>
|
||||
<DropdownRow label="Justify" value={styles.justifyContent} onChange={(v) => u('justifyContent', v)} options={JUSTIFY_OPTS} />
|
||||
<DropdownRow label="Align" value={styles.alignItems} onChange={(v) => u('alignItems', v)} options={ITEMS_OPTS} />
|
||||
<DropdownRow label="Justify" value={styles.justifyContent} onChange={(v) => u('justifyContent', v)} options={JUSTIFY_OPTS} disabled={!layoutEnabled} />
|
||||
<DropdownRow label="Align" value={styles.alignItems} onChange={(v) => u('alignItems', v)} options={ITEMS_OPTS} disabled={!layoutEnabled} />
|
||||
</PairRow>
|
||||
</Section>
|
||||
|
||||
|
|
@ -378,9 +361,9 @@ function StyleInspector({
|
|||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function Section({ title, children, inactive }: { title: string; children: React.ReactNode; inactive?: boolean }) {
|
||||
return (
|
||||
<section className="cc-section">
|
||||
<section className={`cc-section${inactive ? ' cc-section-inactive' : ''}`}>
|
||||
<header className="cc-section-head">{title}</header>
|
||||
<div className="cc-section-body">{children}</div>
|
||||
</section>
|
||||
|
|
@ -391,36 +374,50 @@ function PairRow({ children }: { children: React.ReactNode }) {
|
|||
return <div className="cc-pair">{children}</div>;
|
||||
}
|
||||
|
||||
function UnitRow({ label, value, onChange, unit, autoUnit }: {
|
||||
function UnitRow({ label, value, onChange, unit, autoUnit, disabled }: {
|
||||
label: string; value: string; onChange: (v: string) => void;
|
||||
unit: string; autoUnit?: boolean;
|
||||
unit: string; autoUnit?: boolean; disabled?: boolean;
|
||||
}) {
|
||||
const display = value;
|
||||
const handle = (raw: string) => {
|
||||
const display = unit === 'px' ? stripPxUnit(value) : value;
|
||||
const step = unit === 'px' ? 1 : 0.1;
|
||||
const canStep = !disabled && isNumericInput(display);
|
||||
const valueFromDisplay = (raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (autoUnit && trimmed && /^-?\d+(\.\d+)?$/.test(trimmed)) onChange(`${trimmed}px`);
|
||||
else onChange(raw);
|
||||
if (autoUnit && trimmed && isNumericInput(trimmed)) return `${trimmed}px`;
|
||||
if (autoUnit && /^-?\d+(\.\d+)?px$/i.test(trimmed)) return trimmed.toLowerCase();
|
||||
return raw;
|
||||
};
|
||||
const handle = (raw: string) => {
|
||||
const next = valueFromDisplay(raw);
|
||||
if (next !== value) onChange(next);
|
||||
};
|
||||
const stepBy = (direction: -1 | 1) => {
|
||||
if (!canStep) return;
|
||||
const next = formatSteppedNumber(Number(display) + direction * step, display, step);
|
||||
onChange(valueFromDisplay(next));
|
||||
};
|
||||
return (
|
||||
<label className="cc-row">
|
||||
<span className="cc-label">{label}</span>
|
||||
<span className="cc-value">
|
||||
<input value={display} placeholder="" onChange={(e) => onChange(e.currentTarget.value)} onBlur={(e) => handle(e.currentTarget.value)} />
|
||||
<button type="button" className="cc-step" disabled={!canStep} aria-label={`${label} decrease`} onClick={() => stepBy(-1)}>−</button>
|
||||
<input value={display} placeholder="" disabled={disabled} onChange={(e) => onChange(valueFromDisplay(e.currentTarget.value))} onBlur={(e) => handle(e.currentTarget.value)} />
|
||||
<button type="button" className="cc-step" disabled={!canStep} aria-label={`${label} increase`} onClick={() => stepBy(1)}>+</button>
|
||||
{unit ? <em className="cc-unit">{unit}</em> : null}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownRow({ label, value, onChange, options, placeholder }: {
|
||||
function DropdownRow({ label, value, onChange, options, placeholder, disabled }: {
|
||||
label: string; value: string; onChange: (v: string) => void;
|
||||
options: ReadonlyArray<string>; placeholder?: string;
|
||||
options: ReadonlyArray<string>; placeholder?: string; disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="cc-row">
|
||||
<span className="cc-label">{label}</span>
|
||||
<span className="cc-value cc-select">
|
||||
<select value={value} onChange={(e) => onChange(e.currentTarget.value)}>
|
||||
<select value={value} disabled={disabled} onChange={(e) => onChange(e.currentTarget.value)}>
|
||||
{!options.includes(value) && value ? <option value={value}>{value}</option> : null}
|
||||
{options.map((opt) => <option key={opt || '__'} value={opt}>{opt || (placeholder ?? '–')}</option>)}
|
||||
</select>
|
||||
|
|
@ -430,6 +427,56 @@ function DropdownRow({ label, value, onChange, options, placeholder }: {
|
|||
);
|
||||
}
|
||||
|
||||
function FontRow({ value, onChange }: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const normalizedValue = normalizeFontFamilyForSelect(value);
|
||||
const customValue = normalizedValue === value ? value : '';
|
||||
return (
|
||||
<label className="cc-row">
|
||||
<span className="cc-label">Font</span>
|
||||
<span className="cc-value cc-select">
|
||||
<select value={normalizedValue} onChange={(event) => onChange(event.currentTarget.value)}>
|
||||
{customValue && !FONT_OPTS.some((option) => option.value === customValue) ? (
|
||||
<option value={customValue}>{fontFamilyLabel(customValue)}</option>
|
||||
) : null}
|
||||
{FONT_OPTS.map((option) => (
|
||||
<option key={option.label} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<em className="cc-chevron">▾</em>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFontFamilyForSelect(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
const direct = FONT_OPTS.find((option) => option.value === trimmed);
|
||||
if (direct) return direct.value;
|
||||
const families = parseFontFamilies(trimmed);
|
||||
const primaryFamily = families[0];
|
||||
const match = FONT_OPTS.find((option) => {
|
||||
if (!option.value) return false;
|
||||
const optionFamilies = parseFontFamilies(option.value);
|
||||
return optionFamilies[0] === primaryFamily;
|
||||
});
|
||||
return match?.value ?? trimmed;
|
||||
}
|
||||
|
||||
function fontFamilyLabel(value: string): string {
|
||||
return parseFontFamilies(value)[0] ?? value;
|
||||
}
|
||||
|
||||
function parseFontFamilies(value: string): string[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((family) => family.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function ColorRow({ label, value, onChange, compact }: {
|
||||
label: string; value: string; onChange: (v: string) => void; compact?: boolean;
|
||||
}) {
|
||||
|
|
@ -498,20 +545,56 @@ function QuadRow({ label, values, onChange }: {
|
|||
}
|
||||
|
||||
function QuadCell({ axis, value, onChange }: { axis: string; value: string; onChange: (v: string) => void }) {
|
||||
const display = stripPxUnit(value);
|
||||
const canStep = isNumericInput(display);
|
||||
const stepBy = (direction: -1 | 1) => {
|
||||
if (!canStep) return;
|
||||
onChange(`${formatSteppedNumber(Number(display) + direction, display, 1)}px`);
|
||||
};
|
||||
return (
|
||||
<span className="cc-quad-cell">
|
||||
<em className="cc-quad-axis">{axis}</em>
|
||||
<input value={value} placeholder="0"
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
<button type="button" className="cc-step cc-step-quad" disabled={!canStep} aria-label={`${axis} decrease`} onClick={() => stepBy(-1)}>−</button>
|
||||
<input value={display} placeholder="0"
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value.trim();
|
||||
if (raw === '') onChange('');
|
||||
else if (isNumericInput(raw)) onChange(`${raw}px`);
|
||||
else if (/^-?\d+(\.\d+)?px$/i.test(raw)) onChange(raw.toLowerCase());
|
||||
else onChange(e.currentTarget.value);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const v = e.currentTarget.value.trim();
|
||||
if (v && /^-?\d+(\.\d+)?$/.test(v)) onChange(`${v}px`);
|
||||
const next = v && isNumericInput(v) ? `${v}px` : e.currentTarget.value;
|
||||
if (next !== value) onChange(next);
|
||||
}} />
|
||||
<button type="button" className="cc-step cc-step-quad" disabled={!canStep} aria-label={`${axis} increase`} onClick={() => stepBy(1)}>+</button>
|
||||
<em className="cc-quad-unit">px</em>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function stripPxUnit(value: string): string {
|
||||
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)px$/i);
|
||||
return match?.[1] ?? value;
|
||||
}
|
||||
|
||||
function isNumericInput(value: string): boolean {
|
||||
return /^-?\d+(\.\d+)?$/.test(value.trim());
|
||||
}
|
||||
|
||||
function formatSteppedNumber(value: number, current: string, step: number): string {
|
||||
const decimals = Math.max(decimalPlaces(current), decimalPlaces(String(step)));
|
||||
return decimals > 0
|
||||
? value.toFixed(decimals).replace(/\.?0+$/, '')
|
||||
: String(Math.round(value));
|
||||
}
|
||||
|
||||
function decimalPlaces(value: string): number {
|
||||
const match = value.match(/\.(\d+)/);
|
||||
return match?.[1]?.length ?? 0;
|
||||
}
|
||||
|
||||
function sideToProp(base: 'padding' | 'margin', side: 't' | 'r' | 'b' | 'l'): keyof ManualEditStyles {
|
||||
return `${base}${sideUpper(side)}` as keyof ManualEditStyles;
|
||||
}
|
||||
|
|
@ -536,34 +619,8 @@ function normalizeColorForPicker(value: string): string {
|
|||
return '#000000';
|
||||
}
|
||||
|
||||
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)])),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export function buildManualEditBridge(enabled: boolean): string {
|
|||
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;
|
||||
if (!attr || attr.name.indexOf('data-od-runtime') === 0 || attr.name === 'data-od-edit-selected') continue;
|
||||
attrs[attr.name] = attr.value;
|
||||
}
|
||||
return attrs;
|
||||
|
|
@ -117,6 +117,10 @@ export function buildManualEditBridge(enabled: boolean): string {
|
|||
styleProps.forEach(function(prop){ styles[prop] = el.style[prop] || computed[prop] || ''; });
|
||||
return styles;
|
||||
}
|
||||
function isLayoutContainer(el){
|
||||
var display = window.getComputedStyle(el).display || '';
|
||||
return display.indexOf('flex') >= 0 || display.indexOf('grid') >= 0;
|
||||
}
|
||||
function targetFrom(el, includeOuterHtml){
|
||||
var rect = el.getBoundingClientRect();
|
||||
var kind = inferKind(el);
|
||||
|
|
@ -142,7 +146,8 @@ export function buildManualEditBridge(enabled: boolean): string {
|
|||
fields: fields,
|
||||
attributes: attrsFor(el),
|
||||
styles: stylesFor(el),
|
||||
outerHtml: includeOuterHtml ? (el.outerHTML || '').replace(/\\sdata-od-runtime-id="[^"]*"/g, '').replace(/\\sdata-od-source-path="[^"]*"/g, '') : ''
|
||||
isLayoutContainer: isLayoutContainer(el),
|
||||
outerHtml: includeOuterHtml ? (el.outerHTML || '').replace(/\\sdata-od-runtime-id="[^"]*"/g, '').replace(/\\sdata-od-source-path="[^"]*"/g, '').replace(/\\sdata-od-edit-selected="[^"]*"/g, '') : ''
|
||||
};
|
||||
}
|
||||
function allTargets(){
|
||||
|
|
@ -160,6 +165,16 @@ export function buildManualEditBridge(enabled: boolean): string {
|
|||
if (!enabled) return;
|
||||
window.parent.postMessage({ type: 'od-edit-targets', targets: allTargets() }, '*');
|
||||
}
|
||||
function clearSelectedTarget(){
|
||||
var selected = document.querySelectorAll('[data-od-edit-selected]');
|
||||
for (var i = 0; i < selected.length; i++) selected[i].removeAttribute('data-od-edit-selected');
|
||||
}
|
||||
function setSelectedTarget(id){
|
||||
clearSelectedTarget();
|
||||
if (!id) return;
|
||||
var el = findById(id);
|
||||
if (el) el.setAttribute('data-od-edit-selected', 'true');
|
||||
}
|
||||
function closestTarget(event){
|
||||
var el = event.target;
|
||||
var fallback = null;
|
||||
|
|
@ -195,18 +210,24 @@ export function buildManualEditBridge(enabled: boolean): string {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
function applyPreviewStyles(id, styles){
|
||||
function applyPreviewStyles(id, styles, version){
|
||||
var el = findById(id);
|
||||
if (!el) return;
|
||||
if (!el) {
|
||||
window.parent.postMessage({ type: 'od-edit-preview-style-applied', id: id || '', version: Number(version) || 0, ok: false, error: 'Target not found' }, '*');
|
||||
return;
|
||||
}
|
||||
var keys = Object.keys(styles || {});
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
var value = styles[key];
|
||||
var cssName = camelToKebab(key);
|
||||
try {
|
||||
try {
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
var value = styles[key];
|
||||
var cssName = camelToKebab(key);
|
||||
if (typeof value !== 'string' || value.trim() === '') el.style.removeProperty(cssName);
|
||||
else el.style.setProperty(cssName, value.trim());
|
||||
} catch (_e) {}
|
||||
}
|
||||
window.parent.postMessage({ type: 'od-edit-preview-style-applied', id: id, version: Number(version) || 0, ok: true }, '*');
|
||||
} catch (e) {
|
||||
window.parent.postMessage({ type: 'od-edit-preview-style-applied', id: id, version: Number(version) || 0, ok: false, error: e && e.message ? String(e.message) : 'Could not apply preview styles' }, '*');
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', function(ev){
|
||||
|
|
@ -214,11 +235,16 @@ export function buildManualEditBridge(enabled: boolean): string {
|
|||
if (ev.data.type === 'od-edit-mode') {
|
||||
enabled = !!ev.data.enabled;
|
||||
document.documentElement.toggleAttribute('data-od-edit-mode', enabled);
|
||||
if (!enabled) clearSelectedTarget();
|
||||
if (enabled) setTimeout(postTargets, 0);
|
||||
return;
|
||||
}
|
||||
if (ev.data.type === 'od-edit-selected-target') {
|
||||
setSelectedTarget(ev.data.id || null);
|
||||
return;
|
||||
}
|
||||
if (ev.data.type === 'od-edit-preview-style') {
|
||||
applyPreviewStyles(ev.data.id, ev.data.styles || {});
|
||||
applyPreviewStyles(ev.data.id, ev.data.styles || {}, ev.data.version);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
|
@ -244,5 +270,10 @@ 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; }
|
||||
html[data-od-edit-mode] [data-od-edit-selected] {
|
||||
outline: 2px solid #2563eb !important;
|
||||
outline-offset: 4px;
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
</style>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,11 +113,11 @@ function parseSource(source: string): Document | null {
|
|||
}
|
||||
|
||||
function serializeSource(doc: Document, originalSource: string): string {
|
||||
if (!isFullHtmlDocument(originalSource)) return doc.body.innerHTML;
|
||||
if (!isManualEditFullHtmlDocument(originalSource)) return doc.body.innerHTML;
|
||||
return `<!doctype html>\n${doc.documentElement.outerHTML}`;
|
||||
}
|
||||
|
||||
function isFullHtmlDocument(source: string): boolean {
|
||||
export function isManualEditFullHtmlDocument(source: string): boolean {
|
||||
const normalized = firstSourceToken(source).slice(0, 32).toLowerCase();
|
||||
return normalized.startsWith('<!doctype') || normalized.startsWith('<html');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export interface ManualEditTarget {
|
|||
fields: ManualEditFields;
|
||||
attributes: Record<string, string>;
|
||||
styles: ManualEditStyles;
|
||||
isLayoutContainer: boolean;
|
||||
outerHtml: string;
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +95,18 @@ export interface ManualEditSelectMessage {
|
|||
target: ManualEditTarget;
|
||||
}
|
||||
|
||||
export type ManualEditBridgeMessage = ManualEditTargetMessage | ManualEditSelectMessage;
|
||||
export interface ManualEditPreviewAppliedMessage {
|
||||
type: 'od-edit-preview-style-applied';
|
||||
id: string;
|
||||
version: number;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ManualEditBridgeMessage =
|
||||
| ManualEditTargetMessage
|
||||
| ManualEditSelectMessage
|
||||
| ManualEditPreviewAppliedMessage;
|
||||
|
||||
export const MANUAL_EDIT_STYLE_PROPS: readonly (keyof ManualEditStyles)[] = [
|
||||
'fontFamily', 'fontSize', 'fontWeight', 'color', 'textAlign', 'lineHeight', 'letterSpacing',
|
||||
|
|
|
|||
|
|
@ -1,108 +1,4 @@
|
|||
@layer theme, base, utilities;
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cairo:wght@400;500;600;700&display=swap');
|
||||
@import "tailwindcss/theme.css" layer(theme);
|
||||
@import "tailwindcss/utilities.css" layer(utilities);
|
||||
|
||||
/* Open Design Tailwind token vocabulary.
|
||||
Colors intentionally clear Tailwind's default palette and expose only
|
||||
project-approved tokens backed by the runtime CSS variables below. Use
|
||||
native Tailwind utilities for spacing and standard type sizes; use the
|
||||
`text-ui-*` aliases only when matching existing compact UI text exactly.
|
||||
|
||||
Examples:
|
||||
- `bg-panel text-text border border-border rounded-card shadow-token-sm`
|
||||
- `bg-accent text-accent-foreground hover:bg-accent-hover`
|
||||
- `bg-selection-overlay border-selection-outline ring-selection-outline`
|
||||
|
||||
The local base-layer border reset below keeps examples like
|
||||
`border border-border` rendering solid borders while Preflight is omitted. */
|
||||
@theme {
|
||||
--color-*: initial;
|
||||
|
||||
/* Surfaces */
|
||||
--color-bg: var(--bg);
|
||||
--color-app: var(--bg-app);
|
||||
--color-panel: var(--bg-panel);
|
||||
--color-subtle: var(--bg-subtle);
|
||||
--color-muted-surface: var(--bg-muted);
|
||||
--color-elevated: var(--bg-elevated);
|
||||
|
||||
/* Borders */
|
||||
--color-border: var(--border);
|
||||
--color-border-strong: var(--border-strong);
|
||||
--color-border-soft: var(--border-soft);
|
||||
|
||||
/* Text */
|
||||
--color-text: var(--text);
|
||||
--color-strong: var(--text-strong);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-soft: var(--text-soft);
|
||||
--color-faint: var(--text-faint);
|
||||
|
||||
/* Accent */
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-strong: var(--accent-strong);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-accent-tint: var(--accent-tint);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-wash: var(--accent-wash);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
/* Semantic status */
|
||||
--color-success: var(--green);
|
||||
--color-success-surface: var(--green-bg);
|
||||
--color-success-border: var(--green-border);
|
||||
--color-info: var(--blue);
|
||||
--color-info-surface: var(--blue-bg);
|
||||
--color-info-border: var(--blue-border);
|
||||
--color-discovery: var(--purple);
|
||||
--color-discovery-surface: var(--purple-bg);
|
||||
--color-discovery-border: var(--purple-border);
|
||||
--color-danger: var(--red);
|
||||
--color-danger-surface: var(--red-bg);
|
||||
--color-danger-border: var(--red-border);
|
||||
--color-danger-foreground: var(--bg-panel);
|
||||
--color-warning: var(--amber);
|
||||
--color-warning-surface: var(--amber-bg);
|
||||
--color-warning-border: var(--warning-border);
|
||||
|
||||
/* Interaction and overlays */
|
||||
--color-focus: var(--accent);
|
||||
--color-focus-ring: var(--accent-soft);
|
||||
--color-overlay: var(--overlay);
|
||||
--color-selection-overlay: var(--selection-overlay);
|
||||
--color-selection-outline: var(--selection-outline);
|
||||
--color-inspect-overlay: var(--inspect-overlay);
|
||||
--color-control-hover: var(--bg-subtle);
|
||||
--color-control-active: var(--bg-muted);
|
||||
|
||||
/* Radius */
|
||||
--radius-control: var(--radius-sm);
|
||||
--radius-card: var(--radius);
|
||||
--radius-panel: var(--radius-lg);
|
||||
--radius-token-pill: var(--radius-pill);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-token-xs: var(--shadow-xs);
|
||||
--shadow-token-sm: var(--shadow-sm);
|
||||
--shadow-token-md: var(--shadow-md);
|
||||
--shadow-token-lg: var(--shadow-lg);
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: var(--sans);
|
||||
--font-serif: var(--serif);
|
||||
--font-mono: var(--mono);
|
||||
|
||||
/* Exact existing UI type sizes */
|
||||
--text-ui-9: 9px;
|
||||
--text-ui-10: 10px;
|
||||
--text-ui-10_5: 10.5px;
|
||||
--text-ui-11: 11px;
|
||||
--text-ui-11_5: 11.5px;
|
||||
--text-ui-12_5: 12.5px;
|
||||
--text-ui-13: 13px;
|
||||
--text-ui-13_5: 13.5px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Open Design — neutral product workspace
|
||||
|
|
@ -133,8 +29,6 @@
|
|||
--accent-soft: #f5d8cb;
|
||||
--accent-tint: #fbeee5;
|
||||
--accent-hover: #b45a3b;
|
||||
--accent-wash: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
--accent-foreground: #fff;
|
||||
|
||||
/* Semantic accent tints used by tool / status pills. */
|
||||
--green: #1f7a3a;
|
||||
|
|
@ -151,12 +45,6 @@
|
|||
--red-border: #f5c6c2;
|
||||
--amber: #b26200;
|
||||
--amber-bg: #fff3e0;
|
||||
--warning-border: color-mix(in srgb, var(--amber) 35%, transparent);
|
||||
|
||||
--overlay: rgba(28, 27, 26, 0.42);
|
||||
--selection-overlay: rgba(22, 119, 255, 0.18);
|
||||
--selection-outline: rgba(22, 119, 255, 0.55);
|
||||
--inspect-overlay: rgba(37, 99, 235, 0.12);
|
||||
|
||||
--shadow-xs: 0 1px 0 rgba(28, 27, 26, 0.04);
|
||||
--shadow-sm: 0 1px 2px rgba(28, 27, 26, 0.05), 0 1px 3px rgba(28, 27, 26, 0.04);
|
||||
|
|
@ -277,20 +165,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Tailwind foundation policy: Preflight stays out of this slice. Retained
|
||||
element/reset rules that may conflict with migrated utilities must live in
|
||||
@layer base, be constrained to non-migrated scopes, or be removed before the
|
||||
affected elements migrate. */
|
||||
@layer base {
|
||||
*,
|
||||
::before,
|
||||
::after,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border: 0 solid;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
|
|
@ -1372,21 +1246,6 @@ code {
|
|||
font-size: 11.5px;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.staged-preview-trigger {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.staged-preview-trigger:hover .staged-name {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.staged-chip img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
|
|
@ -1396,46 +1255,47 @@ code {
|
|||
.staged-preview-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px;
|
||||
background: rgba(0, 0, 0, 0.56);
|
||||
padding: 24px;
|
||||
background: rgba(17, 24, 39, 0.44);
|
||||
}
|
||||
.staged-preview-card {
|
||||
max-width: min(920px, 92vw);
|
||||
max-height: min(760px, 88vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
gap: 10px;
|
||||
width: min(720px, calc(100vw - 48px));
|
||||
max-height: calc(100vh - 48px);
|
||||
padding: 12px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-panel);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.staged-preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.staged-preview-head span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.staged-preview-card > img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: calc(88vh - 48px);
|
||||
max-height: min(70vh, 640px);
|
||||
object-fit: contain;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
}
|
||||
.staged-comment {
|
||||
border-radius: var(--radius-pill);
|
||||
|
|
@ -11446,10 +11306,8 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
|
|||
z-index: 1001;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
|
|
@ -12823,7 +12681,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
|
||||
/* ---------------------------------------------------------------------
|
||||
* Critique Theater (Phase 8). Role-keyed tinting via `data-role` so the
|
||||
* components hold no hex literals — the lane just declares which role
|
||||
* components hold no hex literals - the lane just declares which role
|
||||
* it represents and the cascade picks the right hue from
|
||||
* existing semantic tokens. Phase 9 will add the settings toggle that
|
||||
* gates the surrounding mount points.
|
||||
|
|
@ -12941,10 +12799,10 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
background: color-mix(in srgb, var(--accent) 6%, var(--bg-panel));
|
||||
}
|
||||
.theater-lane[data-role="designer"] { border-left-color: color-mix(in srgb, var(--accent) 60%, var(--border)); }
|
||||
.theater-lane[data-role="critic"] { border-left-color: color-mix(in srgb, var(--amber) 60%, var(--border)); }
|
||||
.theater-lane[data-role="brand"] { border-left-color: color-mix(in srgb, var(--purple, var(--accent)) 60%, var(--border)); }
|
||||
.theater-lane[data-role="a11y"] { border-left-color: color-mix(in srgb, var(--green, var(--accent)) 60%, var(--border)); }
|
||||
.theater-lane[data-role="copy"] { border-left-color: color-mix(in srgb, var(--blue, var(--accent)) 60%, var(--border)); }
|
||||
.theater-lane[data-role="critic"] { border-left-color: color-mix(in srgb, var(--amber) 60%, var(--border)); }
|
||||
.theater-lane[data-role="brand"] { border-left-color: color-mix(in srgb, var(--purple, var(--accent)) 60%, var(--border)); }
|
||||
.theater-lane[data-role="a11y"] { border-left-color: color-mix(in srgb, var(--green, var(--accent)) 60%, var(--border)); }
|
||||
.theater-lane[data-role="copy"] { border-left-color: color-mix(in srgb, var(--blue, var(--accent)) 60%, var(--border)); }
|
||||
.theater-lane-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
|
@ -13219,6 +13077,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
transform: rotate(var(--burst-angle, 0deg)) translate(18px, 0) scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.assistant-feedback-reasons {
|
||||
width: min(340px, 100%);
|
||||
padding: 8px;
|
||||
|
|
@ -16470,17 +16329,50 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
background: var(--bg);
|
||||
}
|
||||
.manual-edit-workspace > .manual-edit-canvas { grid-column: 1; grid-row: 1; }
|
||||
.manual-edit-workspace > .manual-edit-right { grid-column: 2; grid-row: 1; min-height: 0; }
|
||||
.manual-edit-workspace > .manual-edit-right {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Claude Code-style edit inspector panel. */
|
||||
.cc-panel { display: flex; flex-direction: column; gap: 8px; padding: 8px 0; overflow: auto; background: var(--bg-panel); }
|
||||
.cc-inspector { display: flex; flex-direction: column; gap: 12px; padding: 6px 12px; }
|
||||
.cc-inspector-nav {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
.cc-inspector-page {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-panel);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cc-inspector-page:hover:not(:disabled) {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.cc-section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.cc-section-inactive { opacity: 0.58; }
|
||||
.cc-section-head {
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
|
||||
color: var(--text-muted); padding: 4px 0 2px;
|
||||
}
|
||||
.cc-section-body { display: flex; flex-direction: column; gap: 4px; }
|
||||
.cc-section-hint {
|
||||
margin: -1px 0 2px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.cc-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
|
||||
.cc-row {
|
||||
display: flex; align-items: center;
|
||||
|
|
@ -16500,6 +16392,28 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
flex: 1 1 auto; display: flex; align-items: center; justify-content: flex-end;
|
||||
gap: 2px; min-width: 0;
|
||||
}
|
||||
.cc-step {
|
||||
flex: 0 0 14px;
|
||||
width: 14px;
|
||||
height: 18px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cc-step:hover:not(:disabled) {
|
||||
background: var(--surface-hover, rgba(0,0,0,0.06));
|
||||
color: var(--text);
|
||||
}
|
||||
.cc-step:disabled {
|
||||
opacity: 0.28;
|
||||
cursor: default;
|
||||
}
|
||||
.cc-value > input,
|
||||
.cc-value > select {
|
||||
flex: 1 1 auto; min-width: 0; border: none; outline: none;
|
||||
|
|
@ -16523,14 +16437,20 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
}
|
||||
.cc-color > input { text-align: right; }
|
||||
.cc-color-popover {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 100;
|
||||
position: absolute; top: calc(100% + 6px); left: 0; z-index: 100;
|
||||
background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.12);
|
||||
display: flex; flex-direction: column; gap: 8px; min-width: 168px;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
width: min(168px, calc(100vw - 32px));
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
.cc-color-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; }
|
||||
.cc-color-compact .cc-color-popover {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
.cc-color-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px; }
|
||||
.cc-color-tile {
|
||||
width: 22px; height: 22px; border-radius: 4px; padding: 0;
|
||||
width: 100%; aspect-ratio: 1; border-radius: 4px; padding: 0;
|
||||
border: 1px solid rgba(0,0,0,0.08); cursor: pointer;
|
||||
}
|
||||
.cc-color-native {
|
||||
|
|
@ -16554,6 +16474,10 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
padding: 0 6px; height: 26px; font-size: 12px; gap: 2px;
|
||||
}
|
||||
.cc-quad-axis { color: var(--text-muted); font-style: normal; font-size: 10px; flex: 0 0 auto; }
|
||||
.cc-step-quad {
|
||||
flex-basis: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
.cc-quad-cell input {
|
||||
flex: 1 1 auto; min-width: 0; border: none; outline: none;
|
||||
background: transparent; font: inherit; padding: 0;
|
||||
|
|
@ -16561,14 +16485,6 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
}
|
||||
.cc-quad-unit { color: var(--text-muted); font-style: normal; font-size: 10px; }
|
||||
|
||||
.cc-advanced {
|
||||
border-top: 1px solid var(--border); margin-top: 6px;
|
||||
padding: 6px 12px; display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.cc-advanced-toggle {
|
||||
align-self: flex-start; background: transparent; border: none;
|
||||
padding: 2px 0; font-size: 11px; color: var(--text-muted); cursor: pointer;
|
||||
}
|
||||
.cc-disclosure { font-size: 11px; color: var(--text-muted); }
|
||||
.cc-disclosure > summary { cursor: pointer; padding: 4px 0; }
|
||||
|
||||
|
|
@ -16581,7 +16497,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-panel);
|
||||
|
|
@ -16682,6 +16598,16 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.manual-edit-modal.cc-panel {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-bottom: 72px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.manual-edit-modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -16758,26 +16684,6 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
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;
|
||||
|
|
@ -16856,22 +16762,6 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
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;
|
||||
|
|
@ -18658,9 +18548,7 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
|
|||
top: 0;
|
||||
background: var(--bg-panel);
|
||||
padding: 8px 10px 4px;
|
||||
min-height: 32px;
|
||||
z-index: 1;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.palette-tweaks-anchor { position: relative; display: inline-flex; }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatComposer } from '../../src/components/ChatComposer';
|
||||
|
|
@ -92,13 +94,29 @@ describe('ChatComposer /search command', () => {
|
|||
|
||||
const dialog = screen.getByRole('dialog', { name: 'drawing.png' });
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(dialog.classList.contains('staged-preview-modal')).toBe(true);
|
||||
expect(dialog.querySelector('.staged-preview-card')).toBeTruthy();
|
||||
expect(dialog.querySelector('.staged-preview-head')).toBeTruthy();
|
||||
const previewImage = screen.getByRole('img', { name: 'drawing.png' }) as HTMLImageElement;
|
||||
expect(previewImage.src).toContain('/api/projects/project-1/raw/uploads/drawing.png');
|
||||
expect(dialog.querySelector('.staged-preview-card > img')).toBe(previewImage);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(screen.queryByRole('dialog', { name: 'drawing.png' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps staged image preview modal styling available', () => {
|
||||
const css = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8');
|
||||
|
||||
expect(css).toContain('.staged-preview-modal');
|
||||
expect(css).toContain('position: fixed;');
|
||||
expect(css).toContain('.staged-preview-card');
|
||||
expect(css).toContain('max-height: calc(100vh - 48px);');
|
||||
expect(css).toContain('.staged-preview-head');
|
||||
expect(css).toContain('.staged-preview-card > img');
|
||||
expect(css).toContain('object-fit: contain;');
|
||||
});
|
||||
|
||||
it('expands /search into a first-action research command prompt', () => {
|
||||
const onSend = vi.fn();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { ProjectFile } from '../../src/types';
|
||||
|
||||
const panelState = vi.hoisted(() => ({
|
||||
props: null as ComponentProps<typeof import('../../src/components/ManualEditPanel').ManualEditPanel> | null,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/components/ManualEditPanel', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/components/ManualEditPanel')>();
|
||||
return {
|
||||
...actual,
|
||||
ManualEditPanel: (props: ComponentProps<typeof actual.ManualEditPanel>) => {
|
||||
panelState.props = props;
|
||||
return <div data-testid="mock-manual-edit-panel" />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { FileViewer } from '../../src/components/FileViewer';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
panelState.props = null;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('FileViewer manual edit history regressions', () => {
|
||||
it('flushes pending style edits before activating draw mode from manual edit', async () => {
|
||||
const initialSource = '<!doctype html><html><body><h1 data-od-id="hero" style="color: #111111">Hero</h1></body></html>';
|
||||
let saveResolve!: (value: Response) => void;
|
||||
const saveResponse = new Promise<Response>((resolve) => {
|
||||
saveResolve = resolve;
|
||||
});
|
||||
const savedSources: string[] = [];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url.includes('/api/projects/project-1/deployments')) {
|
||||
return new Response(JSON.stringify({ deployments: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
|
||||
const payload = JSON.parse(String(init.body)) as { content: string };
|
||||
savedSources.push(payload.content);
|
||||
return saveResponse;
|
||||
}
|
||||
if (url.includes('/api/projects/project-1/raw/preview.html')) {
|
||||
return new Response(initialSource, { status: 200 });
|
||||
}
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml={initialSource}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||
await waitFor(() => expect(panelState.props).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
panelState.props?.onStyleChange?.('hero', { color: '#ef4444' }, 'Style: Hero');
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('draw-overlay-toggle'));
|
||||
|
||||
await waitFor(() => expect(savedSources).toHaveLength(1));
|
||||
expect(savedSources[0]).toContain('rgb(239, 68, 68)');
|
||||
expect(screen.getByTestId('manual-edit-mode-toggle').getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('false');
|
||||
|
||||
await act(async () => {
|
||||
saveResolve(new Response(JSON.stringify({ file: htmlPreviewFile() }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
await saveResponse;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('manual-edit-mode-toggle').getAttribute('aria-pressed')).toBe('false'));
|
||||
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
it('uses the undone source snapshot for a follow-up edit after undo', async () => {
|
||||
const initialSource = '<!doctype html><html><body><h1 data-od-id="hero" style="color: #111111">Hero</h1></body></html>';
|
||||
let persistedSource = initialSource;
|
||||
const savedSources: string[] = [];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url.includes('/api/projects/project-1/deployments')) {
|
||||
return new Response(JSON.stringify({ deployments: [] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
|
||||
const payload = JSON.parse(String(init.body)) as { content: string };
|
||||
persistedSource = payload.content;
|
||||
savedSources.push(payload.content);
|
||||
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/projects/project-1/raw/preview.html')) {
|
||||
return new Response(persistedSource, { status: 200 });
|
||||
}
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml={initialSource}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||
await waitFor(() => expect(panelState.props).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
panelState.props?.onApplyPatch(
|
||||
{ kind: 'set-style', id: 'hero', styles: { color: '#ef4444' } },
|
||||
'Style: Hero',
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(savedSources).toHaveLength(1));
|
||||
expect(savedSources[0]).toContain('rgb(239, 68, 68)');
|
||||
|
||||
act(() => {
|
||||
panelState.props?.onUndo();
|
||||
});
|
||||
await waitFor(() => expect(savedSources).toHaveLength(2));
|
||||
expect(savedSources[1]).toBe(initialSource);
|
||||
|
||||
act(() => {
|
||||
panelState.props?.onApplyPatch(
|
||||
{ kind: 'set-style', id: 'hero', styles: { backgroundColor: '#f97316' } },
|
||||
'Style: Hero',
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(savedSources).toHaveLength(3));
|
||||
|
||||
expect(savedSources[2]).toContain('background-color: rgb(249, 115, 22)');
|
||||
expect(savedSources[2]).not.toContain('rgb(239, 68, 68)');
|
||||
});
|
||||
});
|
||||
|
||||
function htmlPreviewFile(): ProjectFile {
|
||||
return {
|
||||
name: 'preview.html',
|
||||
path: 'preview.html',
|
||||
type: 'file',
|
||||
size: 1024,
|
||||
mtime: 1710000000,
|
||||
mime: 'text/html',
|
||||
kind: 'html',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'Preview',
|
||||
entry: 'preview.html',
|
||||
renderer: 'html',
|
||||
exports: ['html'],
|
||||
},
|
||||
};
|
||||
}
|
||||
167
apps/web/tests/components/FileViewer.manual-edit.test.tsx
Normal file
167
apps/web/tests/components/FileViewer.manual-edit.test.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
FileViewer,
|
||||
cancelManualEditPendingStyleSnapshot,
|
||||
} from '../../src/components/FileViewer';
|
||||
import type { ProjectFile } from '../../src/types';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('FileViewer manual edit regressions', () => {
|
||||
it('removes invalid fields from pending manual edit style saves without dropping unrelated fields', () => {
|
||||
expect(cancelManualEditPendingStyleSnapshot({
|
||||
id: 'hero',
|
||||
label: 'Style: Hero',
|
||||
version: 1,
|
||||
styles: { fontSize: '4px', color: '#111111' },
|
||||
}, 'hero', ['fontSize'])).toEqual({
|
||||
id: 'hero',
|
||||
label: 'Style: Hero',
|
||||
version: 1,
|
||||
styles: { color: '#111111' },
|
||||
});
|
||||
|
||||
expect(cancelManualEditPendingStyleSnapshot({
|
||||
id: 'hero',
|
||||
label: 'Style: Hero',
|
||||
version: 1,
|
||||
styles: { fontSize: '4px' },
|
||||
}, 'hero', ['fontSize'])).toBeNull();
|
||||
|
||||
const otherTargetPending = {
|
||||
id: 'hero',
|
||||
label: 'Style: Hero',
|
||||
version: 1,
|
||||
styles: { fontSize: '4px' },
|
||||
};
|
||||
expect(cancelManualEditPendingStyleSnapshot(otherTargetPending, 'cta', ['fontSize'])).toBe(otherTargetPending);
|
||||
});
|
||||
|
||||
it('does not let a pending manual edit style save survive a file switch', () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return new Response('<!doctype html><html><body></body></html>', { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const first = htmlPreviewFile();
|
||||
const second = { ...htmlPreviewFile(), name: 'second.html', path: 'second.html' };
|
||||
const { rerender } = render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
file={first}
|
||||
liveHtml='<!doctype html><html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||
const baseSizeInput = Array.from(document.querySelectorAll('.cc-row'))
|
||||
.find((row) => row.textContent?.includes('Base size'))
|
||||
?.querySelector('input') as HTMLInputElement | null;
|
||||
if (!baseSizeInput) throw new Error('Base size input not found');
|
||||
fireEvent.change(baseSizeInput, { target: { value: '18' } });
|
||||
|
||||
rerender(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
file={second}
|
||||
liveHtml='<!doctype html><html><body><main data-od-id="second">Second</main></body></html>'
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1100);
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
'/api/projects/project-1/files',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears loaded source immediately on file switch without liveHtml before manual edit can save', async () => {
|
||||
let secondResolve!: (value: Response) => void;
|
||||
const secondFetch = new Promise<Response>((resolve) => {
|
||||
secondResolve = resolve;
|
||||
});
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
|
||||
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/projects/project-1/raw/second.html')) return secondFetch;
|
||||
return new Response('<!doctype html><html><body><main data-od-id="hero">First</main></body></html>', { status: 200 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
try {
|
||||
const first = htmlPreviewFile();
|
||||
const second = { ...htmlPreviewFile(), name: 'second.html', path: 'second.html' };
|
||||
const { rerender } = render(<FileViewer projectId="project-1" file={first} />);
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/projects/project-1/raw/preview.html', {}));
|
||||
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||
const baseSizeInput = await waitFor(() => {
|
||||
const input = Array.from(document.querySelectorAll('.cc-row'))
|
||||
.find((row) => row.textContent?.includes('Base size'))
|
||||
?.querySelector('input') as HTMLInputElement | null;
|
||||
if (!input) throw new Error('Base size input not found');
|
||||
return input;
|
||||
});
|
||||
fireEvent.change(baseSizeInput, { target: { value: '18' } });
|
||||
|
||||
rerender(<FileViewer projectId="project-1" file={second} />);
|
||||
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
'/api/projects/project-1/files',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
secondResolve(new Response('<!doctype html><html><body><main data-od-id="second">Second</main></body></html>', { status: 200 }));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function htmlPreviewFile(): ProjectFile {
|
||||
return {
|
||||
name: 'preview.html',
|
||||
path: 'preview.html',
|
||||
type: 'file',
|
||||
size: 1024,
|
||||
mtime: 1710000000,
|
||||
mime: 'text/html',
|
||||
kind: 'html',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'Preview',
|
||||
entry: 'preview.html',
|
||||
renderer: 'html',
|
||||
exports: ['html'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Simulate } from 'react-dom/test-utils';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary } from '../../src/components/ManualEditPanel';
|
||||
import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary, normalizeManualEditStyles } from '../../src/components/ManualEditPanel';
|
||||
import { emptyManualEditStyles, type ManualEditTarget } from '../../src/edit-mode/types';
|
||||
|
||||
const target: ManualEditTarget = {
|
||||
|
|
@ -16,6 +17,7 @@ const target: ManualEditTarget = {
|
|||
fields: { text: 'Original' },
|
||||
attributes: { 'data-od-id': 'hero-title' },
|
||||
styles: emptyManualEditStyles(),
|
||||
isLayoutContainer: false,
|
||||
outerHtml: '<h1 data-od-id="hero-title">Original</h1>',
|
||||
};
|
||||
|
||||
|
|
@ -43,47 +45,371 @@ describe('ManualEditPanel', () => {
|
|||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
it('opens with target metadata and calls selection from the layers list', () => {
|
||||
const onSelectTarget = vi.fn();
|
||||
renderPanel({ onSelectTarget });
|
||||
it('renders the style inspector without the advanced editor entry', () => {
|
||||
renderPanel();
|
||||
|
||||
click(buttonByText('▸ Advanced'));
|
||||
const summaries = Array.from(host.querySelectorAll('details > summary'));
|
||||
const layersSummary = summaries.find((s) => s.textContent?.includes('Layers')) as HTMLElement | undefined;
|
||||
if (layersSummary) {
|
||||
const details = layersSummary.parentElement as HTMLDetailsElement;
|
||||
act(() => { details.open = true; });
|
||||
}
|
||||
click(buttonByText('Hero Title'));
|
||||
|
||||
expect(onSelectTarget).toHaveBeenCalledWith(target);
|
||||
expect(host.textContent).toContain('TYPOGRAPHY');
|
||||
expect(host.textContent).not.toContain('Advanced');
|
||||
expect(host.textContent).not.toContain('Content');
|
||||
});
|
||||
|
||||
it('builds content patches from the active target', () => {
|
||||
const onApplyPatch = vi.fn();
|
||||
renderPanel({ onApplyPatch });
|
||||
it('allows returning from an element inspector to the page inspector', () => {
|
||||
const onClearSelection = vi.fn();
|
||||
renderPanel({ onClearSelection });
|
||||
|
||||
click(buttonByText('▸ Advanced'));
|
||||
click(buttonByText('Content'));
|
||||
click(buttonByText('Apply Content'));
|
||||
const pageButton = host.querySelector('button[aria-label="Show page inspector"]') as HTMLButtonElement | null;
|
||||
if (!pageButton) throw new Error('Page inspector button not found');
|
||||
|
||||
expect(onApplyPatch).toHaveBeenCalledWith(
|
||||
{ id: 'hero-title', kind: 'set-text', value: 'Updated copy' },
|
||||
'Content: Hero Title',
|
||||
act(() => {
|
||||
pageButton.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onClearSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normalizes font stacks and writes a usable font-family value', () => {
|
||||
const onDraftChange = vi.fn();
|
||||
const onStyleChange = vi.fn();
|
||||
renderPanel({
|
||||
onDraftChange,
|
||||
onStyleChange,
|
||||
styles: {
|
||||
...emptyManualEditStyles(),
|
||||
fontFamily: '"Roboto", sans-serif',
|
||||
fontSize: '32px',
|
||||
color: '#111111',
|
||||
paddingTop: '8px',
|
||||
},
|
||||
});
|
||||
|
||||
const fontSelect = host.querySelector('select') as HTMLSelectElement | null;
|
||||
if (!fontSelect) throw new Error('Font select not found');
|
||||
expect(fontSelect.value).toBe('Roboto, Arial, sans-serif');
|
||||
|
||||
act(() => {
|
||||
fontSelect.value = 'Georgia, serif';
|
||||
fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onDraftChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||
styles: expect.objectContaining({ fontFamily: 'Georgia, serif' }),
|
||||
}));
|
||||
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { fontFamily: 'Georgia, serif' }, 'Style: Hero Title');
|
||||
expect(onStyleChange).not.toHaveBeenCalledWith(
|
||||
'hero-title',
|
||||
expect.objectContaining({ fontSize: '32px', color: '#111111', paddingTop: '8px' }),
|
||||
'Style: Hero Title',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows invalid attribute JSON without applying a write patch', () => {
|
||||
const onApplyPatch = vi.fn();
|
||||
it('shows px-backed values without px in numeric inputs', () => {
|
||||
renderPanel({
|
||||
styles: {
|
||||
...emptyManualEditStyles(),
|
||||
fontSize: '32px',
|
||||
},
|
||||
});
|
||||
|
||||
const sizeRow = Array.from(host.querySelectorAll('.cc-row'))
|
||||
.find((row) => row.textContent?.includes('Size'));
|
||||
const sizeInput = sizeRow?.querySelector('input') as HTMLInputElement | null;
|
||||
if (!sizeInput) throw new Error('Size input not found');
|
||||
|
||||
expect(sizeInput.value).toBe('32');
|
||||
});
|
||||
|
||||
it('increments normal rows and quad cells with normalized values', () => {
|
||||
const onStyleChange = vi.fn();
|
||||
renderPanel({
|
||||
onStyleChange,
|
||||
styles: {
|
||||
...emptyManualEditStyles(),
|
||||
fontSize: '32px',
|
||||
opacity: '0.5',
|
||||
paddingTop: '8px',
|
||||
},
|
||||
});
|
||||
|
||||
const sizeIncrease = host.querySelector('button[aria-label="Size increase"]') as HTMLButtonElement | null;
|
||||
const opacityIncrease = host.querySelector('button[aria-label="Opacity increase"]') as HTMLButtonElement | null;
|
||||
const paddingTopDecrease = host.querySelector('.cc-quad button[aria-label="T decrease"]') as HTMLButtonElement | null;
|
||||
if (!sizeIncrease || !opacityIncrease || !paddingTopDecrease) throw new Error('Stepper button not found');
|
||||
|
||||
act(() => {
|
||||
sizeIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
opacityIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
paddingTopDecrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { fontSize: '33px' }, 'Style: Hero Title');
|
||||
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { opacity: '0.6' }, 'Style: Hero Title');
|
||||
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { paddingTop: '7px' }, 'Style: Hero Title');
|
||||
});
|
||||
|
||||
it('does not persist an unchanged target style when the inspector opens', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onApplyPatch = vi.fn();
|
||||
renderPanel({ onApplyPatch });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
|
||||
expect(onApplyPatch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes valid style values before host preview/persistence', () => {
|
||||
expect(normalizeManualEditStyles({
|
||||
fontSize: '48',
|
||||
color: '#f00',
|
||||
opacity: '2',
|
||||
lineHeight: '1.4',
|
||||
}, { layoutEnabled: true })).toEqual({
|
||||
ok: true,
|
||||
styles: {
|
||||
fontSize: '48px',
|
||||
color: '#ff0000',
|
||||
opacity: '1',
|
||||
lineHeight: '1.4',
|
||||
},
|
||||
});
|
||||
expect(normalizeManualEditStyles({ lineHeight: '49px' }, { layoutEnabled: true })).toEqual({
|
||||
ok: true,
|
||||
styles: { lineHeight: '49px' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid style values before host preview/persistence', () => {
|
||||
expect(normalizeManualEditStyles({ color: 'tomato' }, { layoutEnabled: true })).toEqual({
|
||||
ok: false,
|
||||
error: 'color must be a hex color.',
|
||||
});
|
||||
expect(normalizeManualEditStyles({ lineHeight: '-1px' }, { layoutEnabled: true })).toEqual({
|
||||
ok: false,
|
||||
error: 'Line height must be a positive number or px value.',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats empty values as inline style clears', () => {
|
||||
expect(normalizeManualEditStyles({ fontSize: '', color: '' }, { layoutEnabled: true })).toEqual({
|
||||
ok: true,
|
||||
styles: { fontSize: '', color: '' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not validate unchanged computed line-height values on blur', () => {
|
||||
const onError = vi.fn();
|
||||
renderPanel({ onApplyPatch, onError, attributesText: '{bad' });
|
||||
const onStyleChange = vi.fn();
|
||||
renderPanel({
|
||||
onError,
|
||||
onStyleChange,
|
||||
styles: {
|
||||
...emptyManualEditStyles(),
|
||||
lineHeight: '48.96px',
|
||||
},
|
||||
});
|
||||
|
||||
click(buttonByText('▸ Advanced'));
|
||||
click(buttonByText('Attributes'));
|
||||
click(buttonByText('Apply Attributes'));
|
||||
const lineInput = Array.from(host.querySelectorAll('.cc-row'))
|
||||
.find((row) => row.textContent?.includes('Line'))
|
||||
?.querySelector('input') as HTMLInputElement | null;
|
||||
if (!lineInput) throw new Error('Line input not found');
|
||||
|
||||
expect(onError).toHaveBeenCalled();
|
||||
expect(onApplyPatch).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
lineInput.dispatchEvent(new dom.window.FocusEvent('blur', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
expect(onStyleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts edited computed pixel line-height values', () => {
|
||||
const onError = vi.fn();
|
||||
const onStyleChange = vi.fn();
|
||||
renderPanel({
|
||||
onError,
|
||||
onStyleChange,
|
||||
styles: {
|
||||
...emptyManualEditStyles(),
|
||||
lineHeight: '48.96px',
|
||||
},
|
||||
});
|
||||
|
||||
const lineInput = Array.from(host.querySelectorAll('.cc-row'))
|
||||
.find((row) => row.textContent?.includes('Line'))
|
||||
?.querySelector('input') as HTMLInputElement | null;
|
||||
if (!lineInput) throw new Error('Line input not found');
|
||||
|
||||
act(() => {
|
||||
lineInput.value = '49px';
|
||||
Simulate.change(lineInput);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('');
|
||||
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { lineHeight: '49px' }, 'Style: Hero Title');
|
||||
});
|
||||
|
||||
it('does not persist unchanged page styles when no target is selected', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onApplyPatch = vi.fn();
|
||||
renderPanel({ onApplyPatch, selectedTarget: null });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
});
|
||||
|
||||
expect(onApplyPatch).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('emits only the changed page style field', () => {
|
||||
const onStyleChange = vi.fn();
|
||||
renderPanel({ onStyleChange, selectedTarget: null });
|
||||
|
||||
const bgSwatch = host.querySelector('button[aria-label="Pick Background"]') as HTMLButtonElement | null;
|
||||
if (!bgSwatch) throw new Error('Background swatch not found');
|
||||
|
||||
act(() => {
|
||||
bgSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
const colorTile = host.querySelector('button[aria-label="#3b82f6"]') as HTMLButtonElement | null;
|
||||
if (!colorTile) throw new Error('Background color tile not found');
|
||||
act(() => {
|
||||
colorTile.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onStyleChange).toHaveBeenCalledWith('__body__', { backgroundColor: '#3b82f6' }, 'Page styles');
|
||||
expect(onStyleChange).not.toHaveBeenCalledWith(
|
||||
'__body__',
|
||||
expect.objectContaining({ fontFamily: expect.any(String) }),
|
||||
'Page styles',
|
||||
);
|
||||
expect(onStyleChange).not.toHaveBeenCalledWith(
|
||||
'__body__',
|
||||
expect.objectContaining({ fontSize: expect.any(String) }),
|
||||
'Page styles',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit untouched page fields when changing the page font', () => {
|
||||
const onStyleChange = vi.fn();
|
||||
renderPanel({ onStyleChange, selectedTarget: null });
|
||||
|
||||
const fontSelect = host.querySelector('.cc-row select') as HTMLSelectElement | null;
|
||||
if (!fontSelect) throw new Error('Font select not found');
|
||||
|
||||
act(() => {
|
||||
fontSelect.value = 'Georgia, serif';
|
||||
fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onStyleChange).toHaveBeenCalledWith('__body__', { fontFamily: 'Georgia, serif' }, 'Page styles');
|
||||
expect(onStyleChange).not.toHaveBeenCalledWith(
|
||||
'__body__',
|
||||
expect.objectContaining({ backgroundColor: expect.any(String) }),
|
||||
'Page styles',
|
||||
);
|
||||
expect(onStyleChange).not.toHaveBeenCalledWith(
|
||||
'__body__',
|
||||
expect.objectContaining({ fontSize: expect.any(String) }),
|
||||
'Page styles',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows an inactive Page inspector for fragment HTML sources', () => {
|
||||
const onStyleChange = vi.fn();
|
||||
renderPanel({ onStyleChange, selectedTarget: null, pageStylesEnabled: false });
|
||||
|
||||
expect(host.textContent).toContain('Page styles are available only for full HTML documents.');
|
||||
expect(host.textContent).not.toContain('Background');
|
||||
expect(host.querySelector('input')).toBeNull();
|
||||
expect(host.querySelector('select')).toBeNull();
|
||||
expect(onStyleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps explicit empty page values as field-specific clears', () => {
|
||||
const onStyleChange = vi.fn();
|
||||
renderPanel({ onStyleChange, selectedTarget: null });
|
||||
|
||||
const fontSelect = host.querySelector('.cc-row select') as HTMLSelectElement | null;
|
||||
if (!fontSelect) throw new Error('Font select not found');
|
||||
|
||||
act(() => {
|
||||
fontSelect.value = '';
|
||||
fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onStyleChange).toHaveBeenCalledWith('__body__', { fontFamily: '' }, 'Page styles');
|
||||
expect(onStyleChange).not.toHaveBeenCalledWith(
|
||||
'__body__',
|
||||
expect.objectContaining({ backgroundColor: expect.any(String), fontFamily: expect.any(String) }),
|
||||
'Page styles',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders layout as inactive for non-layout single targets', () => {
|
||||
const onStyleChange = vi.fn();
|
||||
renderPanel({
|
||||
onStyleChange,
|
||||
styles: {
|
||||
...emptyManualEditStyles(),
|
||||
gap: 'normal',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
});
|
||||
|
||||
const layoutSection = sectionByTitle('LAYOUT');
|
||||
expect(layoutSection.classList.contains('cc-section-inactive')).toBe(true);
|
||||
expect(layoutSection.textContent).toContain('Select a container or group to edit layout.');
|
||||
const gapInput = layoutSection.querySelector('input') as HTMLInputElement | null;
|
||||
const directionSelect = layoutSection.querySelector('select') as HTMLSelectElement | null;
|
||||
if (!gapInput || !directionSelect) throw new Error('Layout controls not found');
|
||||
|
||||
expect(gapInput.disabled).toBe(true);
|
||||
expect(directionSelect.disabled).toBe(true);
|
||||
expect(normalizeManualEditStyles({ gap: '12', flexDirection: 'column' }, { layoutEnabled: false })).toEqual({
|
||||
ok: true,
|
||||
styles: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('enables layout controls for flex or grid containers', () => {
|
||||
const onStyleChange = vi.fn();
|
||||
renderPanel({
|
||||
onStyleChange,
|
||||
selectedTarget: { ...target, isLayoutContainer: true },
|
||||
styles: {
|
||||
...emptyManualEditStyles(),
|
||||
gap: '8px',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
});
|
||||
|
||||
const layoutSection = sectionByTitle('LAYOUT');
|
||||
expect(layoutSection.classList.contains('cc-section-inactive')).toBe(false);
|
||||
expect(layoutSection.textContent).not.toContain('Select a container or group to edit layout.');
|
||||
const gapInput = layoutSection.querySelector('input') as HTMLInputElement | null;
|
||||
const directionSelect = layoutSection.querySelector('select') as HTMLSelectElement | null;
|
||||
const gapIncrease = layoutSection.querySelector('button[aria-label="Gap increase"]') as HTMLButtonElement | null;
|
||||
if (!gapInput || !directionSelect) throw new Error('Layout controls not found');
|
||||
expect(gapInput.disabled).toBe(false);
|
||||
expect(directionSelect.disabled).toBe(false);
|
||||
if (!gapIncrease) throw new Error('Gap increase control not found');
|
||||
|
||||
act(() => {
|
||||
gapIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
directionSelect.value = 'column';
|
||||
directionSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { gap: '9px' }, 'Style: Hero Title');
|
||||
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { flexDirection: 'column' }, 'Style: Hero Title');
|
||||
});
|
||||
|
||||
it('summarizes full-source history entries without rendering the full file', () => {
|
||||
|
|
@ -95,37 +421,61 @@ describe('ManualEditPanel', () => {
|
|||
expect(manualEditPatchSummary({ kind: 'set-full-source', source })).not.toContain('x'.repeat(100));
|
||||
});
|
||||
|
||||
function sectionByTitle(title: string): HTMLElement {
|
||||
const section = Array.from(host.querySelectorAll('.cc-section'))
|
||||
.find((candidate) => candidate.querySelector('.cc-section-head')?.textContent === title) as HTMLElement | undefined;
|
||||
if (!section) throw new Error(`${title} section not found`);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderPanel({
|
||||
onSelectTarget = vi.fn(),
|
||||
onDraftChange = vi.fn(),
|
||||
onApplyPatch = vi.fn(),
|
||||
onError = vi.fn(),
|
||||
onStyleChange = vi.fn(),
|
||||
onInvalidStyle = vi.fn(),
|
||||
onClearSelection = vi.fn(),
|
||||
attributesText = '{}',
|
||||
selectedTarget = target,
|
||||
styles = emptyManualEditStyles(),
|
||||
pageStylesEnabled = true,
|
||||
}: {
|
||||
onSelectTarget?: ReturnType<typeof vi.fn>;
|
||||
onDraftChange?: ReturnType<typeof vi.fn>;
|
||||
onApplyPatch?: ReturnType<typeof vi.fn>;
|
||||
onError?: ReturnType<typeof vi.fn>;
|
||||
onStyleChange?: ReturnType<typeof vi.fn>;
|
||||
onInvalidStyle?: ReturnType<typeof vi.fn>;
|
||||
onClearSelection?: ReturnType<typeof vi.fn>;
|
||||
attributesText?: string;
|
||||
}) {
|
||||
selectedTarget?: ManualEditTarget | null;
|
||||
styles?: ReturnType<typeof emptyManualEditStyles>;
|
||||
pageStylesEnabled?: boolean;
|
||||
} = {}) {
|
||||
const draft = {
|
||||
...emptyManualEditDraft('<html></html>'),
|
||||
text: 'Updated copy',
|
||||
attributesText,
|
||||
styles,
|
||||
outerHtml: target.outerHtml,
|
||||
};
|
||||
act(() => {
|
||||
root.render(
|
||||
<ManualEditPanel
|
||||
targets={[target]}
|
||||
selectedTarget={target}
|
||||
selectedTarget={selectedTarget}
|
||||
draft={draft}
|
||||
history={[]}
|
||||
error={null}
|
||||
canUndo={false}
|
||||
canRedo={false}
|
||||
onSelectTarget={onSelectTarget}
|
||||
onDraftChange={vi.fn()}
|
||||
pageStylesEnabled={pageStylesEnabled}
|
||||
onSelectTarget={vi.fn()}
|
||||
onDraftChange={onDraftChange}
|
||||
onStyleChange={onStyleChange}
|
||||
onInvalidStyle={onInvalidStyle}
|
||||
onApplyPatch={onApplyPatch}
|
||||
onError={onError}
|
||||
onClearSelection={onClearSelection}
|
||||
onCancelDraft={vi.fn()}
|
||||
onUndo={vi.fn()}
|
||||
onRedo={vi.fn()}
|
||||
|
|
@ -134,16 +484,4 @@ describe('ManualEditPanel', () => {
|
|||
});
|
||||
}
|
||||
|
||||
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 }));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
16
apps/web/tests/components/Theater/styles.test.ts
Normal file
16
apps/web/tests/components/Theater/styles.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Critique Theater styles', () => {
|
||||
it('keeps the Theater UI selectors in the global stylesheet', () => {
|
||||
const css = readFileSync(join(process.cwd(), 'src/index.css'), 'utf8');
|
||||
|
||||
expect(css).toContain('.theater-stage');
|
||||
expect(css).toContain('.theater-lane');
|
||||
expect(css).toContain('.theater-score-ticker');
|
||||
expect(css).toContain('.theater-interrupt');
|
||||
expect(css).toContain('.theater-transcript');
|
||||
expect(css.indexOf('.theater-stage')).toBeLessThan(css.indexOf('.assistant-feedback-wrap'));
|
||||
});
|
||||
});
|
||||
|
|
@ -66,4 +66,81 @@ describe('manual edit bridge target normalization', () => {
|
|||
expect(bridge).toContain('if (!isSourceMappable(nodes[i])) continue;');
|
||||
expect(bridge).toContain('if (isPrimaryTarget(el)) return el;');
|
||||
});
|
||||
|
||||
it('acks live preview style patches by id and version', () => {
|
||||
const bridge = buildManualEditBridge(true);
|
||||
|
||||
expect(bridge).toContain("type: 'od-edit-preview-style-applied'");
|
||||
expect(bridge).toContain('version: Number(version) || 0, ok: true');
|
||||
expect(bridge).toContain("ok: false, error: 'Target not found'");
|
||||
});
|
||||
|
||||
it('moves the runtime selected marker between selected targets', () => {
|
||||
const dom = new JSDOM(
|
||||
`<main>
|
||||
<h1 data-od-id="title">Title</h1>
|
||||
<p data-od-id="body">Body</p>
|
||||
</main>${buildManualEditBridge(true)}`,
|
||||
{ runScripts: 'dangerously', url: 'http://localhost' },
|
||||
);
|
||||
const title = dom.window.document.querySelector('[data-od-id="title"]')!;
|
||||
const body = dom.window.document.querySelector('[data-od-id="body"]')!;
|
||||
|
||||
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
|
||||
data: { type: 'od-edit-selected-target', id: 'title' },
|
||||
}));
|
||||
expect(title.getAttribute('data-od-edit-selected')).toBe('true');
|
||||
expect(body.hasAttribute('data-od-edit-selected')).toBe(false);
|
||||
|
||||
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
|
||||
data: { type: 'od-edit-selected-target', id: 'body' },
|
||||
}));
|
||||
expect(title.hasAttribute('data-od-edit-selected')).toBe(false);
|
||||
expect(body.getAttribute('data-od-edit-selected')).toBe('true');
|
||||
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it('clears runtime selected markers for null selection and edit-mode exit', () => {
|
||||
const dom = new JSDOM(
|
||||
`<main>
|
||||
<h1 data-od-id="title">Title</h1>
|
||||
<p data-od-id="body" data-od-edit-selected="true">Body</p>
|
||||
</main>${buildManualEditBridge(true)}`,
|
||||
{ runScripts: 'dangerously', url: 'http://localhost' },
|
||||
);
|
||||
const body = dom.window.document.querySelector('[data-od-id="body"]')!;
|
||||
|
||||
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
|
||||
data: { type: 'od-edit-selected-target', id: null },
|
||||
}));
|
||||
expect(body.hasAttribute('data-od-edit-selected')).toBe(false);
|
||||
|
||||
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
|
||||
data: { type: 'od-edit-selected-target', id: 'body' },
|
||||
}));
|
||||
expect(body.getAttribute('data-od-edit-selected')).toBe('true');
|
||||
|
||||
dom.window.dispatchEvent(new dom.window.MessageEvent('message', {
|
||||
data: { type: 'od-edit-mode', enabled: false },
|
||||
}));
|
||||
expect(body.hasAttribute('data-od-edit-selected')).toBe(false);
|
||||
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it('keeps runtime selection marker out of source-shaped target data', () => {
|
||||
const bridge = buildManualEditBridge(true);
|
||||
|
||||
expect(bridge).toContain("attr.name === 'data-od-edit-selected'");
|
||||
expect(bridge).toContain('replace(/\\sdata-od-edit-selected="[^"]*"/g, \'\')');
|
||||
expect(bridge).toContain('[data-od-edit-selected]');
|
||||
});
|
||||
|
||||
it('marks flex/grid targets as layout containers', () => {
|
||||
const bridge = buildManualEditBridge(true);
|
||||
|
||||
expect(bridge).toContain('isLayoutContainer: isLayoutContainer(el)');
|
||||
expect(bridge).toContain("display.indexOf('flex') >= 0 || display.indexOf('grid') >= 0");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
import { JSDOM } from 'jsdom';
|
||||
import {
|
||||
applyManualEditPatch,
|
||||
isManualEditFullHtmlDocument,
|
||||
readManualEditAttributes,
|
||||
readManualEditFields,
|
||||
readManualEditOuterHtml,
|
||||
|
|
@ -92,15 +93,33 @@ describe('manual edit source patches', () => {
|
|||
const result = applyManualEditPatch(baseSource, {
|
||||
kind: 'set-style',
|
||||
id: 'card',
|
||||
styles: { color: '', backgroundColor: 'blue', fontSize: '24px' },
|
||||
styles: {
|
||||
color: '',
|
||||
backgroundColor: '#ff0000',
|
||||
fontSize: '24px',
|
||||
paddingTop: '12px',
|
||||
marginLeft: '4px',
|
||||
borderTopWidth: '2px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: '#000000',
|
||||
borderRadius: '8px',
|
||||
opacity: '0.5',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const styles = readManualEditStyles(result.source, 'card');
|
||||
expect(styles.color).toBe('');
|
||||
expect(styles.backgroundColor).toBe('blue');
|
||||
expect(styles.backgroundColor).toBe('rgb(255, 0, 0)');
|
||||
expect(styles.fontSize).toBe('24px');
|
||||
expect(styles.padding).toBe('8px');
|
||||
expect(styles.padding).toBe('12px 8px 8px');
|
||||
expect(styles.paddingTop).toBe('12px');
|
||||
expect(styles.marginLeft).toBe('4px');
|
||||
expect(styles.borderTopWidth).toBe('2px');
|
||||
expect(styles.borderStyle).toBe('solid');
|
||||
expect(styles.borderColor).toBe('rgb(0, 0, 0)');
|
||||
expect(styles.borderRadius).toBe('8px');
|
||||
expect(styles.opacity).toBe('0.5');
|
||||
});
|
||||
|
||||
it('applies attributes additively and preserves class/style unless explicitly updated', () => {
|
||||
|
|
@ -157,6 +176,12 @@ describe('manual edit source patches', () => {
|
|||
expect(result.source).not.toContain('<body');
|
||||
});
|
||||
|
||||
it('detects full documents after leading comments and keeps fragments distinct', () => {
|
||||
expect(isManualEditFullHtmlDocument('<!-- generated -->\n<!doctype html><html></html>')).toBe(true);
|
||||
expect(isManualEditFullHtmlDocument('<?xml version="1.0"?>\n<html></html>')).toBe(true);
|
||||
expect(isManualEditFullHtmlDocument('<main><h1>Fragment</h1></main>')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves full documents with leading comments when saving patches', () => {
|
||||
const source = [
|
||||
'<!-- generated by open design -->',
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ test.beforeEach(async ({ page }) => {
|
|||
}, STORAGE_KEY);
|
||||
});
|
||||
|
||||
test('manual edit mode applies content, style, attribute, HTML, source, undo, and redo patches', async ({ page }) => {
|
||||
test('manual edit inspector previews and persists page and selected element styles', async ({ page }) => {
|
||||
await routeMockAgents(page);
|
||||
const projectId = await createEmptyProject(page, 'Manual edit smoke');
|
||||
await seedHtmlArtifact(page, projectId, 'manual-edit.html', manualEditHtml());
|
||||
|
|
@ -34,60 +34,92 @@ test('manual edit mode applies content, style, attribute, HTML, source, undo, an
|
|||
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();
|
||||
const responsivePair = frame.locator('[data-od-id="responsive-pair"]');
|
||||
await expect.poll(async () => responsivePair.evaluate((el) => getComputedStyle(el).flexDirection)).toBe('row');
|
||||
|
||||
await page.getByTestId('manual-edit-mode-toggle').click();
|
||||
await expect.poll(async () => responsivePair.evaluate((el) => getComputedStyle(el).flexDirection)).toBe('row');
|
||||
|
||||
await expect(page.locator('.manual-edit-modal')).toContainText('PAGE');
|
||||
await expect(page.locator('.manual-edit-tabs')).toHaveCount(0);
|
||||
await expect(page.locator('.manual-edit-layer-row')).toHaveCount(0);
|
||||
|
||||
await inspectorRow(page, 'Background').locator('input').fill('#eef2ff');
|
||||
await inspectorRow(page, 'Font').locator('select').selectOption('Georgia, serif');
|
||||
await inspectorRow(page, 'Base size').locator('input').fill('18');
|
||||
await expect
|
||||
.poll(async () => frame.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor))
|
||||
.toBe('rgb(238, 242, 255)');
|
||||
await expectFileSource(page, projectId, 'manual-edit.html', [
|
||||
'background-color:',
|
||||
'font-family: Georgia, serif',
|
||||
'font-size: 18px',
|
||||
'letter-spacing: 0.01em',
|
||||
]);
|
||||
|
||||
await frame.getByRole('heading', { name: 'Original Hero' }).click();
|
||||
await expect(page.locator('.manual-edit-modal')).toContainText('Hero title');
|
||||
await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY');
|
||||
await expect(page.locator('.manual-edit-modal')).toContainText('SIZE');
|
||||
await expect(page.locator('.manual-edit-modal')).toContainText('LAYOUT');
|
||||
await expect(page.locator('.manual-edit-modal')).toContainText('BOX');
|
||||
const selectedTitleMarker = frame.locator('[data-od-id="hero-title"][data-od-edit-selected="true"]');
|
||||
await expect(selectedTitleMarker).toHaveCount(1);
|
||||
const fontSizeInput = inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Size' }).locator('input');
|
||||
await fontSizeInput.click();
|
||||
await expect(selectedTitleMarker).toHaveCount(1);
|
||||
await expect(fontSizeInput).not.toHaveValue('');
|
||||
await expect(fontSizeInput).not.toHaveValue(/px/i);
|
||||
await page.getByRole('button', { name: 'Show page inspector' }).click();
|
||||
await expect(page.locator('.manual-edit-modal')).toContainText('PAGE');
|
||||
await expect(page.locator('.manual-edit-modal')).not.toContainText('TYPOGRAPHY');
|
||||
await expect(selectedTitleMarker).toHaveCount(0);
|
||||
await frame.getByRole('heading', { name: 'Original Hero' }).click();
|
||||
await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY');
|
||||
await expect(selectedTitleMarker).toHaveCount(1);
|
||||
await expect(inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Color' }).locator('input')).toHaveValue(/^#[0-9a-f]{6}$/);
|
||||
const lineInput = inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Line' }).locator('input');
|
||||
await lineInput.click();
|
||||
await lineInput.blur();
|
||||
await expect(page.locator('.manual-edit-error')).toHaveCount(0);
|
||||
await frame.locator('body').evaluate(() => {
|
||||
window.parent.postMessage({ type: 'od-edit-targets', targets: [] }, '*');
|
||||
});
|
||||
await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY');
|
||||
await expect(page.locator('.manual-edit-modal')).not.toContainText('PAGE');
|
||||
await frame.locator('body').evaluate(() => {
|
||||
(window as Window & typeof globalThis & { __manualEditSmokeMarker?: string }).__manualEditSmokeMarker = 'stable-frame';
|
||||
});
|
||||
|
||||
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 fontSizeInput.fill('48');
|
||||
await inspectorSection(page, 'TYPOGRAPHY').locator('.cc-row').filter({ hasText: 'Color' }).locator('input').fill('#ef4444');
|
||||
await inspectorSection(page, 'BOX').locator('.cc-row').filter({ hasText: 'Fill' }).locator('input').fill('#f97316');
|
||||
const paddingTopInput = inspectorSection(page, 'BOX').locator('.cc-quad').filter({ hasText: 'Padding' }).locator('input').first();
|
||||
await paddingTopInput.fill('12');
|
||||
await inspectorSection(page, 'BOX').locator('.cc-row').filter({ hasText: 'Radius' }).locator('input').fill('8');
|
||||
await expect(fontSizeInput).toHaveValue('48');
|
||||
await expect(paddingTopInput).toHaveValue('12');
|
||||
|
||||
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();
|
||||
const title = frame.getByRole('heading', { name: 'Original Hero' });
|
||||
await expect.poll(async () => title.evaluate((el) => getComputedStyle(el).fontSize)).toBe('48px');
|
||||
await expect(title).toHaveCSS('color', 'rgb(239, 68, 68)');
|
||||
await expect(title).toHaveCSS('background-color', 'rgb(249, 115, 22)');
|
||||
await expect(title).toHaveCSS('padding-top', '12px');
|
||||
await expect(title).toHaveCSS('border-radius', '8px');
|
||||
await expectFileSource(page, projectId, 'manual-edit.html', [
|
||||
'font-size: 48px',
|
||||
'color:',
|
||||
'background-color:',
|
||||
'padding-top: 12px',
|
||||
'border-radius: 8px',
|
||||
]);
|
||||
await expectFileSourceExcludes(page, projectId, 'manual-edit.html', ['data-od-edit-selected']);
|
||||
await expect(page.locator('.manual-edit-modal')).toContainText('TYPOGRAPHY');
|
||||
await expect(page.locator('.manual-edit-modal')).not.toContainText('PAGE');
|
||||
await expect(selectedTitleMarker).toHaveCount(1);
|
||||
await expect(page.locator('.manual-edit-error')).toHaveCount(0);
|
||||
await expect.poll(async () => frame.locator('body').evaluate(() => (
|
||||
window as Window & typeof globalThis & { __manualEditSmokeMarker?: string }
|
||||
).__manualEditSmokeMarker)).toBe('stable-frame');
|
||||
|
||||
await page.getByRole('button', { name: /^Share$/ }).click();
|
||||
await expect(page.getByRole('menuitem', { name: /Export as PDF/ })).toBeVisible();
|
||||
|
|
@ -171,13 +203,46 @@ async function expectFileSource(page: Page, projectId: string, fileName: string,
|
|||
.toBe(true);
|
||||
}
|
||||
|
||||
async function expectFileSourceExcludes(page: 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 inspectorRow(page: Page, label: string) {
|
||||
return page.locator('.manual-edit-modal .cc-row').filter({ hasText: label }).first();
|
||||
}
|
||||
|
||||
function inspectorSection(page: Page, title: string) {
|
||||
return page.locator('.manual-edit-modal .cc-section').filter({ hasText: title }).first();
|
||||
}
|
||||
|
||||
function manualEditHtml(): string {
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Manual Edit</title></head>
|
||||
<body>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Manual Edit</title>
|
||||
<style>
|
||||
.responsive-pair { display: flex; gap: 24px; }
|
||||
.responsive-pair > div { flex: 1 1 0; min-height: 40px; }
|
||||
@media (max-width: 700px) {
|
||||
.responsive-pair { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="font-family: Inter, system-ui, sans-serif; font-size: 16px; letter-spacing: 0.01em;">
|
||||
<main>
|
||||
<section data-od-id="hero" data-od-label="Hero section">
|
||||
<section data-od-id="responsive-pair" data-od-label="Responsive pair" class="responsive-pair">
|
||||
<div data-od-id="pair-a">Left panel</div>
|
||||
<div data-od-id="pair-b">Right panel</div>
|
||||
</section>
|
||||
<section data-od-id="hero" data-od-label="Hero section" style="display:flex;gap:8px;align-items:center;">
|
||||
<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;">
|
||||
|
|
|
|||
|
|
@ -10,42 +10,42 @@
|
|||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 24px;
|
||||
}
|
||||
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 16px 48px 16px 20px;
|
||||
|
|
@ -55,12 +55,12 @@
|
|||
background: white;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #0066ff;
|
||||
}
|
||||
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
transform: translateY(-50%);
|
||||
color: #999;
|
||||
}
|
||||
|
||||
|
||||
.categories {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
margin-bottom: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.category-btn {
|
||||
padding: 10px 20px;
|
||||
border: 2px solid #e0e0e0;
|
||||
|
|
@ -88,24 +88,24 @@
|
|||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
|
||||
.category-btn:hover {
|
||||
border-color: #0066ff;
|
||||
color: #0066ff;
|
||||
}
|
||||
|
||||
|
||||
.category-btn.active {
|
||||
background: #0066ff;
|
||||
border-color: #0066ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
.faq-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.faq-item {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
|
@ -113,15 +113,15 @@
|
|||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
|
||||
.faq-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
|
||||
.faq-item.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.faq-question {
|
||||
width: 100%;
|
||||
padding: 20px 24px;
|
||||
|
|
@ -137,39 +137,39 @@
|
|||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.faq-question:hover {
|
||||
color: #0066ff;
|
||||
}
|
||||
|
||||
|
||||
.faq-icon {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
|
||||
.faq-item.open .faq-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
|
||||
.faq-answer {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
|
||||
|
||||
.faq-answer-content {
|
||||
padding: 0 24px 20px;
|
||||
color: #555;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
|
||||
.faq-item.open .faq-answer {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
|
||||
.footer-cta {
|
||||
margin-top: 64px;
|
||||
padding: 32px;
|
||||
|
|
@ -178,18 +178,18 @@
|
|||
border-radius: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
.footer-cta h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 12px;
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
|
||||
.footer-cta p {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
.contact-link {
|
||||
display: inline-block;
|
||||
padding: 12px 32px;
|
||||
|
|
@ -200,18 +200,18 @@
|
|||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
|
||||
.contact-link:hover {
|
||||
background: #0052cc;
|
||||
}
|
||||
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
color: #999;
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.no-results.show {
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -223,12 +223,12 @@
|
|||
<h1>Frequently Asked Questions</h1>
|
||||
<p class="subtitle">Find answers to common questions about our product and services</p>
|
||||
</header>
|
||||
|
||||
|
||||
<div class="search-box" data-od-id="search">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
id="searchInput"
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
id="searchInput"
|
||||
placeholder="Search for answers..."
|
||||
aria-label="Search FAQ"
|
||||
>
|
||||
|
|
@ -236,7 +236,7 @@
|
|||
<path d="M9 17A8 8 0 1 0 9 1a8 8 0 0 0 0 16zM18 18l-4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="categories" data-od-id="categories">
|
||||
<button class="category-btn active" data-category="all">All</button>
|
||||
<button class="category-btn" data-category="billing">Billing</button>
|
||||
|
|
@ -244,7 +244,7 @@
|
|||
<button class="category-btn" data-category="technical">Technical</button>
|
||||
<button class="category-btn" data-category="general">General</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="faq-list" data-od-id="faq-list">
|
||||
<!-- Billing -->
|
||||
<div class="faq-item" data-category="billing">
|
||||
|
|
@ -260,7 +260,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="faq-item" data-category="billing">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>What payment methods do you accept?</span>
|
||||
|
|
@ -274,7 +274,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="faq-item" data-category="billing">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>Can I get a refund?</span>
|
||||
|
|
@ -288,7 +288,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Account -->
|
||||
<div class="faq-item" data-category="account">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
|
|
@ -303,7 +303,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="faq-item" data-category="account">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>Can I change my email address?</span>
|
||||
|
|
@ -317,7 +317,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="faq-item" data-category="account">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>How do I delete my account?</span>
|
||||
|
|
@ -331,7 +331,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Technical -->
|
||||
<div class="faq-item" data-category="technical">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
|
|
@ -346,7 +346,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="faq-item" data-category="technical">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>Is there a mobile app?</span>
|
||||
|
|
@ -360,7 +360,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="faq-item" data-category="technical">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>How do I export my data?</span>
|
||||
|
|
@ -374,7 +374,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- General -->
|
||||
<div class="faq-item" data-category="general">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
|
|
@ -389,7 +389,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="faq-item" data-category="general">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>How do I get started?</span>
|
||||
|
|
@ -403,7 +403,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="faq-item" data-category="general">
|
||||
<button class="faq-question" aria-expanded="false">
|
||||
<span>Do you offer customer support?</span>
|
||||
|
|
@ -418,34 +418,34 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="no-results" id="noResults">
|
||||
<p>No questions found matching your search.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="footer-cta" data-od-id="footer-cta">
|
||||
<h2>Still have questions?</h2>
|
||||
<p>Can't find what you're looking for? Our support team is here to help.</p>
|
||||
<a href="mailto:support@example.com" class="contact-link">Contact Support</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
// Accordion functionality
|
||||
const faqItems = document.querySelectorAll('.faq-item');
|
||||
|
||||
|
||||
faqItems.forEach(item => {
|
||||
const question = item.querySelector('.faq-question');
|
||||
|
||||
|
||||
question.addEventListener('click', () => {
|
||||
const isOpen = item.classList.contains('open');
|
||||
|
||||
|
||||
// Close all items
|
||||
faqItems.forEach(i => {
|
||||
i.classList.remove('open');
|
||||
i.querySelector('.faq-question').setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
|
||||
// Open clicked item if it was closed
|
||||
if (!isOpen) {
|
||||
item.classList.add('open');
|
||||
|
|
@ -453,21 +453,21 @@
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Search functionality
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const noResults = document.getElementById('noResults');
|
||||
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
const searchTerm = e.target.value.toLowerCase();
|
||||
let visibleCount = 0;
|
||||
|
||||
|
||||
faqItems.forEach(item => {
|
||||
const question = item.querySelector('.faq-question span').textContent.toLowerCase();
|
||||
const answer = item.querySelector('.faq-answer-content').textContent.toLowerCase();
|
||||
const matchesSearch = question.includes(searchTerm) || answer.includes(searchTerm);
|
||||
const matchesCategory = item.dataset.category === currentCategory || currentCategory === 'all';
|
||||
|
||||
|
||||
if (matchesSearch && matchesCategory) {
|
||||
item.classList.remove('hidden');
|
||||
visibleCount++;
|
||||
|
|
@ -475,32 +475,32 @@
|
|||
item.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
noResults.classList.toggle('show', visibleCount === 0);
|
||||
});
|
||||
|
||||
|
||||
// Category filtering
|
||||
const categoryBtns = document.querySelectorAll('.category-btn');
|
||||
let currentCategory = 'all';
|
||||
|
||||
|
||||
categoryBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
currentCategory = btn.dataset.category;
|
||||
|
||||
|
||||
// Update active state
|
||||
categoryBtns.forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
|
||||
// Filter items
|
||||
const searchTerm = searchInput.value.toLowerCase();
|
||||
let visibleCount = 0;
|
||||
|
||||
|
||||
faqItems.forEach(item => {
|
||||
const question = item.querySelector('.faq-question span').textContent.toLowerCase();
|
||||
const answer = item.querySelector('.faq-answer-content').textContent.toLowerCase();
|
||||
const matchesSearch = !searchTerm || question.includes(searchTerm) || answer.includes(searchTerm);
|
||||
const matchesCategory = currentCategory === 'all' || item.dataset.category === currentCategory;
|
||||
|
||||
|
||||
if (matchesSearch && matchesCategory) {
|
||||
item.classList.remove('hidden');
|
||||
visibleCount++;
|
||||
|
|
@ -508,7 +508,7 @@
|
|||
item.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
noResults.classList.toggle('show', visibleCount === 0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue