Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 138 additions & 37 deletions apps/web/client/src/app/project/[id]/_components/canvas/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@

import { useEditorEngine } from '@/components/store/editor';
import { EditorAttributes } from '@onlook/constants';
import { EditorMode } from '@onlook/models';
import { EditorMode, LeftPanelTabValue } from '@onlook/models';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from '@onlook/ui/context-menu';
import { Icons } from '@onlook/ui/icons';
import { throttle } from 'lodash';
import { observer } from 'mobx-react-lite';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
Expand All @@ -23,7 +34,11 @@ const MAX_Y = 10000;
const MIN_X = -5000;
const MIN_Y = -5000;

export const Canvas = observer(() => {
interface CanvasProps {
onSidebarClick?: (tab: LeftPanelTabValue) => void;
}

export const Canvas = observer(({ onSidebarClick }: CanvasProps) => {
const editorEngine = useEditorEngine();
const containerRef = useRef<HTMLDivElement>(null);
const scale = editorEngine.canvas.scale;
Expand Down Expand Up @@ -272,41 +287,127 @@ export const Canvas = observer(() => {

return (
<HotkeysArea>
<div
ref={containerRef}
className="overflow-hidden bg-background-onlook flex flex-grow relative"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
onMouseLeave={(e) => {
// Only terminate drag if no mouse button is pressed
// Note: The global mouseup listener will handle the actual cleanup
// This is just an additional safety check for when mouse leaves without buttons pressed
if (e.buttons === 0 && isDragSelecting) {
setIsDragSelecting(false);
setFramesInSelection(new Set());
editorEngine.state.isDragSelecting = false;
}
}}
>
<div id={EditorAttributes.CANVAS_CONTAINER_ID} style={transformStyle}>
<Frames framesInDragSelection={framesInSelection} />
</div>
<RecenterCanvasButton />
<DragSelectOverlay
startX={dragSelectStart.x}
startY={dragSelectStart.y}
endX={dragSelectEnd.x}
endY={dragSelectEnd.y}
isSelecting={isDragSelecting}
/>
<Overlay />
<PanOverlay
clampPosition={(position: { x: number; y: number }) =>
clampPosition(position, scale)
}
/>
</div>
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={containerRef}
className="overflow-hidden bg-background-onlook flex flex-grow relative"
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
onMouseLeave={(e) => {
// Only terminate drag if no mouse button is pressed
// Note: The global mouseup listener will handle the actual cleanup
// This is just an additional safety check for when mouse leaves without buttons pressed
if (e.buttons === 0 && isDragSelecting) {
setIsDragSelecting(false);
setFramesInSelection(new Set());
editorEngine.state.isDragSelecting = false;
}
}}
>
<div id={EditorAttributes.CANVAS_CONTAINER_ID} style={transformStyle}>
<Frames framesInDragSelection={framesInSelection} />
</div>
<RecenterCanvasButton />
<DragSelectOverlay
startX={dragSelectStart.x}
startY={dragSelectStart.y}
endX={dragSelectEnd.x}
endY={dragSelectEnd.y}
isSelecting={isDragSelecting}
/>
<Overlay />
<PanOverlay
clampPosition={(position: { x: number; y: number }) =>
clampPosition(position, scale)
}
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-64 bg-background/95 backdrop-blur-lg">
<ContextMenuItem
onClick={() => {
// Add Element action
}}
>
<Icons.Plus className="mr-2 h-4 w-4" />
Add Element
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
// Add Component action
}}
>
<Icons.ComponentInstance className="mr-2 h-4 w-4" />
Add Component
</ContextMenuItem>
Comment on lines +329 to +344
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Implement missing onClick handlers for Add Element and Add Component.

The context menu items for "Add Element" and "Add Component" have empty onClick handlers. These actions are non-functional and will confuse users if the menu is accessible.

Either:

  1. Implement the handlers if the functionality exists
  2. Remove these menu items until they're ready
  3. Disable them with a "Coming soon" indicator

Do you want me to help search for existing add element/component functionality that could be wired up?

🤖 Prompt for AI Agents
In apps/web/client/src/app/project/[id]/_components/canvas/index.tsx around
lines 329 to 344, the ContextMenuItem onClick handlers for "Add Element" and
"Add Component" are empty; either wire them to the existing
addElement/addComponent functions (import or access from the canvas/project
context or hooks and call them with the current selection/parent), or remove
these menu items; if the features are planned but not implemented, set the items
to disabled and show a "Coming soon" label or tooltip so users aren’t presented
with inert controls.

<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>
<Icons.ViewGrid className="mr-2 h-4 w-4" />
Panels
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onClick={() => {
onSidebarClick?.(LeftPanelTabValue.LAYERS);
}}
>
Layers
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
onSidebarClick?.(LeftPanelTabValue.BRAND);
}}
>
Brand
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
onSidebarClick?.(LeftPanelTabValue.PAGES);
}}
>
Pages
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
onSidebarClick?.(LeftPanelTabValue.IMAGES);
}}
>
Images
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
onSidebarClick?.(LeftPanelTabValue.BRANCHES);
}}
>
Branches
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
editorEngine.copy.copy();
}}
>
<Icons.Clipboard className="mr-2 h-4 w-4" />
Copy
<span className="ml-auto text-xs text-muted-foreground">⌘ C</span>
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
editorEngine.copy.paste();
}}
>
<Icons.ClipboardCopy className="mr-2 h-4 w-4" />
Paste
<span className="ml-auto text-xs text-muted-foreground">⌘ V</span>
</ContextMenuItem>
Comment on lines +328 to +408
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Internationalize hardcoded menu text.

