Skip to content

Commit 479b643

Browse files
committed
feat: spec explorer
1 parent beb8bc6 commit 479b643

5 files changed

Lines changed: 547 additions & 163 deletions

File tree

apps/web/src/components/Navbar.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface NavbarProps {
1313
export function Navbar({ sticky = false, maxWidth = false, onSearchClick }: NavbarProps) {
1414
const location = useLocation();
1515
const isDocsActive = location.pathname.startsWith("/docs");
16+
const isSpecActive = location.pathname === "/spec";
1617
const isMcpActive = location.pathname === "/mcp";
1718
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
1819

@@ -33,6 +34,9 @@ export function Navbar({ sticky = false, maxWidth = false, onSearchClick }: Navb
3334
<NavLink to="/docs" active={isDocsActive}>
3435
Reference
3536
</NavLink>
37+
<NavLink to="/spec" active={isSpecActive}>
38+
Spec
39+
</NavLink>
3640
<div className="flex items-center gap-1">
3741
<NavLink to="/mcp" active={isMcpActive}>
3842
MCP
@@ -80,6 +84,9 @@ export function Navbar({ sticky = false, maxWidth = false, onSearchClick }: Navb
8084
<NavLink to="/docs" active={isDocsActive} onClick={() => setMobileMenuOpen(false)}>
8185
Reference
8286
</NavLink>
87+
<NavLink to="/spec" active={isSpecActive} onClick={() => setMobileMenuOpen(false)}>
88+
Spec
89+
</NavLink>
8390
<div className="flex items-center gap-1">
8491
<NavLink to="/mcp" active={isMcpActive} onClick={() => setMobileMenuOpen(false)}>
8592
MCP
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
3+
// PDF URLs and page counts for each part
4+
const PDF_CONFIG: Record<number, { url: string; totalPages: number; name: string }> = {
5+
1: {
6+
url: "https://cdn.ooxml.dev/ecma-376/part1.pdf",
7+
totalPages: 5560,
8+
name: "Fundamentals",
9+
},
10+
2: {
11+
url: "https://cdn.ooxml.dev/ecma-376/part2.pdf",
12+
totalPages: 129,
13+
name: "OPC",
14+
},
15+
3: {
16+
url: "https://cdn.ooxml.dev/ecma-376/part3.pdf",
17+
totalPages: 65,
18+
name: "Compatibility",
19+
},
20+
4: {
21+
url: "https://cdn.ooxml.dev/ecma-376/part4.pdf",
22+
totalPages: 4031,
23+
name: "Transitional",
24+
},
25+
};
26+
27+
interface PdfViewerProps {
28+
partNumber: number;
29+
pageNumber: number;
30+
onPageChange?: (page: number) => void;
31+
}
32+
33+
export function PdfViewer({ partNumber, pageNumber, onPageChange }: PdfViewerProps) {
34+
const config = PDF_CONFIG[partNumber] || PDF_CONFIG[1];
35+
const [currentPage, setCurrentPage] = useState(pageNumber);
36+
const [isDragging, setIsDragging] = useState(false);
37+
const progressRef = useRef<HTMLDivElement>(null);
38+
39+
// Sync with prop changes
40+
useEffect(() => {
41+
setCurrentPage(pageNumber);
42+
}, [pageNumber]);
43+
44+
const updatePage = useCallback(
45+
(newPage: number) => {
46+
const clamped = Math.max(1, Math.min(newPage, config.totalPages));
47+
setCurrentPage(clamped);
48+
onPageChange?.(clamped);
49+
},
50+
[config.totalPages, onPageChange],
51+
);
52+
53+
const handlePrev = () => updatePage(currentPage - 1);
54+
const handleNext = () => updatePage(currentPage + 1);
55+
56+
// Progress bar interaction
57+
const getPageFromPosition = useCallback(
58+
(clientX: number) => {
59+
if (!progressRef.current) return currentPage;
60+
const rect = progressRef.current.getBoundingClientRect();
61+
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
62+
return Math.max(1, Math.round(ratio * config.totalPages));
63+
},
64+
[config.totalPages, currentPage],
65+
);
66+
67+
const handleProgressClick = (e: React.MouseEvent) => {
68+
updatePage(getPageFromPosition(e.clientX));
69+
};
70+
71+
const handleDragStart = (e: React.MouseEvent) => {
72+
e.preventDefault();
73+
setIsDragging(true);
74+
};
75+
76+
useEffect(() => {
77+
if (!isDragging) return;
78+
79+
const handleMove = (e: MouseEvent) => {
80+
updatePage(getPageFromPosition(e.clientX));
81+
};
82+
83+
const handleUp = () => {
84+
setIsDragging(false);
85+
};
86+
87+
document.addEventListener("mousemove", handleMove);
88+
document.addEventListener("mouseup", handleUp);
89+
90+
return () => {
91+
document.removeEventListener("mousemove", handleMove);
92+
document.removeEventListener("mouseup", handleUp);
93+
};
94+
}, [isDragging, getPageFromPosition, updatePage]);
95+
96+
// Keyboard navigation
97+
useEffect(() => {
98+
const handleKeyDown = (e: KeyboardEvent) => {
99+
if (e.key === "ArrowLeft") {
100+
e.preventDefault();
101+
setCurrentPage((p) => {
102+
const newPage = Math.max(1, p - 1);
103+
onPageChange?.(newPage);
104+
return newPage;
105+
});
106+
}
107+
if (e.key === "ArrowRight") {
108+
e.preventDefault();
109+
setCurrentPage((p) => {
110+
const newPage = Math.min(config.totalPages, p + 1);
111+
onPageChange?.(newPage);
112+
return newPage;
113+
});
114+
}
115+
};
116+
117+
document.addEventListener("keydown", handleKeyDown);
118+
return () => document.removeEventListener("keydown", handleKeyDown);
119+
}, [config.totalPages, onPageChange]);
120+
121+
const progressPercent = (currentPage / config.totalPages) * 100;
122+
const pdfUrl = `${config.url}#page=${currentPage}&toolbar=0&navpanes=0`;
123+
124+
return (
125+
<div className="flex h-full flex-col bg-[var(--color-bg-secondary)]">
126+
{/* Toolbar */}
127+
<div className="border-b border-[var(--color-border)] bg-[var(--color-bg-primary)]">
128+
<div className="flex items-center justify-between px-5 py-2.5">
129+
{/* Part label */}
130+
<div className="text-sm text-[var(--color-text-secondary)]">
131+
<span className="font-medium text-[var(--color-text-primary)]">Part {partNumber}</span>
132+
<span className="mx-1.5">·</span>
133+
<span>{config.name}</span>
134+
</div>
135+
136+
{/* Navigation controls */}
137+
<div className="flex items-center gap-4">
138+
<div className="flex gap-1">
139+
<button
140+
type="button"
141+
onClick={handlePrev}
142+
disabled={currentPage <= 1}
143+
className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] transition hover:bg-[var(--color-border)] hover:text-[var(--color-text-primary)] disabled:opacity-40 disabled:cursor-not-allowed"
144+
aria-label="Previous page"
145+
>
146+
147+
</button>
148+
<button
149+
type="button"
150+
onClick={handleNext}
151+
disabled={currentPage >= config.totalPages}
152+
className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] transition hover:bg-[var(--color-border)] hover:text-[var(--color-text-primary)] disabled:opacity-40 disabled:cursor-not-allowed"
153+
aria-label="Next page"
154+
>
155+
156+
</button>
157+
</div>
158+
<div className="flex items-baseline gap-1 text-sm">
159+
<span className="font-semibold text-[var(--color-text-primary)]">{currentPage}</span>
160+
<span className="text-[var(--color-text-muted)]">of {config.totalPages}</span>
161+
</div>
162+
</div>
163+
</div>
164+
165+
{/* Progress bar */}
166+
<div
167+
ref={progressRef}
168+
onClick={handleProgressClick}
169+
className="group relative h-[3px] cursor-pointer bg-[var(--color-bg-tertiary)] transition-all hover:h-[5px]"
170+
>
171+
<div
172+
className="absolute left-0 top-0 h-full rounded-r bg-[var(--color-accent)] transition-[width] duration-200"
173+
style={{ width: `${progressPercent}%` }}
174+
/>
175+
<div
176+
onMouseDown={handleDragStart}
177+
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border-2 border-white bg-[var(--color-accent)] shadow-md transition-transform active:scale-110 active:cursor-grabbing group-hover:h-3.5 group-hover:w-3.5"
178+
style={{ left: `${progressPercent}%` }}
179+
/>
180+
</div>
181+
</div>
182+
183+
{/* PDF iframe */}
184+
<div className="flex-1">
185+
<iframe
186+
key={pdfUrl}
187+
src={pdfUrl}
188+
className="h-full w-full border-0"
189+
title={`ECMA-376 Part ${partNumber} - Page ${currentPage}`}
190+
/>
191+
</div>
192+
</div>
193+
);
194+
}

0 commit comments

Comments
 (0)