Annotator (#1861)
<!-- This is an auto-generated description by cubic. --> ## Summary by cubic Adds an in-app screenshot annotator to the Preview panel for Pro users so you can capture the current app view, draw or add text, and submit an annotated image to chat. - **New Features** - Pen button in PreviewIframe to toggle annotator; captures a screenshot via worker messaging and displays it in a Konva canvas. - Tools: select, freehand draw, and draggable text; supports undo/redo, delete, and resizing with Transformer. Canvas scales to the container. Includes a color picker. - Submit exports a PNG and attaches it to the chat via useAttachments; prefills the chat input; annotator auto-closes after submit. - Pro-only: non-Pro users see an upsell screen. - State atoms added: annotatorModeAtom, screenshotDataUrlAtom, attachmentsAtom; PreviewIframe now handles dyad-screenshot-response messages. - **Dependencies** - Added konva, react-konva, perfect-freehand, and html-to-image. - Proxy now injects html-to-image and the new dyad-screenshot-client.js for screenshot capture. <sup>Written for commit 580aca271c5993a0dc7426e36e34393e073bd67b. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
committed by
GitHub
parent
86e4005795
commit
a4ab1a7f84
53
src/components/preview_panel/AnnotatorOnlyForPro.tsx
Normal file
53
src/components/preview_panel/AnnotatorOnlyForPro.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Lock, ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
interface AnnotatorOnlyForProProps {
|
||||
onGoBack: () => void;
|
||||
}
|
||||
|
||||
export const AnnotatorOnlyForPro = ({ onGoBack }: AnnotatorOnlyForProProps) => {
|
||||
const handleGetPro = () => {
|
||||
IpcClient.getInstance().openExternalUrl("https://dyad.sh/pro");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-background relative">
|
||||
{/* Go Back Button */}
|
||||
<button
|
||||
onClick={onGoBack}
|
||||
className="absolute top-4 left-4 p-2 hover:bg-accent rounded-md transition-all z-10 group"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft
|
||||
size={20}
|
||||
className="text-foreground/70 group-hover:text-foreground transition-colors"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Centered Content */}
|
||||
<div className="flex flex-col items-center justify-center h-full px-8">
|
||||
{/* Lock Icon */}
|
||||
<Lock size={72} className="text-primary/60 dark:text-primary/70 mb-8" />
|
||||
|
||||
{/* Message */}
|
||||
<h2 className="text-3xl font-semibold text-foreground mb-4 text-center">
|
||||
Annotator is a Pro Feature
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-10 text-center max-w-md text-base leading-relaxed">
|
||||
Unlock the ability to annotate screenshots and enhance your workflow
|
||||
with Dyad Pro.
|
||||
</p>
|
||||
|
||||
{/* Get Pro Button */}
|
||||
<Button
|
||||
onClick={handleGetPro}
|
||||
size="lg"
|
||||
className="px-8 shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
Get Dyad Pro
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
214
src/components/preview_panel/AnnotatorToolbar.tsx
Normal file
214
src/components/preview_panel/AnnotatorToolbar.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
MousePointer2,
|
||||
Pencil,
|
||||
Type,
|
||||
Trash2,
|
||||
Undo,
|
||||
Redo,
|
||||
Check,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ToolbarColorPicker } from "./ToolbarColorPicker";
|
||||
|
||||
interface AnnotatorToolbarProps {
|
||||
tool: "select" | "draw" | "text";
|
||||
color: string;
|
||||
selectedId: string | null;
|
||||
historyStep: number;
|
||||
historyLength: number;
|
||||
onToolChange: (tool: "select" | "draw" | "text") => void;
|
||||
onColorChange: (color: string) => void;
|
||||
onDelete: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onSubmit: () => void;
|
||||
onDeactivate: () => void;
|
||||
hasSubmitHandler: boolean;
|
||||
}
|
||||
|
||||
export const AnnotatorToolbar = ({
|
||||
tool,
|
||||
color,
|
||||
selectedId,
|
||||
historyStep,
|
||||
historyLength,
|
||||
onToolChange,
|
||||
onColorChange,
|
||||
onDelete,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onSubmit,
|
||||
onDeactivate,
|
||||
hasSubmitHandler,
|
||||
}: AnnotatorToolbarProps) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-2 border-b space-x-2">
|
||||
<TooltipProvider>
|
||||
{/* Tool Selection Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onToolChange("select")}
|
||||
aria-label="Select"
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors duration-200",
|
||||
tool === "select"
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
|
||||
)}
|
||||
>
|
||||
<MousePointer2 size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Select</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onToolChange("draw")}
|
||||
aria-label="Draw"
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors duration-200",
|
||||
tool === "draw"
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
|
||||
)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Draw</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => onToolChange("text")}
|
||||
aria-label="Text"
|
||||
className={cn(
|
||||
"p-1 rounded transition-colors duration-200",
|
||||
tool === "text"
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: "text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
|
||||
)}
|
||||
>
|
||||
<Type size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Text</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="p-1 rounded transition-colors duration-200 hover:bg-purple-200 dark:hover:bg-purple-900">
|
||||
<ToolbarColorPicker color={color} onChange={onColorChange} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Color</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
aria-label="Delete"
|
||||
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!selectedId}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete Selected</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onUndo}
|
||||
aria-label="Undo"
|
||||
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={historyStep === 0}
|
||||
>
|
||||
<Undo size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Undo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onRedo}
|
||||
aria-label="Redo"
|
||||
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={historyStep === historyLength - 1}
|
||||
>
|
||||
<Redo size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Redo</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
aria-label="Add to Chat"
|
||||
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!hasSubmitHandler}
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Add to Chat</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onDeactivate}
|
||||
aria-label="Close Annotator"
|
||||
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Close Annotator</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
156
src/components/preview_panel/DraggableTextInput.tsx
Normal file
156
src/components/preview_panel/DraggableTextInput.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface DraggableTextInputProps {
|
||||
input: {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
adjustedX: number;
|
||||
adjustedY: number;
|
||||
value: string;
|
||||
};
|
||||
index: number;
|
||||
totalInputs: number;
|
||||
scale: number;
|
||||
onMove: (
|
||||
id: string,
|
||||
x: number,
|
||||
y: number,
|
||||
adjustedX: number,
|
||||
adjustedY: number,
|
||||
) => void;
|
||||
onChange: (id: string, value: string) => void;
|
||||
onKeyDown: (id: string, e: React.KeyboardEvent, index: number) => void;
|
||||
onRemove: (id: string) => void;
|
||||
spanRef: React.MutableRefObject<HTMLSpanElement[]>;
|
||||
inputRef: React.MutableRefObject<HTMLInputElement[]>;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const DraggableTextInput = ({
|
||||
input,
|
||||
index,
|
||||
totalInputs,
|
||||
scale,
|
||||
onMove,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onRemove,
|
||||
spanRef,
|
||||
inputRef,
|
||||
color,
|
||||
}: DraggableTextInputProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const newX = e.clientX - dragOffset.current.x;
|
||||
const newY = e.clientY - dragOffset.current.y;
|
||||
// Calculate adjusted coordinates for the canvas
|
||||
const adjustedX = newX / scale;
|
||||
const adjustedY = newY / scale;
|
||||
onMove(input.id, newX, newY, adjustedX, adjustedY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, input.id, onMove, scale]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[999]"
|
||||
style={{
|
||||
left: `${input.x}px`,
|
||||
top: `${input.y}px`,
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 cursor-move p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors z-10"
|
||||
onMouseDown={(e) => {
|
||||
setIsDragging(true);
|
||||
dragOffset.current = {
|
||||
x: e.clientX - input.x,
|
||||
y: e.clientY - input.y,
|
||||
};
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
title="Drag to move"
|
||||
>
|
||||
{/* Grip dots icon - smaller and more subtle */}
|
||||
<svg
|
||||
width="8"
|
||||
height="12"
|
||||
viewBox="0 0 8 12"
|
||||
fill="currentColor"
|
||||
className="text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
<circle cx="2" cy="2" r="1" />
|
||||
<circle cx="6" cy="2" r="1" />
|
||||
<circle cx="2" cy="6" r="1" />
|
||||
<circle cx="6" cy="6" r="1" />
|
||||
<circle cx="2" cy="10" r="1" />
|
||||
<circle cx="6" cy="10" r="1" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span
|
||||
ref={(e) => {
|
||||
if (e) spanRef.current[index] = e;
|
||||
}}
|
||||
className="
|
||||
absolute
|
||||
invisible
|
||||
whitespace-pre
|
||||
text-base
|
||||
font-normal
|
||||
"
|
||||
></span>
|
||||
<input
|
||||
autoFocus={index === totalInputs - 1}
|
||||
type="text"
|
||||
value={input.value}
|
||||
onChange={(e) => onChange(input.id, e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown(input.id, e, index)}
|
||||
className="pl-8 pr-8 py-2 bg-[var(--background)] border-2 rounded-md shadow-lg text-gray-900 dark:text-gray-100 focus:outline-none min-w-[200px] cursor-text"
|
||||
style={{ borderColor: color }}
|
||||
placeholder="Type text..."
|
||||
ref={(e) => {
|
||||
if (e) inputRef.current[index] = e;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Close Button - Rightmost */}
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors z-10 group"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemove(input.id);
|
||||
}}
|
||||
title="Remove text input"
|
||||
type="button"
|
||||
>
|
||||
<X className="w-3 h-3 text-gray-400 dark:text-gray-500 group-hover:text-red-600 dark:group-hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Monitor,
|
||||
Tablet,
|
||||
Smartphone,
|
||||
Pen,
|
||||
} from "lucide-react";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
|
||||
@@ -36,12 +37,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
||||
import {
|
||||
selectedComponentsPreviewAtom,
|
||||
visualEditingSelectedComponentAtom,
|
||||
currentComponentCoordinatesAtom,
|
||||
previewIframeRefAtom,
|
||||
annotatorModeAtom,
|
||||
screenshotDataUrlAtom,
|
||||
pendingVisualChangesAtom,
|
||||
} from "@/atoms/previewAtoms";
|
||||
import { ComponentSelection } from "@/ipc/ipc_types";
|
||||
@@ -61,6 +63,11 @@ import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { useShortcut } from "@/hooks/useShortcut";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { normalizePath } from "../../../shared/normalizePath";
|
||||
import { showError } from "@/lib/toast";
|
||||
import { AnnotatorOnlyForPro } from "./AnnotatorOnlyForPro";
|
||||
import { useAttachments } from "@/hooks/useAttachments";
|
||||
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
|
||||
import { Annotator } from "@/pro/ui/components/Annotator/Annotator";
|
||||
import { VisualEditingToolbar } from "./VisualEditingToolbar";
|
||||
|
||||
interface ErrorBannerProps {
|
||||
@@ -193,12 +200,32 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
const setPreviewIframeRef = useSetAtom(previewIframeRefAtom);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [isPicking, setIsPicking] = useState(false);
|
||||
const [annotatorMode, setAnnotatorMode] = useAtom(annotatorModeAtom);
|
||||
const [screenshotDataUrl, setScreenshotDataUrl] = useAtom(
|
||||
screenshotDataUrlAtom,
|
||||
);
|
||||
|
||||
const { addAttachments } = useAttachments();
|
||||
const setPendingChanges = useSetAtom(pendingVisualChangesAtom);
|
||||
|
||||
// AST Analysis State
|
||||
const [isDynamicComponent, setIsDynamicComponent] = useState(false);
|
||||
const [hasStaticText, setHasStaticText] = useState(false);
|
||||
|
||||
// Device mode state
|
||||
type DeviceMode = "desktop" | "tablet" | "mobile";
|
||||
const [deviceMode, setDeviceMode] = useState<DeviceMode>("desktop");
|
||||
const [isDevicePopoverOpen, setIsDevicePopoverOpen] = useState(false);
|
||||
|
||||
// Device configurations
|
||||
const deviceWidthConfig = {
|
||||
tablet: 768,
|
||||
mobile: 375,
|
||||
};
|
||||
|
||||
//detect if the user is using Mac
|
||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
|
||||
const analyzeComponent = async (componentId: string) => {
|
||||
if (!componentId || !selectedAppId) return;
|
||||
|
||||
@@ -283,21 +310,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
console.error("Failed to get element styles:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Device mode state
|
||||
type DeviceMode = "desktop" | "tablet" | "mobile";
|
||||
const [deviceMode, setDeviceMode] = useState<DeviceMode>("desktop");
|
||||
const [isDevicePopoverOpen, setIsDevicePopoverOpen] = useState(false);
|
||||
|
||||
// Device configurations
|
||||
const deviceWidthConfig = {
|
||||
tablet: 768,
|
||||
mobile: 375,
|
||||
};
|
||||
|
||||
//detect if the user is using Mac
|
||||
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
|
||||
useEffect(() => {
|
||||
setAnnotatorMode(false);
|
||||
}, []);
|
||||
// Reset visual editing state when app changes or component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -423,6 +438,16 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data?.type === "dyad-screenshot-response") {
|
||||
if (event.data.success && event.data.dataUrl) {
|
||||
setScreenshotDataUrl(event.data.dataUrl);
|
||||
setAnnotatorMode(true);
|
||||
} else {
|
||||
showError(event.data.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, payload } = event.data as {
|
||||
type:
|
||||
| "window-error"
|
||||
@@ -558,6 +583,22 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle annotator button click
|
||||
const handleAnnotatorClick = () => {
|
||||
if (annotatorMode) {
|
||||
setAnnotatorMode(false);
|
||||
return;
|
||||
}
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "dyad-take-screenshot",
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Activate component selector using a shortcut
|
||||
useShortcut(
|
||||
"c",
|
||||
@@ -675,203 +716,239 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Browser-style header */}
|
||||
<div className="flex items-center p-2 border-b space-x-2 ">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleActivateComponentSelector}
|
||||
className={`p-1 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isPicking
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
}`}
|
||||
disabled={
|
||||
loading || !selectedAppId || !isComponentSelectorInitialized
|
||||
}
|
||||
data-testid="preview-pick-element-button"
|
||||
>
|
||||
<MousePointerClick size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isPicking
|
||||
? "Deactivate component selector"
|
||||
: "Select component"}
|
||||
</p>
|
||||
<p>{isMac ? "⌘ + ⇧ + C" : "Ctrl + ⇧ + C"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoBack || loading || !selectedAppId}
|
||||
onClick={handleNavigateBack}
|
||||
data-testid="preview-navigate-back-button"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoForward || loading || !selectedAppId}
|
||||
onClick={handleNavigateForward}
|
||||
data-testid="preview-navigate-forward-button"
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReload}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={loading || !selectedAppId}
|
||||
data-testid="preview-refresh-button"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
|
||||
<div className="relative flex-grow min-w-20">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm text-gray-700 dark:text-gray-200 cursor-pointer w-full min-w-0">
|
||||
<span className="truncate flex-1 mr-2 min-w-0">
|
||||
{navigationHistory[currentHistoryPosition]
|
||||
? new URL(navigationHistory[currentHistoryPosition])
|
||||
.pathname
|
||||
: "/"}
|
||||
</span>
|
||||
<ChevronDown size={14} className="flex-shrink-0" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{availableRoutes.length > 0 ? (
|
||||
availableRoutes.map((route) => (
|
||||
<DropdownMenuItem
|
||||
key={route.path}
|
||||
onClick={() => navigateToRoute(route.path)}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span>{route.label}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{route.path}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>Loading routes...</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="flex items-center space-x-1 px-3 py-1 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
|
||||
title="Restart App"
|
||||
>
|
||||
<Power size={16} />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
<button
|
||||
data-testid="preview-open-browser-button"
|
||||
onClick={() => {
|
||||
if (originalUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(originalUrl);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
|
||||
{/* Device Mode Button */}
|
||||
<Popover open={isDevicePopoverOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
data-testid="device-mode-button"
|
||||
onClick={() => {
|
||||
// Toggle popover open/close
|
||||
if (isDevicePopoverOpen) setDeviceMode("desktop");
|
||||
setIsDevicePopoverOpen(!isDevicePopoverOpen);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 dark:text-gray-300",
|
||||
deviceMode !== "desktop" && "bg-gray-200 dark:bg-gray-700",
|
||||
)}
|
||||
title="Device Mode"
|
||||
>
|
||||
<MonitorSmartphone size={16} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-2"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={deviceMode}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDeviceMode(value as DeviceMode);
|
||||
setIsDevicePopoverOpen(false);
|
||||
{/* Browser-style header - hide when annotator is active */}
|
||||
{!annotatorMode && (
|
||||
<div className="flex items-center p-2 border-b space-x-2">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleActivateComponentSelector}
|
||||
className={`p-1 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isPicking
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
}`}
|
||||
disabled={
|
||||
loading ||
|
||||
!selectedAppId ||
|
||||
!isComponentSelectorInitialized
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{/* Tooltips placed inside items instead of wrapping
|
||||
to avoid asChild prop merging that breaks highlighting */}
|
||||
<ToggleGroupItem value="desktop" aria-label="Desktop view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Monitor size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Desktop</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tablet" aria-label="Tablet view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Tablet size={16} className="scale-x-130" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Tablet</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="mobile" aria-label="Mobile view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Smartphone size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Mobile</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</TooltipProvider>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
data-testid="preview-pick-element-button"
|
||||
>
|
||||
<MousePointerClick size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isPicking
|
||||
? "Deactivate component selector"
|
||||
: "Select component"}
|
||||
</p>
|
||||
<p>{isMac ? "⌘ + ⇧ + C" : "Ctrl + ⇧ + C"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleAnnotatorClick}
|
||||
className={`p-1 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
annotatorMode
|
||||
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
|
||||
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
|
||||
}`}
|
||||
disabled={
|
||||
loading ||
|
||||
!selectedAppId ||
|
||||
isPicking ||
|
||||
!isComponentSelectorInitialized
|
||||
}
|
||||
data-testid="preview-annotator-button"
|
||||
>
|
||||
<Pen size={16} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{annotatorMode
|
||||
? "Annotator mode active"
|
||||
: "Activate annotator"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoBack || loading || !selectedAppId}
|
||||
onClick={handleNavigateBack}
|
||||
data-testid="preview-navigate-back-button"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoForward || loading || !selectedAppId}
|
||||
onClick={handleNavigateForward}
|
||||
data-testid="preview-navigate-forward-button"
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReload}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={loading || !selectedAppId}
|
||||
data-testid="preview-refresh-button"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-grow ">
|
||||
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
|
||||
<div className="relative flex-grow min-w-20">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm text-gray-700 dark:text-gray-200 cursor-pointer w-full min-w-0">
|
||||
<span className="truncate flex-1 mr-2 min-w-0">
|
||||
{navigationHistory[currentHistoryPosition]
|
||||
? new URL(navigationHistory[currentHistoryPosition])
|
||||
.pathname
|
||||
: "/"}
|
||||
</span>
|
||||
<ChevronDown size={14} className="flex-shrink-0" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{availableRoutes.length > 0 ? (
|
||||
availableRoutes.map((route) => (
|
||||
<DropdownMenuItem
|
||||
key={route.path}
|
||||
onClick={() => navigateToRoute(route.path)}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span>{route.label}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{route.path}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>
|
||||
Loading routes...
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="flex items-center space-x-1 px-3 py-1 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
|
||||
title="Restart App"
|
||||
>
|
||||
<Power size={16} />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
<button
|
||||
data-testid="preview-open-browser-button"
|
||||
onClick={() => {
|
||||
if (originalUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(originalUrl);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
|
||||
{/* Device Mode Button */}
|
||||
<Popover open={isDevicePopoverOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
data-testid="device-mode-button"
|
||||
onClick={() => {
|
||||
// Toggle popover open/close
|
||||
if (isDevicePopoverOpen) setDeviceMode("desktop");
|
||||
setIsDevicePopoverOpen(!isDevicePopoverOpen);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 dark:text-gray-300",
|
||||
deviceMode !== "desktop" && "bg-gray-200 dark:bg-gray-700",
|
||||
)}
|
||||
title="Device Mode"
|
||||
>
|
||||
<MonitorSmartphone size={16} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto p-2"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={deviceMode}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDeviceMode(value as DeviceMode);
|
||||
setIsDevicePopoverOpen(false);
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{/* Tooltips placed inside items instead of wrapping
|
||||
to avoid asChild prop merging that breaks highlighting */}
|
||||
<ToggleGroupItem value="desktop" aria-label="Desktop view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Monitor size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Desktop</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tablet" aria-label="Tablet view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Tablet size={16} className="scale-x-130" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Tablet</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="mobile" aria-label="Mobile view">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center justify-center">
|
||||
<Smartphone size={16} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Mobile</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</TooltipProvider>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative flex-grow overflow-hidden">
|
||||
<ErrorBanner
|
||||
error={errorMessage}
|
||||
onDismiss={() => setErrorMessage(undefined)}
|
||||
@@ -899,32 +976,59 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
|
||||
deviceMode !== "desktop" && "flex justify-center",
|
||||
)}
|
||||
>
|
||||
<iframe
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-downloads"
|
||||
data-testid="preview-iframe-element"
|
||||
onLoad={() => {
|
||||
setErrorMessage(undefined);
|
||||
}}
|
||||
ref={iframeRef}
|
||||
key={reloadKey}
|
||||
title={`Preview for App ${selectedAppId}`}
|
||||
className="w-full h-full border-none bg-white dark:bg-gray-950"
|
||||
style={
|
||||
deviceMode == "desktop"
|
||||
? {}
|
||||
: { width: `${deviceWidthConfig[deviceMode]}px` }
|
||||
}
|
||||
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}
|
||||
/>
|
||||
{annotatorMode && screenshotDataUrl ? (
|
||||
<div
|
||||
className="w-full h-full bg-white dark:bg-gray-950"
|
||||
style={
|
||||
deviceMode == "desktop"
|
||||
? {}
|
||||
: { width: `${deviceWidthConfig[deviceMode]}px` }
|
||||
}
|
||||
>
|
||||
{userBudget ? (
|
||||
<Annotator
|
||||
screenshotUrl={screenshotDataUrl}
|
||||
onSubmit={addAttachments}
|
||||
handleAnnotatorClick={handleAnnotatorClick}
|
||||
/>
|
||||
) : (
|
||||
<AnnotatorOnlyForPro
|
||||
onGoBack={() => setAnnotatorMode(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<iframe
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-downloads"
|
||||
data-testid="preview-iframe-element"
|
||||
onLoad={() => {
|
||||
setErrorMessage(undefined);
|
||||
}}
|
||||
ref={iframeRef}
|
||||
key={reloadKey}
|
||||
title={`Preview for App ${selectedAppId}`}
|
||||
className="w-full h-full border-none bg-white dark:bg-gray-950"
|
||||
style={
|
||||
deviceMode == "desktop"
|
||||
? {}
|
||||
: { width: `${deviceWidthConfig[deviceMode]}px` }
|
||||
}
|
||||
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>
|
||||
)}
|
||||
|
||||
25
src/components/preview_panel/ToolbarColorPicker.tsx
Normal file
25
src/components/preview_panel/ToolbarColorPicker.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
interface ToolbarColorPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export const ToolbarColorPicker = ({
|
||||
color,
|
||||
onChange,
|
||||
}: ToolbarColorPickerProps) => {
|
||||
return (
|
||||
<label
|
||||
className="h-[16px] w-[16px] rounded-sm cursor-pointer transition-all overflow-hidden block self-center"
|
||||
style={{ backgroundColor: color }}
|
||||
title="Choose color"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="opacity-0 w-full h-full"
|
||||
aria-label="Choose color"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user