<!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Prototype visual editing mode for the preview app. Toggle the mode, pick elements (single or multiple), and edit margin, padding, border, background, static text, and text styles with live updates, then save changes back to code. - **New Features** - Pen tool button to enable/disable visual editing in the preview and toggle single/multi select; pro-only. - Inline toolbar anchored to the selected element for Margin (X/Y), Padding (X/Y), Border (width/radius/color), Background color, Edit Text (when static), and Text Style (font size/weight/color/font family). - Reads computed styles from the iframe and applies changes in real time; auto-appends px; overlay updates on scroll/resize. - Save/Discard dialog batches edits and writes Tailwind classes to source files via IPC; uses AST/recast to update className and text, replacing conflicting classes by prefix; supports multiple components. - New visual editor worker to get/apply styles and enable inline text editing via postMessage; selector client updated for coordinates streaming and highlight/deselect. - Proxy injects the visual editor client; new atoms track selected component, coordinates, and pending changes; component analysis flags dynamic styling and static text. - Uses runtimeId to correctly target and edit duplicate components. - **Dependencies** - Added @babel/parser for AST-based text updates. - Added recast for safer code transformations. <sup>Written for commit cdd50d33387a29103864f4743ae7570d64d61e93. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
180 lines
5.8 KiB
TypeScript
180 lines
5.8 KiB
TypeScript
import { useAtom, useAtomValue } from "jotai";
|
|
import { pendingVisualChangesAtom } from "@/atoms/previewAtoms";
|
|
import { Button } from "@/components/ui/button";
|
|
import { IpcClient } from "@/ipc/ipc_client";
|
|
import { Check, X } from "lucide-react";
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { showError, showSuccess } from "@/lib/toast";
|
|
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
|
|
|
interface VisualEditingChangesDialogProps {
|
|
onReset?: () => void;
|
|
iframeRef?: React.RefObject<HTMLIFrameElement | null>;
|
|
}
|
|
|
|
export function VisualEditingChangesDialog({
|
|
onReset,
|
|
iframeRef,
|
|
}: VisualEditingChangesDialogProps) {
|
|
const [pendingChanges, setPendingChanges] = useAtom(pendingVisualChangesAtom);
|
|
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const textContentCache = useRef<Map<string, string>>(new Map());
|
|
const [allResponsesReceived, setAllResponsesReceived] = useState(false);
|
|
const expectedResponsesRef = useRef<Set<string>>(new Set());
|
|
const isWaitingForResponses = useRef(false);
|
|
|
|
// Listen for text content responses
|
|
useEffect(() => {
|
|
const handleMessage = (event: MessageEvent) => {
|
|
if (event.data?.type === "dyad-text-content-response") {
|
|
const { componentId, text } = event.data;
|
|
if (text !== null) {
|
|
textContentCache.current.set(componentId, text);
|
|
}
|
|
|
|
// Mark this response as received
|
|
expectedResponsesRef.current.delete(componentId);
|
|
|
|
// Check if all responses received (only if we're actually waiting)
|
|
if (
|
|
isWaitingForResponses.current &&
|
|
expectedResponsesRef.current.size === 0
|
|
) {
|
|
setAllResponsesReceived(true);
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleMessage);
|
|
return () => window.removeEventListener("message", handleMessage);
|
|
}, []);
|
|
|
|
// Execute when all responses are received
|
|
useEffect(() => {
|
|
if (allResponsesReceived && isSaving) {
|
|
const applyChanges = async () => {
|
|
try {
|
|
const changesToSave = Array.from(pendingChanges.values());
|
|
|
|
// Update changes with cached text content
|
|
const updatedChanges = changesToSave.map((change) => {
|
|
const cachedText = textContentCache.current.get(change.componentId);
|
|
if (cachedText !== undefined) {
|
|
return { ...change, textContent: cachedText };
|
|
}
|
|
return change;
|
|
});
|
|
|
|
await IpcClient.getInstance().applyVisualEditingChanges({
|
|
appId: selectedAppId!,
|
|
changes: updatedChanges,
|
|
});
|
|
|
|
setPendingChanges(new Map());
|
|
textContentCache.current.clear();
|
|
showSuccess("Visual changes saved to source files");
|
|
onReset?.();
|
|
} catch (error) {
|
|
console.error("Failed to save visual editing changes:", error);
|
|
showError(`Failed to save changes: ${error}`);
|
|
} finally {
|
|
setIsSaving(false);
|
|
setAllResponsesReceived(false);
|
|
isWaitingForResponses.current = false;
|
|
}
|
|
};
|
|
|
|
applyChanges();
|
|
}
|
|
}, [
|
|
allResponsesReceived,
|
|
isSaving,
|
|
pendingChanges,
|
|
selectedAppId,
|
|
onReset,
|
|
setPendingChanges,
|
|
]);
|
|
|
|
if (pendingChanges.size === 0) return null;
|
|
|
|
const handleSave = async () => {
|
|
setIsSaving(true);
|
|
try {
|
|
const changesToSave = Array.from(pendingChanges.values());
|
|
|
|
if (iframeRef?.current?.contentWindow) {
|
|
// Reset state for new request
|
|
setAllResponsesReceived(false);
|
|
expectedResponsesRef.current.clear();
|
|
isWaitingForResponses.current = true;
|
|
|
|
// Track which components we're expecting responses from
|
|
for (const change of changesToSave) {
|
|
expectedResponsesRef.current.add(change.componentId);
|
|
}
|
|
|
|
// Request text content for each component
|
|
for (const change of changesToSave) {
|
|
iframeRef.current.contentWindow.postMessage(
|
|
{
|
|
type: "get-dyad-text-content",
|
|
data: { componentId: change.componentId },
|
|
},
|
|
"*",
|
|
);
|
|
}
|
|
|
|
// If no responses are expected, trigger immediately
|
|
if (expectedResponsesRef.current.size === 0) {
|
|
setAllResponsesReceived(true);
|
|
}
|
|
} else {
|
|
await IpcClient.getInstance().applyVisualEditingChanges({
|
|
appId: selectedAppId!,
|
|
changes: changesToSave,
|
|
});
|
|
|
|
setPendingChanges(new Map());
|
|
textContentCache.current.clear();
|
|
showSuccess("Visual changes saved to source files");
|
|
onReset?.();
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to save visual editing changes:", error);
|
|
showError(`Failed to save changes: ${error}`);
|
|
setIsSaving(false);
|
|
isWaitingForResponses.current = false;
|
|
}
|
|
};
|
|
|
|
const handleDiscard = () => {
|
|
setPendingChanges(new Map());
|
|
onReset?.();
|
|
};
|
|
|
|
return (
|
|
<div className="bg-[var(--background)] border-b border-[var(--border)] px-2 lg:px-4 py-1.5 flex flex-col lg:flex-row items-start lg:items-center lg:justify-between gap-1.5 lg:gap-4 flex-wrap">
|
|
<p className="text-xs lg:text-sm w-full lg:w-auto">
|
|
<span className="font-medium">{pendingChanges.size}</span> component
|
|
{pendingChanges.size > 1 ? "s" : ""} modified
|
|
</p>
|
|
<div className="flex gap-1 lg:gap-2 w-full lg:w-auto flex-wrap">
|
|
<Button size="sm" onClick={handleSave} disabled={isSaving}>
|
|
<Check size={14} className="mr-1" />
|
|
<span>{isSaving ? "Saving..." : "Save Changes"}</span>
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleDiscard}
|
|
disabled={isSaving}
|
|
>
|
|
<X size={14} className="mr-1" />
|
|
<span>Discard</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|