Lines 335, 343, 349, 357, 364, 371, 378, 388, 397, and 406 contain hardcoded user-facing text ("Add Element", "Add Component", "Panels", "Layers", "Brand", "Pages", "Images", "Branches", "Copy", "Paste"). This violates the coding guideline to use next-intl messages/hooks instead of hardcoded text.

Based on coding guidelines.

The DesignPanel component (apps/web/client/src/app/project/[id]/_components/left-panel/design-panel/index.tsx) already uses useTranslations() and translation keys from transKeys.editor.panels.layers.tabs.*. Apply a similar pattern here:

+import { useTranslations } from 'next-intl';
+import { transKeys } from '@/i18n/keys';
+
 export const Canvas = observer(({ onSidebarClick }: CanvasProps) => {
     const editorEngine = useEditorEngine();
+    const t = useTranslations();
     // ... rest of component
     
     <ContextMenuItem onClick={() => { /* ... */ }}>
         <Icons.Plus className="mr-2 h-4 w-4" />
-        Add Element
+        {t(transKeys.editor.contextMenu.addElement)}
     </ContextMenuItem>

Ensure corresponding translation keys exist in your i18n configuration.

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines +391 to +408
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify keyboard shortcut labels match actual hotkeys.

The context menu displays "⌘ C" and "⌘ V" for Copy and Paste operations, but these might not reflect the actual keyboard shortcuts registered in the HotkeysArea or elsewhere in the application.

Verify that the displayed shortcuts match the actual hotkey bindings:

Additionally, consider making shortcuts cross-platform:

  • macOS: ⌘ C / ⌘ V
  • Windows/Linux: Ctrl+C / Ctrl+V

🏁 Script executed:

#!/bin/bash
# Description: Search for keyboard shortcut definitions for copy/paste

echo "=== Searching for copy hotkey definitions ==="
rg -n "copy|Copy" --type=ts --type=tsx -B 2 -A 2 | rg -i "cmd|meta|ctrl|key"

echo -e "\n=== Searching for paste hotkey definitions ==="
rg -n "paste|Paste" --type=ts --type=tsx -B 2 -A 2 | rg -i "cmd|meta|ctrl|key"

echo -e "\n=== Searching in HotkeysArea component ==="
fd -e ts -e tsx "hotkey" --exec cat {}

Length of output: 4228


🏁 Script executed:

#!/bin/bash
# Search for context menu usage of Copy/Paste with keyboard shortcuts in canvas component
echo "=== Finding canvas component file ==="
fd -type f -path "*canvas/index.tsx"

echo -e "\n=== Showing lines 391-408 of canvas component ==="
fd -type f -path "*canvas/index.tsx" -exec sed -n '391,408p' {} \;

echo -e "\n=== Broader context: lines 380-420 to understand the component ==="
fd -type f -path "*canvas/index.tsx" -exec sed -n '380,420p' {} \;

echo -e "\n=== Search for Hotkey imports in canvas component ==="
fd -type f -path "*canvas/index.tsx" -exec grep -n "Hotkey\|import.*hotkey" {} \;

Length of output: 895


🏁 Script executed:

#!/bin/bash
# Search for canvas component with correct fd syntax

echo "=== Finding canvas component file ==="
fd canvas/index.tsx

echo -e "\n=== Showing lines 391-408 of canvas component ==="
fd canvas/index.tsx -exec sed -n '391,408p' {} \;

echo -e "\n=== Broader context: lines 380-420 ==="
fd canvas/index.tsx -exec sed -n '380,420p' {} \;

echo -e "\n=== Search for Hotkey imports in canvas component ==="
fd canvas/index.tsx -exec grep -n "Hotkey\|import.*hotkey" {} \;

echo -e "\n=== Search for hardcoded keyboard shortcuts ==="
fd canvas/index.tsx -exec grep -n "⌘\|text-muted-foreground" {} \;

Length of output: 1156


🏁 Script executed:

#!/bin/bash
# Search for the canvas component more broadly

echo "=== Finding all index.tsx files in canvas directories ==="
find . -type f -path "*canvas/index.tsx" 2>/dev/null

echo -e "\n=== List recent .tsx files ==="
find . -type f -name "index.tsx" -path "*canvas*" 2>/dev/null

echo -e "\n=== Search for files containing Copy/Paste context menu ==="
rg -l "ContextMenuItem.*[Cc]opy|ContextMenuItem.*[Pp]aste" -g "*.tsx" 2>/dev/null | head -5

echo -e "\n=== Look for the exact file from the review path ==="
find . -type f -path "*project*\[id\]*canvas*index.tsx" 2>/dev/null

Length of output: 1210


🏁 Script executed:

#!/bin/bash
# Extract the exact lines from the canvas component

echo "=== Lines 391-408 from canvas/index.tsx ==="
sed -n '391,408p' ./apps/web/client/src/app/project/[id]/_components/canvas/index.tsx

echo -e "\n=== Broader context: lines 380-420 ==="
sed -n '380,420p' ./apps/web/client/src/app/project/[id]/_components/canvas/index.tsx

echo -e "\n=== Check for Hotkey imports in canvas/index.tsx ==="
grep -n "import.*Hotkey\|from.*hotkey" ./apps/web/client/src/app/project/[id]/_components/canvas/index.tsx

echo -e "\n=== Check if COPY or PASTE hotkeys are used ==="
grep -n "Hotkey\.COPY\|Hotkey\.PASTE\|\.COPY\|\.PASTE" ./apps/web/client/src/app/project/[id]/_components/canvas/index.tsx

echo -e "\n=== Search for ContextMenuItem usage ==="
grep -n "ContextMenuItem" ./apps/web/client/src/app/project/[id]/_components/canvas/index.tsx | head -10

echo -e "\n=== Search for keyboard shortcut display patterns ==="
grep -n "⌘\|Ctrl\|text-xs\|muted-foreground" ./apps/web/client/src/app/project/[id]/_components/canvas/index.tsx

Length of output: 3154


🏁 Script executed:

#!/bin/bash
# Check the HotkeysArea component to understand keyboard handling

echo "=== Content of hotkeys/index.tsx ==="
cat ./apps/web/client/src/app/project/[id]/_components/canvas/hotkeys/index.tsx

echo -e "\n=== Search for Hotkey class usage in canvas components ==="
grep -r "Hotkey\." ./apps/web/client/src/app/project/[id]/_components/canvas/ --include="*.tsx" | head -15

echo -e "\n=== Check imports at top of canvas/index.tsx ==="
head -30 ./apps/web/client/src/app/project/[id]/_components/canvas/index.tsx

Length of output: 9112


Use the Hotkey system to display platform-appropriate keyboard shortcuts instead of hardcoded Mac-only labels.

The context menu displays hardcoded "⌘ C" and "⌘ V" at lines 398 and 407, but the application's keyboard binding system (HotkeysArea in hotkeys/index.tsx) correctly supports cross-platform shortcuts via the Hotkey class, which dynamically converts mod to on macOS and Ctrl on Windows/Linux.

Import the Hotkey class and replace the hardcoded labels with:

  • Line 398: {Hotkey.COPY.readableCommand}
  • Line 407: {Hotkey.PASTE.readableCommand}

This ensures the displayed shortcuts match the actual keyboard bindings on all platforms.

🤖 Prompt for AI Agents
In apps/web/client/src/app/project/[id]/_components/canvas/index.tsx around
lines 391 to 408, the context menu uses hardcoded Mac-only labels ("⌘ C" and "⌘
V"); import the Hotkey class from the hotkeys module and replace those hardcoded
spans with {Hotkey.COPY.readableCommand} and {Hotkey.PASTE.readableCommand}
respectively so the displayed shortcuts are platform-appropriate; also add the
Hotkey import at the top of the file (e.g., import { Hotkey } from
'path/to/hotkeys') and remove the old hardcoded text.

</ContextMenuContent>
</ContextMenu>
</HotkeysArea>
);
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEditorEngine } from '@/components/store/editor';
import { transKeys } from '@/i18n/keys';
import { LeftPanelTabValue } from '@onlook/models';
import { Button } from '@onlook/ui/button';
import { Icons } from '@onlook/ui/icons';
import { cn } from '@onlook/ui/utils';
import { observer } from 'mobx-react-lite';
Expand Down Expand Up @@ -47,11 +48,16 @@ const tabs: {
},
];

