Visual editor (Pro only) (#1828)
<!-- 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. -->
This commit is contained in:
committed by
GitHub
parent
c174778d5f
commit
352d4330ed
@@ -16,6 +16,7 @@ import {
|
||||
ChevronsDownUp,
|
||||
ChartColumnIncreasing,
|
||||
SendHorizontalIcon,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -65,11 +66,16 @@ import { ChatErrorBox } from "./ChatErrorBox";
|
||||
import {
|
||||
selectedComponentsPreviewAtom,
|
||||
previewIframeRefAtom,
|
||||
visualEditingSelectedComponentAtom,
|
||||
currentComponentCoordinatesAtom,
|
||||
pendingVisualChangesAtom,
|
||||
} from "@/atoms/previewAtoms";
|
||||
import { SelectedComponentsDisplay } from "./SelectedComponentDisplay";
|
||||
import { useCheckProblems } from "@/hooks/useCheckProblems";
|
||||
import { LexicalChatInput } from "./LexicalChatInput";
|
||||
import { useChatModeToggle } from "@/hooks/useChatModeToggle";
|
||||
import { VisualEditingChangesDialog } from "@/components/preview_panel/VisualEditingChangesDialog";
|
||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
||||
|
||||
const showTokenBarAtom = atom(false);
|
||||
|
||||
@@ -92,7 +98,15 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
selectedComponentsPreviewAtom,
|
||||
);
|
||||
const previewIframeRef = useAtomValue(previewIframeRefAtom);
|
||||
const setVisualEditingSelectedComponent = useSetAtom(
|
||||
visualEditingSelectedComponentAtom,
|
||||
);
|
||||
const setCurrentComponentCoordinates = useSetAtom(
|
||||
currentComponentCoordinatesAtom,
|
||||
);
|
||||
const setPendingVisualChanges = useSetAtom(pendingVisualChangesAtom);
|
||||
const { checkProblems } = useCheckProblems(appId);
|
||||
const { refreshAppIframe } = useRunApp();
|
||||
// Use the attachments hook
|
||||
const {
|
||||
attachments,
|
||||
@@ -124,6 +138,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
proposal.type === "code-proposal" &&
|
||||
messageId === lastMessage.id;
|
||||
|
||||
const { userBudget } = useUserBudgetInfo();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setShowError(true);
|
||||
@@ -160,7 +176,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
? selectedComponents
|
||||
: [];
|
||||
setSelectedComponents([]);
|
||||
|
||||
setVisualEditingSelectedComponent(null);
|
||||
// Clear overlays in the preview iframe
|
||||
if (previewIframeRef?.contentWindow) {
|
||||
previewIframeRef.contentWindow.postMessage(
|
||||
@@ -307,6 +323,58 @@ export function ChatInput({ chatId }: { chatId?: number }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{userBudget ? (
|
||||
<VisualEditingChangesDialog
|
||||
iframeRef={
|
||||
previewIframeRef
|
||||
? { current: previewIframeRef }
|
||||
: { current: null }
|
||||
}
|
||||
onReset={() => {
|
||||
// Exit component selection mode and visual editing
|
||||
setSelectedComponents([]);
|
||||
setVisualEditingSelectedComponent(null);
|
||||
setCurrentComponentCoordinates(null);
|
||||
setPendingVisualChanges(new Map());
|
||||
refreshAppIframe();
|
||||
|
||||
// Deactivate component selector in iframe
|
||||
if (previewIframeRef?.contentWindow) {
|
||||
previewIframeRef.contentWindow.postMessage(
|
||||
{ type: "deactivate-dyad-component-selector" },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
selectedComponents.length > 0 && (
|
||||
<div className="border-b border-border p-3 bg-muted/30">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
"https://dyad.sh/pro",
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
<Lock size={16} />
|
||||
<span className="font-medium">Visual editor (Pro)</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Visual editing lets you make UI changes without AI and is
|
||||
a Pro-only feature
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<SelectedComponentsDisplay />
|
||||
|
||||
{/* Use the AttachmentsList component */}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
selectedComponentsPreviewAtom,
|
||||
previewIframeRefAtom,
|
||||
visualEditingSelectedComponentAtom,
|
||||
} from "@/atoms/previewAtoms";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { Code2, X } from "lucide-react";
|
||||
|
||||
export function SelectedComponentsDisplay() {
|
||||
@@ -10,11 +11,15 @@ export function SelectedComponentsDisplay() {
|
||||
selectedComponentsPreviewAtom,
|
||||
);
|
||||
const previewIframeRef = useAtomValue(previewIframeRefAtom);
|
||||
const setVisualEditingSelectedComponent = useSetAtom(
|
||||
visualEditingSelectedComponentAtom,
|
||||
);
|
||||
|
||||
const handleRemoveComponent = (index: number) => {
|
||||
const componentToRemove = selectedComponents[index];
|
||||
const newComponents = selectedComponents.filter((_, i) => i !== index);
|
||||
setSelectedComponents(newComponents);
|
||||
setVisualEditingSelectedComponent(null);
|
||||
|
||||
// Remove the specific overlay from the iframe
|
||||
if (previewIframeRef?.contentWindow) {
|
||||
@@ -30,7 +35,7 @@ export function SelectedComponentsDisplay() {
|
||||
|
||||
const handleClearAll = () => {
|
||||
setSelectedComponents([]);
|
||||
|
||||
setVisualEditingSelectedComponent(null);
|
||||
if (previewIframeRef?.contentWindow) {
|
||||
previewIframeRef.contentWindow.postMessage(
|
||||
{ type: "clear-dyad-component-overlays" },
|
||||
|
||||
@@ -36,9 +36,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
||||
import {
|
||||
selectedComponentsPreviewAtom,
|
||||
visualEditingSelectedComponentAtom,
|
||||
currentComponentCoordinatesAtom,
|
||||
previewIframeRefAtom,
|
||||
pendingVisualChangesAtom,
|
||||
} from "@/atoms/previewAtoms";
|
||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||
import {
|
||||
@@ -57,6 +61,7 @@ import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { useShortcut } from "@/hooks/useShortcut";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizePath } from "../../../shared/normalizePath";
|
||||
import { VisualEditingToolbar } from "./VisualEditingToolbar";
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: { message: string; source: "preview-app" | "dyad-app" } | undefined;
|
||||
@@ -167,6 +172,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
const { streamMessage } = useStreamChat();
|
||||
const { routes: availableRoutes } = useParseRouter(selectedAppId);
|
||||
const { restartApp } = useRunApp();
|
||||
const { userBudget } = useUserBudgetInfo();
|
||||
const isProMode = !!userBudget;
|
||||
|
||||
// Navigation state
|
||||
const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] =
|
||||
@@ -175,12 +182,107 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
|
||||
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
|
||||
const [selectedComponentsPreview, setSelectedComponentsPreview] = useAtom(
|
||||
const setSelectedComponentsPreview = useSetAtom(
|
||||
selectedComponentsPreviewAtom,
|
||||
);
|
||||
const [visualEditingSelectedComponent, setVisualEditingSelectedComponent] =
|
||||
useAtom(visualEditingSelectedComponentAtom);
|
||||
const setCurrentComponentCoordinates = useSetAtom(
|
||||
currentComponentCoordinatesAtom,
|
||||
);
|
||||
const setPreviewIframeRef = useSetAtom(previewIframeRefAtom);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [isPicking, setIsPicking] = useState(false);
|
||||
const setPendingChanges = useSetAtom(pendingVisualChangesAtom);
|
||||
|
||||
// AST Analysis State
|
||||
const [isDynamicComponent, setIsDynamicComponent] = useState(false);
|
||||
const [hasStaticText, setHasStaticText] = useState(false);
|
||||
|
||||
const analyzeComponent = async (componentId: string) => {
|
||||
if (!componentId || !selectedAppId) return;
|
||||
|
||||
try {
|
||||
const result = await IpcClient.getInstance().analyzeComponent({
|
||||
appId: selectedAppId,
|
||||
componentId,
|
||||
});
|
||||
setIsDynamicComponent(result.isDynamic);
|
||||
setHasStaticText(result.hasStaticText);
|
||||
|
||||
// Automatically enable text editing if component has static text
|
||||
if (result.hasStaticText && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "enable-dyad-text-editing",
|
||||
data: {
|
||||
componentId: componentId,
|
||||
runtimeId: visualEditingSelectedComponent?.runtimeId,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to analyze component", err);
|
||||
setIsDynamicComponent(false);
|
||||
setHasStaticText(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextUpdated = async (data: any) => {
|
||||
const { componentId, text } = data;
|
||||
if (!componentId || !selectedAppId) return;
|
||||
|
||||
// Parse componentId to extract file path and line number
|
||||
const [filePath, lineStr] = componentId.split(":");
|
||||
const lineNumber = parseInt(lineStr, 10);
|
||||
|
||||
if (!filePath || isNaN(lineNumber)) {
|
||||
console.error("Invalid componentId format:", componentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store text change in pending changes
|
||||
setPendingChanges((prev) => {
|
||||
const updated = new Map(prev);
|
||||
const existing = updated.get(componentId);
|
||||
|
||||
updated.set(componentId, {
|
||||
componentId: componentId,
|
||||
componentName:
|
||||
existing?.componentName || visualEditingSelectedComponent?.name || "",
|
||||
relativePath: filePath,
|
||||
lineNumber: lineNumber,
|
||||
styles: existing?.styles || {},
|
||||
textContent: text,
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Function to get current styles from selected element
|
||||
const getCurrentElementStyles = () => {
|
||||
if (!iframeRef.current?.contentWindow || !visualEditingSelectedComponent)
|
||||
return;
|
||||
|
||||
try {
|
||||
// Send message to iframe to get current styles
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "get-dyad-component-styles",
|
||||
data: {
|
||||
elementId: visualEditingSelectedComponent.id,
|
||||
runtimeId: visualEditingSelectedComponent.runtimeId,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to get element styles:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Device mode state
|
||||
type DeviceMode = "desktop" | "tablet" | "mobile";
|
||||
@@ -196,23 +298,30 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
//detect if the user is using Mac
|
||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
|
||||
// Reset visual editing state when app changes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup on unmount or when app changes
|
||||
setVisualEditingSelectedComponent(null);
|
||||
setPendingChanges(new Map());
|
||||
setCurrentComponentCoordinates(null);
|
||||
};
|
||||
}, [selectedAppId]);
|
||||
|
||||
// Update iframe ref atom
|
||||
useEffect(() => {
|
||||
setPreviewIframeRef(iframeRef.current);
|
||||
}, [iframeRef.current, setPreviewIframeRef]);
|
||||
|
||||
// Deactivate component selector when selection is cleared
|
||||
// Send pro mode status to iframe
|
||||
useEffect(() => {
|
||||
if (!selectedComponentsPreview || selectedComponentsPreview.length === 0) {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: "deactivate-dyad-component-selector" },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
setIsPicking(false);
|
||||
if (iframeRef.current?.contentWindow && isComponentSelectorInitialized) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: "dyad-pro-mode", enabled: isProMode },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
}, [selectedComponentsPreview]);
|
||||
}, [isProMode, isComponentSelectorInitialized]);
|
||||
|
||||
// Add message listener for iframe errors and navigation events
|
||||
useEffect(() => {
|
||||
@@ -224,41 +333,92 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
|
||||
if (event.data?.type === "dyad-component-selector-initialized") {
|
||||
setIsComponentSelectorInitialized(true);
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{ type: "dyad-pro-mode", enabled: isProMode },
|
||||
"*",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-text-updated") {
|
||||
handleTextUpdated(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-text-finalized") {
|
||||
handleTextUpdated(event.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-component-selected") {
|
||||
console.log("Component picked:", event.data);
|
||||
|
||||
// Parse the single selected component
|
||||
const component = event.data.component
|
||||
? parseComponentSelection({
|
||||
type: "dyad-component-selected",
|
||||
id: event.data.component.id,
|
||||
name: event.data.component.name,
|
||||
})
|
||||
: null;
|
||||
const component = parseComponentSelection(event.data);
|
||||
|
||||
if (!component) return;
|
||||
|
||||
// Add to existing components, avoiding duplicates by id
|
||||
// Store the coordinates
|
||||
if (event.data.coordinates && isProMode) {
|
||||
setCurrentComponentCoordinates(event.data.coordinates);
|
||||
}
|
||||
|
||||
// Add to selected components if not already there
|
||||
setSelectedComponentsPreview((prev) => {
|
||||
// Check if this component is already selected
|
||||
if (prev.some((c) => c.id === component.id)) {
|
||||
const exists = prev.some((c) => {
|
||||
// Check by runtimeId if available otherwise by id
|
||||
// Stored components may have lost their runtimeId after re-renders or reloading the page
|
||||
if (component.runtimeId && c.runtimeId) {
|
||||
return c.runtimeId === component.runtimeId;
|
||||
}
|
||||
return c.id === component.id;
|
||||
});
|
||||
if (exists) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, component];
|
||||
});
|
||||
|
||||
if (isProMode) {
|
||||
// Set as the highlighted component for visual editing
|
||||
setVisualEditingSelectedComponent(component);
|
||||
// Trigger AST analysis
|
||||
analyzeComponent(component.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-component-deselected") {
|
||||
const componentId = event.data.componentId;
|
||||
if (componentId) {
|
||||
// Disable text editing for the deselected component
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "disable-dyad-text-editing",
|
||||
data: { componentId },
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedComponentsPreview((prev) =>
|
||||
prev.filter((c) => c.id !== componentId),
|
||||
);
|
||||
setVisualEditingSelectedComponent((prev) => {
|
||||
const shouldClear = prev?.id === componentId;
|
||||
if (shouldClear) {
|
||||
setCurrentComponentCoordinates(null);
|
||||
}
|
||||
return shouldClear ? null : prev;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-component-coordinates-updated") {
|
||||
if (event.data.coordinates) {
|
||||
setCurrentComponentCoordinates(event.data.coordinates);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -348,6 +508,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
setErrorMessage,
|
||||
setIsComponentSelectorInitialized,
|
||||
setSelectedComponentsPreview,
|
||||
setVisualEditingSelectedComponent,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -366,11 +527,26 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
}
|
||||
}, [appUrl]);
|
||||
|
||||
// Get current styles when component is selected for visual editing
|
||||
useEffect(() => {
|
||||
if (visualEditingSelectedComponent) {
|
||||
getCurrentElementStyles();
|
||||
}
|
||||
}, [visualEditingSelectedComponent]);
|
||||
|
||||
// Function to activate component selector in the iframe
|
||||
const handleActivateComponentSelector = () => {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
const newIsPicking = !isPicking;
|
||||
if (!newIsPicking) {
|
||||
// Clean up any text editing states when deactivating
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{ type: "cleanup-all-text-editing" },
|
||||
"*",
|
||||
);
|
||||
}
|
||||
setIsPicking(newIsPicking);
|
||||
setVisualEditingSelectedComponent(null);
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: newIsPicking
|
||||
@@ -433,6 +609,10 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
const handleReload = () => {
|
||||
setReloadKey((prevKey) => prevKey + 1);
|
||||
setErrorMessage(undefined);
|
||||
// Reset visual editing state
|
||||
setVisualEditingSelectedComponent(null);
|
||||
setPendingChanges(new Map());
|
||||
setCurrentComponentCoordinates(null);
|
||||
// Optionally, add logic here if you need to explicitly stop/start the app again
|
||||
// For now, just changing the key should remount the iframe
|
||||
console.debug("Reloading iframe preview for app", selectedAppId);
|
||||
@@ -737,6 +917,15 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
src={appUrl}
|
||||
allow="clipboard-read; clipboard-write; fullscreen; microphone; camera; display-capture; geolocation; autoplay; picture-in-picture"
|
||||
/>
|
||||
{/* Visual Editing Toolbar */}
|
||||
{isProMode && visualEditingSelectedComponent && selectedAppId && (
|
||||
<VisualEditingToolbar
|
||||
selectedComponent={visualEditingSelectedComponent}
|
||||
iframeRef={iframeRef}
|
||||
isDynamic={isDynamicComponent}
|
||||
hasStaticText={hasStaticText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -745,16 +934,20 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
};
|
||||
|
||||
function parseComponentSelection(data: any): ComponentSelection | null {
|
||||
if (!data || data.type !== "dyad-component-selected") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const component = data.component;
|
||||
if (
|
||||
!data ||
|
||||
data.type !== "dyad-component-selected" ||
|
||||
typeof data.id !== "string" ||
|
||||
typeof data.name !== "string"
|
||||
!component ||
|
||||
typeof component.id !== "string" ||
|
||||
typeof component.name !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { id, name } = data;
|
||||
const { id, name, runtimeId } = component;
|
||||
|
||||
// The id is expected to be in the format "filepath:line:column"
|
||||
const parts = id.split(":");
|
||||
@@ -783,6 +976,7 @@ function parseComponentSelection(data: any): ComponentSelection | null {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
runtimeId,
|
||||
relativePath: normalizePath(relativePath),
|
||||
lineNumber,
|
||||
columnNumber,
|
||||
|
||||
56
src/components/preview_panel/StylePopover.tsx
Normal file
56
src/components/preview_panel/StylePopover.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ReactNode } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface StylePopoverProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
tooltip: string;
|
||||
children: ReactNode;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
|
||||
export function StylePopover({
|
||||
icon,
|
||||
title,
|
||||
tooltip,
|
||||
children,
|
||||
side = "bottom",
|
||||
}: StylePopoverProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-[#7f22fe] dark:text-gray-200"
|
||||
aria-label={tooltip}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{icon}</TooltipTrigger>
|
||||
<TooltipContent side={side}>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side={side} className="w-64">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm" style={{ color: "#7f22fe" }}>
|
||||
{title}
|
||||
</h4>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
179
src/components/preview_panel/VisualEditingChangesDialog.tsx
Normal file
179
src/components/preview_panel/VisualEditingChangesDialog.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
531
src/components/preview_panel/VisualEditingToolbar.tsx
Normal file
531
src/components/preview_panel/VisualEditingToolbar.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Move, Square, Palette, Type } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||
import { useSetAtom, useAtomValue } from "jotai";
|
||||
import {
|
||||
pendingVisualChangesAtom,
|
||||
selectedComponentsPreviewAtom,
|
||||
currentComponentCoordinatesAtom,
|
||||
visualEditingSelectedComponentAtom,
|
||||
} from "@/atoms/previewAtoms";
|
||||
import { StylePopover } from "./StylePopover";
|
||||
import { ColorPicker } from "@/components/ui/ColorPicker";
|
||||
import { NumberInput } from "@/components/ui/NumberInput";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { rgbToHex, processNumericValue } from "@/utils/style-utils";
|
||||
|
||||
const FONT_WEIGHT_OPTIONS = [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "100", label: "Thin (100)" },
|
||||
{ value: "200", label: "Extra Light (200)" },
|
||||
{ value: "300", label: "Light (300)" },
|
||||
{ value: "400", label: "Normal (400)" },
|
||||
{ value: "500", label: "Medium (500)" },
|
||||
{ value: "600", label: "Semi Bold (600)" },
|
||||
{ value: "700", label: "Bold (700)" },
|
||||
{ value: "800", label: "Extra Bold (800)" },
|
||||
{ value: "900", label: "Black (900)" },
|
||||
] as const;
|
||||
|
||||
const FONT_FAMILY_OPTIONS = [
|
||||
{ value: "", label: "Default" },
|
||||
// Sans-serif (clean, modern)
|
||||
{ value: "Arial, sans-serif", label: "Arial" },
|
||||
{ value: "Inter, sans-serif", label: "Inter" },
|
||||
{ value: "Roboto, sans-serif", label: "Roboto" },
|
||||
// Serif (traditional, elegant)
|
||||
{ value: "Georgia, serif", label: "Georgia" },
|
||||
{ value: "'Times New Roman', Times, serif", label: "Times New Roman" },
|
||||
{ value: "Merriweather, serif", label: "Merriweather" },
|
||||
// Monospace (code, technical)
|
||||
{ value: "'Courier New', Courier, monospace", label: "Courier New" },
|
||||
{ value: "'Fira Code', monospace", label: "Fira Code" },
|
||||
{ value: "Consolas, monospace", label: "Consolas" },
|
||||
// Display/Decorative (bold, distinctive)
|
||||
{ value: "Impact, fantasy", label: "Impact" },
|
||||
{ value: "'Bebas Neue', cursive", label: "Bebas Neue" },
|
||||
// Cursive/Handwriting (casual, friendly)
|
||||
{ value: "'Comic Sans MS', cursive", label: "Comic Sans MS" },
|
||||
{ value: "'Brush Script MT', cursive", label: "Brush Script" },
|
||||
] as const;
|
||||
|
||||
interface VisualEditingToolbarProps {
|
||||
selectedComponent: ComponentSelection | null;
|
||||
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
||||
isDynamic: boolean;
|
||||
hasStaticText: boolean;
|
||||
}
|
||||
|
||||
export function VisualEditingToolbar({
|
||||
selectedComponent,
|
||||
iframeRef,
|
||||
isDynamic,
|
||||
hasStaticText,
|
||||
}: VisualEditingToolbarProps) {
|
||||
const coordinates = useAtomValue(currentComponentCoordinatesAtom);
|
||||
const [currentMargin, setCurrentMargin] = useState({ x: "", y: "" });
|
||||
const [currentPadding, setCurrentPadding] = useState({ x: "", y: "" });
|
||||
const [currentBorder, setCurrentBorder] = useState({
|
||||
width: "",
|
||||
radius: "",
|
||||
color: "#000000",
|
||||
});
|
||||
const [currentBackgroundColor, setCurrentBackgroundColor] =
|
||||
useState("#ffffff");
|
||||
const [currentTextStyles, setCurrentTextStyles] = useState({
|
||||
fontSize: "",
|
||||
fontWeight: "",
|
||||
fontFamily: "",
|
||||
color: "#000000",
|
||||
});
|
||||
const setPendingChanges = useSetAtom(pendingVisualChangesAtom);
|
||||
const setSelectedComponentsPreview = useSetAtom(
|
||||
selectedComponentsPreviewAtom,
|
||||
);
|
||||
const setVisualEditingSelectedComponent = useSetAtom(
|
||||
visualEditingSelectedComponentAtom,
|
||||
);
|
||||
|
||||
const handleDeselectComponent = () => {
|
||||
if (!selectedComponent) return;
|
||||
|
||||
setSelectedComponentsPreview((prev) =>
|
||||
prev.filter((c) => c.id !== selectedComponent.id),
|
||||
);
|
||||
setVisualEditingSelectedComponent(null);
|
||||
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "remove-dyad-component-overlay",
|
||||
componentId: selectedComponent.id,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sendStyleModification = (styles: {
|
||||
margin?: { left?: string; right?: string; top?: string; bottom?: string };
|
||||
padding?: { left?: string; right?: string; top?: string; bottom?: string };
|
||||
|
||||
border?: { width?: string; radius?: string; color?: string };
|
||||
backgroundColor?: string;
|
||||
text?: { fontSize?: string; fontWeight?: string; color?: string };
|
||||
}) => {
|
||||
if (!iframeRef.current?.contentWindow || !selectedComponent) return;
|
||||
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "modify-dyad-component-styles",
|
||||
data: {
|
||||
elementId: selectedComponent.id,
|
||||
runtimeId: selectedComponent.runtimeId,
|
||||
styles,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "update-dyad-overlay-positions",
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
setPendingChanges((prev) => {
|
||||
const updated = new Map(prev);
|
||||
const existing = updated.get(selectedComponent.id);
|
||||
const newStyles: any = { ...existing?.styles };
|
||||
|
||||
if (styles.margin) {
|
||||
newStyles.margin = { ...existing?.styles?.margin, ...styles.margin };
|
||||
}
|
||||
if (styles.padding) {
|
||||
newStyles.padding = { ...existing?.styles?.padding, ...styles.padding };
|
||||
}
|
||||
|
||||
if (styles.border) {
|
||||
newStyles.border = { ...existing?.styles?.border, ...styles.border };
|
||||
}
|
||||
if (styles.backgroundColor) {
|
||||
newStyles.backgroundColor = styles.backgroundColor;
|
||||
}
|
||||
if (styles.text) {
|
||||
newStyles.text = { ...existing?.styles?.text, ...styles.text };
|
||||
}
|
||||
|
||||
updated.set(selectedComponent.id, {
|
||||
componentId: selectedComponent.id,
|
||||
componentName: selectedComponent.name,
|
||||
relativePath: selectedComponent.relativePath,
|
||||
lineNumber: selectedComponent.lineNumber,
|
||||
styles: newStyles,
|
||||
textContent: existing?.textContent || "",
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentElementStyles = () => {
|
||||
if (!iframeRef.current?.contentWindow || !selectedComponent) return;
|
||||
|
||||
try {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "get-dyad-component-styles",
|
||||
data: {
|
||||
elementId: selectedComponent.id,
|
||||
runtimeId: selectedComponent.runtimeId,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to get element styles:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedComponent) {
|
||||
getCurrentElementStyles();
|
||||
}
|
||||
}, [selectedComponent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (coordinates && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "update-component-coordinates",
|
||||
coordinates,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
}, [coordinates, iframeRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === "dyad-component-styles") {
|
||||
const { margin, padding, border, backgroundColor, text } =
|
||||
event.data.data;
|
||||
|
||||
const marginX = margin?.left === margin?.right ? margin.left : "";
|
||||
const marginY = margin?.top === margin?.bottom ? margin.top : "";
|
||||
const paddingX = padding?.left === padding?.right ? padding.left : "";
|
||||
const paddingY = padding?.top === padding?.bottom ? padding.top : "";
|
||||
|
||||
setCurrentMargin({ x: marginX, y: marginY });
|
||||
setCurrentPadding({ x: paddingX, y: paddingY });
|
||||
setCurrentBorder({
|
||||
width: border?.width || "",
|
||||
radius: border?.radius || "",
|
||||
color: rgbToHex(border?.color),
|
||||
});
|
||||
setCurrentBackgroundColor(rgbToHex(backgroundColor) || "#ffffff");
|
||||
setCurrentTextStyles({
|
||||
fontSize: text?.fontSize || "",
|
||||
fontWeight: text?.fontWeight || "",
|
||||
fontFamily: text?.fontFamily || "",
|
||||
color: rgbToHex(text?.color) || "#000000",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, []);
|
||||
|
||||
const handleSpacingChange = (
|
||||
type: "margin" | "padding",
|
||||
axis: "x" | "y",
|
||||
value: string,
|
||||
) => {
|
||||
const setter = type === "margin" ? setCurrentMargin : setCurrentPadding;
|
||||
setter((prev) => ({ ...prev, [axis]: value }));
|
||||
|
||||
if (value) {
|
||||
const processedValue = processNumericValue(value);
|
||||
const data =
|
||||
axis === "x"
|
||||
? { left: processedValue, right: processedValue }
|
||||
: { top: processedValue, bottom: processedValue };
|
||||
|
||||
sendStyleModification({ [type]: data });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBorderChange = (
|
||||
property: "width" | "radius" | "color",
|
||||
value: string,
|
||||
) => {
|
||||
const newBorder = { ...currentBorder, [property]: value };
|
||||
setCurrentBorder(newBorder);
|
||||
|
||||
if (value) {
|
||||
let processedValue = value;
|
||||
if (property !== "color" && /^\d+$/.test(value)) {
|
||||
processedValue = `${value}px`;
|
||||
}
|
||||
|
||||
if (property === "width" || property === "color") {
|
||||
sendStyleModification({
|
||||
border: {
|
||||
width:
|
||||
property === "width"
|
||||
? processedValue
|
||||
: currentBorder.width || "0px",
|
||||
color: property === "color" ? processedValue : currentBorder.color,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendStyleModification({ border: { [property]: processedValue } });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextStyleChange = (
|
||||
property: "fontSize" | "fontWeight" | "fontFamily" | "color",
|
||||
value: string,
|
||||
) => {
|
||||
setCurrentTextStyles((prev) => ({ ...prev, [property]: value }));
|
||||
|
||||
if (value) {
|
||||
let processedValue = value;
|
||||
if (property === "fontSize" && /^\d+$/.test(value)) {
|
||||
processedValue = `${value}px`;
|
||||
}
|
||||
|
||||
sendStyleModification({ text: { [property]: processedValue } });
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectedComponent || !coordinates) return null;
|
||||
|
||||
const toolbarTop = coordinates.top + coordinates.height + 4;
|
||||
const toolbarLeft = coordinates.left;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-[var(--background)] border border-[var(--border)] rounded-md shadow-lg z-50 flex flex-row items-center p-2 gap-1"
|
||||
style={{
|
||||
top: `${toolbarTop}px`,
|
||||
left: `${toolbarLeft}px`,
|
||||
}}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleDeselectComponent}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-[#7f22fe] dark:text-gray-200"
|
||||
aria-label="Deselect Component"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Deselect Component</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{isDynamic ? (
|
||||
<div className="flex items-center px-2 py-1 text-yellow-800 dark:text-yellow-200 rounded text-xs font-medium">
|
||||
<span>This component is styled dynamically</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StylePopover
|
||||
icon={<Move size={16} />}
|
||||
title="Margin"
|
||||
tooltip="Margin"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<NumberInput
|
||||
id="margin-x"
|
||||
label="Horizontal"
|
||||
value={currentMargin.x}
|
||||
onChange={(v) => handleSpacingChange("margin", "x", v)}
|
||||
placeholder="10"
|
||||
/>
|
||||
<NumberInput
|
||||
id="margin-y"
|
||||
label="Vertical"
|
||||
value={currentMargin.y}
|
||||
onChange={(v) => handleSpacingChange("margin", "y", v)}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
</StylePopover>
|
||||
|
||||
<StylePopover
|
||||
icon={
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||
</svg>
|
||||
}
|
||||
title="Padding"
|
||||
tooltip="Padding"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<NumberInput
|
||||
id="padding-x"
|
||||
label="Horizontal"
|
||||
value={currentPadding.x}
|
||||
onChange={(v) => handleSpacingChange("padding", "x", v)}
|
||||
placeholder="10"
|
||||
/>
|
||||
<NumberInput
|
||||
id="padding-y"
|
||||
label="Vertical"
|
||||
value={currentPadding.y}
|
||||
onChange={(v) => handleSpacingChange("padding", "y", v)}
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
</StylePopover>
|
||||
|
||||
<StylePopover
|
||||
icon={<Square size={16} />}
|
||||
title="Border"
|
||||
tooltip="Border"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<NumberInput
|
||||
id="border-width"
|
||||
label="Width"
|
||||
value={currentBorder.width}
|
||||
onChange={(v) => handleBorderChange("width", v)}
|
||||
placeholder="1"
|
||||
/>
|
||||
<NumberInput
|
||||
id="border-radius"
|
||||
label="Radius"
|
||||
value={currentBorder.radius}
|
||||
onChange={(v) => handleBorderChange("radius", v)}
|
||||
placeholder="4"
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="border-color" className="text-xs">
|
||||
Color
|
||||
</Label>
|
||||
<ColorPicker
|
||||
id="border-color"
|
||||
value={currentBorder.color}
|
||||
onChange={(v) => handleBorderChange("color", v)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StylePopover>
|
||||
|
||||
<StylePopover
|
||||
icon={<Palette size={16} />}
|
||||
title="Background Color"
|
||||
tooltip="Background"
|
||||
>
|
||||
<div>
|
||||
<Label htmlFor="bg-color" className="text-xs">
|
||||
Color
|
||||
</Label>
|
||||
<ColorPicker
|
||||
id="bg-color"
|
||||
value={currentBackgroundColor}
|
||||
onChange={(v) => {
|
||||
setCurrentBackgroundColor(v);
|
||||
if (v) sendStyleModification({ backgroundColor: v });
|
||||
}}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</StylePopover>
|
||||
|
||||
{hasStaticText && (
|
||||
<StylePopover
|
||||
icon={<Type size={16} />}
|
||||
title="Text Style"
|
||||
tooltip="Text Style"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<NumberInput
|
||||
id="font-size"
|
||||
label="Font Size"
|
||||
value={currentTextStyles.fontSize}
|
||||
onChange={(v) => handleTextStyleChange("fontSize", v)}
|
||||
placeholder="16"
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="font-weight" className="text-xs">
|
||||
Font Weight
|
||||
</Label>
|
||||
<select
|
||||
id="font-weight"
|
||||
className="mt-1 h-8 text-xs w-full rounded-md border border-input bg-background px-3 py-2"
|
||||
value={currentTextStyles.fontWeight}
|
||||
onChange={(e) =>
|
||||
handleTextStyleChange("fontWeight", e.target.value)
|
||||
}
|
||||
>
|
||||
{FONT_WEIGHT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="font-family" className="text-xs">
|
||||
Font Family
|
||||
</Label>
|
||||
<select
|
||||
id="font-family"
|
||||
className="mt-1 h-8 text-xs w-full rounded-md border border-input bg-background px-3 py-2"
|
||||
value={currentTextStyles.fontFamily}
|
||||
onChange={(e) =>
|
||||
handleTextStyleChange("fontFamily", e.target.value)
|
||||
}
|
||||
>
|
||||
{FONT_FAMILY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="text-color" className="text-xs">
|
||||
Text Color
|
||||
</Label>
|
||||
<ColorPicker
|
||||
id="text-color"
|
||||
value={currentTextStyles.color}
|
||||
onChange={(v) => handleTextStyleChange("color", v)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StylePopover>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/components/ui/ColorPicker.tsx
Normal file
35
src/components/ui/ColorPicker.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ColorPickerProps {
|
||||
id: string;
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColorPicker({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
className = "",
|
||||
}: ColorPickerProps) {
|
||||
return (
|
||||
<div className={`flex gap-2 ${className}`}>
|
||||
<Input
|
||||
id={id}
|
||||
type="color"
|
||||
className="h-8 w-12 p-1 cursor-pointer"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="#000000"
|
||||
className="h-8 text-xs flex-1"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
src/components/ui/NumberInput.tsx
Normal file
42
src/components/ui/NumberInput.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface NumberInputProps {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
step?: string;
|
||||
min?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NumberInput({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "0",
|
||||
step = "1",
|
||||
min = "0",
|
||||
className = "",
|
||||
}: NumberInputProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Label htmlFor={id} className="text-xs">
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
placeholder={placeholder}
|
||||
className="mt-1 h-8 text-xs"
|
||||
value={value.replace(/[^\d.-]/g, "") || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
step={step}
|
||||
min={min}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user