Skip to content

Commit 7577e4b

Browse files
feat: page selector (#2515)
* feat: page selector * add test * add navigation history * fix navigate inside frame * remove listeners * fix selecting folder
1 parent 6a1254b commit 7577e4b

File tree

16 files changed

+1316
-446
lines changed

16 files changed

+1316
-446
lines changed
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { useEditorEngine } from '@/components/store/editor';
2+
import { LeftPanelTabValue, type PageNode, type WebFrame } from '@onlook/models';
3+
import { Button } from '@onlook/ui/button';
4+
import {
5+
DropdownMenu,
6+
DropdownMenuContent,
7+
DropdownMenuItem,
8+
DropdownMenuTrigger,
9+
} from '@onlook/ui/dropdown-menu';
10+
import { Icons } from '@onlook/ui/icons';
11+
import { Separator } from '@onlook/ui/separator';
12+
import { cn } from '@onlook/ui/utils';
13+
import { inferPageFromUrl } from '@onlook/utility';
14+
import { observer } from 'mobx-react-lite';
15+
import React, { useEffect, useMemo, useState } from 'react';
16+
import { PageModal } from '../../left-panel/page-tab/page-modal';
17+
18+
interface PageSelectorProps {
19+
frame: WebFrame;
20+
className?: string;
21+
}
22+
23+
export const PageSelector = observer(({ frame, className }: PageSelectorProps) => {
24+
const editorEngine = useEditorEngine();
25+
const [showCreateModal, setShowCreateModal] = useState(false);
26+
27+
// Get inferred current page from URL immediately
28+
const inferredCurrentPage = useMemo(() => inferPageFromUrl(frame.url), [frame.url]);
29+
30+
// Flatten the page tree to get all pages for finding current page
31+
const flattenPages = (pages: PageNode[]): PageNode[] => {
32+
return pages.reduce<PageNode[]>((acc, page) => {
33+
acc.push(page);
34+
if (page.children) {
35+
acc.push(...flattenPages(page.children));
36+
}
37+
return acc;
38+
}, []);
39+
};
40+
41+
const allPages = useMemo(() => {
42+
return flattenPages(editorEngine.pages.tree);
43+
}, [editorEngine.pages.tree]);
44+
45+
// Find the current page based on the frame URL
46+
const currentPage = useMemo(() => {
47+
const framePathname = new URL(frame.url).pathname;
48+
return allPages.find(page => {
49+
const pagePath = page.path === '/' ? '' : page.path;
50+
return framePathname === pagePath || framePathname === page.path;
51+
});
52+
}, [frame.url, allPages]);
53+
54+
// Render pages recursively with indentation
55+
const renderPageItems = (pages: PageNode[], depth = 0): React.ReactElement[] => {
56+
const items: React.ReactElement[] = [];
57+
58+
for (const page of pages) {
59+
const isCurrentPage = currentPage?.id === page.id;
60+
const hasChildren = page.children && page.children.length > 0;
61+
62+
items.push(
63+
<DropdownMenuItem
64+
key={page.id}
65+
onClick={() => handlePageSelect(page)}
66+
className={cn(
67+
"cursor-pointer",
68+
isCurrentPage && "bg-accent"
69+
)}
70+
>
71+
<div className="flex items-center w-full" style={{ paddingLeft: `${depth * 16}px` }}>
72+
{hasChildren ? (
73+
<Icons.Directory className="w-4 h-4 mr-2" />
74+
) : (
75+
<Icons.File className="w-4 h-4 mr-2" />
76+
)}
77+
<span className="truncate">{page.name}</span>
78+
{isCurrentPage && (
79+
<Icons.Check className="ml-auto h-3 w-3" />
80+
)}
81+
</div>
82+
</DropdownMenuItem>
83+
);
84+
85+
// Render children recursively
86+
if (page.children && page.children.length > 0) {
87+
items.push(...renderPageItems(page.children, depth + 1));
88+
}
89+
}
90+
91+
return items;
92+
};
93+
94+
useEffect(() => {
95+
if (editorEngine.sandbox.routerConfig) {
96+
editorEngine.pages.scanPages();
97+
}
98+
}, [editorEngine.sandbox.routerConfig]);
99+
100+
const displayPages = useMemo(() => {
101+
if (allPages.length > 0) {
102+
return allPages;
103+
}
104+
// Temp page while scanning
105+
return [{
106+
id: 'temp-current',
107+
name: inferredCurrentPage.name,
108+
path: inferredCurrentPage.path,
109+
children: [],
110+
isActive: true,
111+
isRoot: inferredCurrentPage.path === '/',
112+
metadata: {}
113+
}] as PageNode[];
114+
}, [allPages, inferredCurrentPage]);
115+
116+
const displayCurrentPage = currentPage ?? {
117+
name: inferredCurrentPage.name,
118+
path: inferredCurrentPage.path
119+
};
120+
121+
const handlePageSelect = async (page: PageNode) => {
122+
try {
123+
await editorEngine.frames.navigateToPath(frame.id, page.path);
124+
} catch (error) {
125+
console.error('Failed to navigate to page:', error);
126+
}
127+
};
128+
129+
const handleManagePages = () => {
130+
editorEngine.state.leftPanelTab = LeftPanelTabValue.PAGES
131+
editorEngine.state.leftPanelLocked = true;
132+
};
133+
134+
return (
135+
<DropdownMenu onOpenChange={(open) => {
136+
if (open) {
137+
editorEngine.frames.select([frame]);
138+
}
139+
}}>
140+
<DropdownMenuTrigger asChild>
141+
<Button
142+
variant="ghost"
143+
size="sm"
144+
className={cn(
145+
"h-auto px-2 py-1 text-xs hover:bg-background-secondary",
146+
className
147+
)}
148+
>
149+
<span className="max-w-32 truncate">
150+
{displayCurrentPage.name}
151+
</span>
152+
<Icons.ChevronDown className="ml-1 h-3 w-3" />
153+
</Button>
154+
</DropdownMenuTrigger>
155+
<DropdownMenuContent align="start" className="w-48">
156+
{displayPages.length > 0 ? (
157+
<>
158+
{allPages.length > 0 ? (
159+
// Show full scanned tree when available
160+
renderPageItems(editorEngine.pages.tree)
161+
) : (
162+
// Show inferred current page while scanning
163+
<>
164+
{displayPages[0] && (
165+
<DropdownMenuItem
166+
onClick={() => {
167+
const firstPage = displayPages[0];
168+
if (firstPage) {
169+
void handlePageSelect(firstPage);
170+
}
171+
}}
172+
className="cursor-pointer bg-accent"
173+
>
174+
<div className="flex items-center w-full">
175+
<Icons.File className="w-4 h-4 mr-2" />
176+
<span className="truncate">{displayPages[0].name}</span>
177+
<Icons.Check className="ml-auto h-3 w-3" />
178+
</div>
179+
</DropdownMenuItem>
180+
)}
181+
{editorEngine.pages.isScanning && (
182+
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
183+
<Icons.LoadingSpinner className="w-3 h-3 mr-2 animate-spin" />
184+
<span>Scanning pages...</span>
185+
</DropdownMenuItem>
186+
)}
187+
</>
188+
)}
189+
</>
190+
) : (
191+
<DropdownMenuItem disabled>
192+
No pages available
193+
</DropdownMenuItem>
194+
)}
195+
<Separator />
196+
<DropdownMenuItem className="cursor-pointer " onClick={() => setShowCreateModal(true)}>
197+
<Icons.FilePlus />
198+
<span>
199+
New Page
200+
</span>
201+
</DropdownMenuItem>
202+
<DropdownMenuItem className="cursor-pointer" onClick={handleManagePages}>
203+
<Icons.Gear />
204+
<span>
205+
Manage Pages
206+
</span>
207+
</DropdownMenuItem>
208+
</DropdownMenuContent>
209+
<PageModal mode="create" open={showCreateModal} onOpenChange={setShowCreateModal} />
210+
211+
</DropdownMenu>
212+
);
213+
});

apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar.tsx

Lines changed: 75 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,19 @@ import { observer } from 'mobx-react-lite';
77
import Link from 'next/link';
88
import { useRef } from 'react';
99
import { HoverOnlyTooltip } from '../../editor-bar/hover-tooltip';
10+
import { PageSelector } from './page-selector';
1011

1112
export const TopBar = observer(
1213
({ frame }: { frame: WebFrame }) => {
1314
const editorEngine = useEditorEngine();
1415
const isSelected = editorEngine.frames.isSelected(frame.id);
1516
const topBarRef = useRef<HTMLDivElement>(null);
16-
const urlRef = useRef<HTMLDivElement>(null);
17-
const topBarWidth = (topBarRef.current?.clientWidth ?? 0);
18-
const urlWidth = (urlRef.current?.clientWidth ?? 0);
19-
const shouldShowExternalLink = ((topBarWidth - urlWidth) * editorEngine.canvas.scale) > 250;
17+
const toolBarRef = useRef<HTMLDivElement>(null);
18+
const topBarWidth = topBarRef.current?.clientWidth ?? 0;
19+
const toolBarWidth = toolBarRef.current?.clientWidth ?? 0;
20+
const padding = 210;
21+
const shouldShowExternalLink =
22+
(topBarWidth - toolBarWidth - padding) * editorEngine.canvas.scale > 250;
2023

2124
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
2225
e.preventDefault();
@@ -28,7 +31,7 @@ export const TopBar = observer(
2831
const startPositionX = frame.position.x;
2932
const startPositionY = frame.position.y;
3033

31-
const handleMove = (e: MouseEvent) => {
34+
const handleMove = async (e: MouseEvent) => {
3235
const scale = editorEngine.canvas.scale;
3336
const deltaX = (e.clientX - startX) / scale;
3437
const deltaY = (e.clientY - startY) / scale;
@@ -61,19 +64,25 @@ export const TopBar = observer(
6164
editorEngine.frames.reloadView(frame.id);
6265
};
6366

67+
const handleGoBack = async () => {
68+
await editorEngine.frames.goBack(frame.id);
69+
};
70+
71+
const handleGoForward = async () => {
72+
await editorEngine.frames.goForward(frame.id);
73+
};
74+
6475
const handleClick = () => {
6576
editorEngine.frames.select([frame]);
6677
};
6778

6879
return (
6980
<div
7081
ref={topBarRef}
71-
className={
72-
cn(
73-
'rounded-lg bg-background-primary/10 hover:shadow h-6 m-auto flex flex-row items-center backdrop-blur-lg overflow-hidden relative shadow-sm border-input text-foreground-secondary group-hover:text-foreground cursor-grab active:cursor-grabbing',
74-
isSelected && 'text-teal-400 fill-teal-400',
75-
)
76-
}
82+
className={cn(
83+
'rounded-lg bg-background-primary/10 hover:shadow h-6 m-auto flex flex-row items-center backdrop-blur-lg overflow-hidden relative shadow-sm border-input text-foreground-secondary group-hover:text-foreground cursor-grab active:cursor-grabbing',
84+
isSelected && 'text-teal-400 fill-teal-400',
85+
)}
7786
style={{
7887
height: `${28 / editorEngine.canvas.scale}px`,
7988
width: `${frame.dimension.width}px`,
@@ -83,54 +92,70 @@ export const TopBar = observer(
8392
onClick={handleClick}
8493
>
8594
<div
86-
className="flex flex-row items-center gap-2"
95+
className="flex flex-row items-center"
8796
style={{
8897
transform: `scale(${1 / editorEngine.canvas.scale})`,
8998
transformOrigin: 'left center',
9099
}}
100+
ref={toolBarRef}
91101
>
92-
<HoverOnlyTooltip
93-
content="Refresh Page"
94-
side="top"
95-
className='mb-1'
96-
hideArrow
97-
>
98-
<Button variant="ghost" size="icon" className="cursor-pointer" onClick={handleReload}>
102+
<HoverOnlyTooltip content="Go back" side="top" className="mb-1" hideArrow>
103+
<Button
104+
variant="ghost"
105+
size="icon"
106+
className={cn(
107+
'cursor-pointer',
108+
!editorEngine.frames.navigation.canGoBack(frame.id) && 'hidden',
109+
)}
110+
onClick={handleGoBack}
111+
disabled={!editorEngine.frames.navigation.canGoBack(frame.id)}
112+
>
113+
<Icons.ArrowLeft />
114+
</Button>
115+
</HoverOnlyTooltip>
116+
<HoverOnlyTooltip content="Go forward" side="top" className="mb-1" hideArrow>
117+
<Button
118+
variant="ghost"
119+
size="icon"
120+
className={cn(
121+
'cursor-pointer',
122+
!editorEngine.frames.navigation.canGoForward(frame.id) && 'hidden',
123+
)}
124+
onClick={handleGoForward}
125+
disabled={!editorEngine.frames.navigation.canGoForward(frame.id)}
126+
>
127+
<Icons.ArrowRight />
128+
</Button>
129+
</HoverOnlyTooltip>
130+
<HoverOnlyTooltip content="Refresh Page" side="top" className="mb-1" hideArrow>
131+
<Button
132+
variant="ghost"
133+
size="icon"
134+
className="cursor-pointer"
135+
onClick={handleReload}
136+
>
99137
<Icons.Reload />
100138
</Button>
101139
</HoverOnlyTooltip>
102-
103-
<div
104-
ref={urlRef}
105-
className="text-small overflow-hidden text-ellipsis whitespace-nowrap">
106-
{frame.url}
107-
</div>
140+
<PageSelector frame={frame} />
108141
</div>
109-
<HoverOnlyTooltip
110-
content="Preview in new tab"
111-
side="top"
112-
hideArrow
113-
className='mb-1'
114-
>
115-
<Link
116-
className="absolute right-1 top-1/2 -translate-y-1/2 transition-opacity duration-300"
117-
href={frame.url}
118-
target="_blank"
119-
style={{
120-
transform: `scale(${1 / editorEngine.canvas.scale})`,
121-
transformOrigin: 'right center',
122-
opacity: shouldShowExternalLink ? 1 : 0,
123-
pointerEvents: shouldShowExternalLink ? 'auto' : 'none',
124-
}}
125-
>
126-
<Button variant="ghost" size="icon">
127-
<Icons.ExternalLink />
128-
</Button>
129-
</Link>
142+
<HoverOnlyTooltip content="Preview in new tab" side="top" hideArrow className="mb-1">
143+
<Link
144+
className="absolute right-1 top-1/2 -translate-y-1/2 transition-opacity duration-300"
145+
href={frame.url.replace(/\[([^\]]+)\]/g, 'temp-$1')} // Dynamic routes are not supported so we replace them with a temporary value
146+
target="_blank"
147+
style={{
148+
transform: `scale(${1 / editorEngine.canvas.scale})`,
149+
transformOrigin: 'right center',
150+
opacity: shouldShowExternalLink ? 1 : 0,
151+
pointerEvents: shouldShowExternalLink ? 'auto' : 'none',
152+
}}
153+
>
154+
<Button variant="ghost" size="icon">
155+
<Icons.ExternalLink />
156+
</Button>
157+
</Link>
130158
</HoverOnlyTooltip>
131-
132159
</div>
133160
);
134-
},
135-
);
136-
161+
});

0 commit comments

Comments
 (0)