export const DesignPanel = observer(() => {
interface DesignPanelProps {
onClose?: () => void;
activeSection?: string | null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refine the type of the activeSection prop to use LeftPanelTabValue | null instead of string | null for better type-safety.

Suggested change
activeSection?: string | null;
activeSection?: LeftPanelTabValue | null;

}

export const DesignPanel = observer(({ onClose, activeSection }: DesignPanelProps) => {
const editorEngine = useEditorEngine();
const t = useTranslations();
const isLocked = editorEngine.state.leftPanelLocked;
const selectedTab = editorEngine.state.leftPanelTab;
const selectedTab = (activeSection as LeftPanelTabValue) || editorEngine.state.leftPanelTab;

const handleMouseEnter = (tab: LeftPanelTabValue) => {
if (isLocked) {
Expand Down Expand Up @@ -102,8 +108,8 @@ export const DesignPanel = observer(() => {
className="flex h-full overflow-auto"
onMouseLeave={handleMouseLeave}
>
{/* Left sidebar with tabs */}
<div className="w-20 flex flex-col items-center py-0.5 gap-2 bg-background-onlook/60 backdrop-blur-xl">
{/* Left sidebar with tabs - HIDDEN */}
{/* <div className="w-20 flex flex-col items-center py-0.5 gap-2 bg-background-onlook/60 backdrop-blur-xl">
{tabs.map((tab) => (
<button
key={tab.value}
Expand All @@ -127,18 +133,47 @@ export const DesignPanel = observer(() => {
<ZoomControls />
<HelpButton />
</div>
</div>
</div> */}

{/* Content panel */}
{editorEngine.state.leftPanelTab && (
{selectedTab && (
<>
<div className="flex-1 w-[280px] bg-background/95 rounded-xl">
<div className="border backdrop-blur-xl h-full shadow overflow-auto p-0 rounded-xl">
{selectedTab === LeftPanelTabValue.LAYERS && <LayersTab />}
{selectedTab === LeftPanelTabValue.BRAND && <BrandTab />}
{selectedTab === LeftPanelTabValue.PAGES && <PagesTab />}
{selectedTab === LeftPanelTabValue.IMAGES && <ImagesTab />}
{selectedTab === LeftPanelTabValue.BRANCHES && <BranchesTab />}
<div className="border backdrop-blur-xl h-full shadow overflow-auto p-0 rounded-xl flex flex-col">
{/* Panel header with close button */}
<div className="flex items-center justify-between p-3 border-b border-border/50">
<div className="flex items-center gap-2">
{selectedTab === LeftPanelTabValue.LAYERS && <Icons.Layers className="w-4 h-4" />}
{selectedTab === LeftPanelTabValue.BRAND && <Icons.Brand className="w-4 h-4" />}
{selectedTab === LeftPanelTabValue.PAGES && <Icons.File className="w-4 h-4" />}
{selectedTab === LeftPanelTabValue.IMAGES && <Icons.Image className="w-4 h-4" />}
{selectedTab === LeftPanelTabValue.BRANCHES && <Icons.Branch className="w-4 h-4" />}
<span className="text-sm font-medium">
{selectedTab === LeftPanelTabValue.LAYERS && t(transKeys.editor.panels.layers.tabs.layers)}
{selectedTab === LeftPanelTabValue.BRAND && t(transKeys.editor.panels.layers.tabs.brand)}
{selectedTab === LeftPanelTabValue.PAGES && t(transKeys.editor.panels.layers.tabs.pages)}
{selectedTab === LeftPanelTabValue.IMAGES && t(transKeys.editor.panels.layers.tabs.images)}
{selectedTab === LeftPanelTabValue.BRANCHES && t(transKeys.editor.panels.layers.tabs.branches)}
</span>
</div>
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onClose}
>
<Icons.CrossS className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 overflow-auto">
{selectedTab === LeftPanelTabValue.LAYERS && <LayersTab />}
{selectedTab === LeftPanelTabValue.BRAND && <BrandTab />}
{selectedTab === LeftPanelTabValue.PAGES && <PagesTab />}
{selectedTab === LeftPanelTabValue.IMAGES && <ImagesTab />}
{selectedTab === LeftPanelTabValue.BRANCHES && <BranchesTab />}
</div>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import { observer } from "mobx-react-lite";
import { CodePanel } from "./code-panel";
import { DesignPanel } from "./design-panel";

export const LeftPanel = observer(() => {
interface LeftPanelProps {
onClose?: () => void;
activeSection?: string | null;
}

export const LeftPanel = observer(({ onClose, activeSection }: LeftPanelProps) => {
const editorEngine = useEditorEngine();
return <>
<div className={cn('size-full', editorEngine.state.editorMode !== EditorMode.DESIGN && editorEngine.state.editorMode !== EditorMode.PAN && 'hidden')}>
<DesignPanel />
<DesignPanel onClose={onClose} activeSection={activeSection} />
</div>
<div className={cn('size-full', editorEngine.state.editorMode !== EditorMode.CODE && 'hidden')}>
<CodePanel />
Expand Down
23 changes: 18 additions & 5 deletions apps/web/client/src/app/project/[id]/_components/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { useEditorEngine } from '@/components/store/editor';
import { SubscriptionModal } from '@/components/ui/pricing-modal';
import { SettingsModalWithProjects } from '@/components/ui/settings-modal/with-project';
import { EditorAttributes } from '@onlook/constants';
import { EditorMode } from '@onlook/models';
import { EditorMode, LeftPanelTabValue } from '@onlook/models';
import { Button } from '@onlook/ui/button';
import { Icons } from '@onlook/ui/icons';
import { TooltipProvider } from '@onlook/ui/tooltip';
import { cn } from '@onlook/ui/utils';
import { observer } from 'mobx-react-lite';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { usePanelMeasurements } from '../_hooks/use-panel-measure';
import { useStartProject } from '../_hooks/use-start-project';
import { BottomBar } from './bottom-bar';
Expand All @@ -31,6 +31,19 @@ export const Main = observer(() => {
leftPanelRef,
rightPanelRef,
);
const [activeSection, setActiveSection] = useState<string | null>(null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider typing the activeSection state as LeftPanelTabValue | null instead of string | null for improved type consistency.

Suggested change
const [activeSection, setActiveSection] = useState<string | null>(null);
const [activeSection, setActiveSection] = useState<LeftPanelTabValue | null>(null);


const handleSidebarClick = (tab: LeftPanelTabValue) => {
editorEngine.state.leftPanelTab = tab;
editorEngine.state.leftPanelLocked = true;
setActiveSection(tab);
};

const handleClosePanel = () => {
editorEngine.state.leftPanelTab = null;
editorEngine.state.leftPanelLocked = false;
setActiveSection(null);
};

useEffect(() => {
function handleGlobalWheel(event: WheelEvent) {
Expand Down Expand Up @@ -82,7 +95,7 @@ export const Main = observer(() => {
return (
<TooltipProvider>
<div className="h-screen w-screen flex flex-row select-none relative overflow-hidden">
<Canvas />
<Canvas onSidebarClick={handleSidebarClick} />

<div className="absolute top-0 w-full">
<TopBar />
Expand All @@ -91,9 +104,9 @@ export const Main = observer(() => {
{/* Left Panel */}
<div
ref={leftPanelRef}
className="absolute top-10 left-0 h-[calc(100%-40px)] z-50"
className="absolute top-10 left-2 h-[calc(100%-40px)] z-50"
>
<LeftPanel />
<LeftPanel onClose={handleClosePanel} activeSection={activeSection} />
</div>
{/* EditorBar anchored between panels */}
<div
Expand Down
Loading