Skip to content

Commit 78daeeb

Browse files
committed
v2.29 - Fix Slide Selector, Reposition Block Lab
1 parent e714ddf commit 78daeeb

2 files changed

Lines changed: 88 additions & 49 deletions

File tree

frontend/src/components/app/views/BlocksView.tsx

Lines changed: 34 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,40 @@ export function BlocksView() {
165165

166166
return (
167167
<div style={{ padding: "40px", maxWidth: "720px", margin: "0 auto", width: "100%", flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
168-
<h2 style={{ fontFamily: FONTS.sans, fontSize: "28px", fontWeight: 600, color: COLORS.textPrimary, marginBottom: "6px", flexShrink: 0 }}>
169-
Blocks
170-
</h2>
168+
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px", marginBottom: "6px", flexShrink: 0 }}>
169+
<h2 style={{ fontFamily: FONTS.sans, fontSize: "28px", fontWeight: 600, color: COLORS.textPrimary, margin: 0 }}>
170+
Blocks
171+
</h2>
172+
<Link
173+
href="/block-lab"
174+
title="Block Lab — Component showcase"
175+
aria-label="Block Lab — Component showcase"
176+
style={{
177+
display: "flex",
178+
alignItems: "center",
179+
justifyContent: "center",
180+
width: 28,
181+
height: 28,
182+
borderRadius: "50%",
183+
color: COLORS.textTertiary,
184+
transition: "color 0.15s ease, background 0.15s ease",
185+
flexShrink: 0,
186+
}}
187+
onMouseEnter={(e) => {
188+
e.currentTarget.style.color = COLORS.accent
189+
e.currentTarget.style.background = COLORS.accentDim
190+
}}
191+
onMouseLeave={(e) => {
192+
e.currentTarget.style.color = COLORS.textTertiary
193+
e.currentTarget.style.background = "transparent"
194+
}}
195+
>
196+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
197+
<circle cx="12" cy="12" r="10" />
198+
<path d="M12 16v-4M12 8h.01" />
199+
</svg>
200+
</Link>
201+
</div>
171202
<p style={{ color: COLORS.iconDefault, fontSize: "14px", fontFamily: FONTS.sans, marginBottom: "16px", flexShrink: 0 }}>
172203
All your explorations in one place.
173204
</p>
@@ -210,32 +241,6 @@ export function BlocksView() {
210241
)
211242
})}
212243
</div>
213-
214-
{/* Block Lab link */}
215-
<Link
216-
href="/block-lab"
217-
style={{
218-
flexShrink: 0,
219-
display: "flex",
220-
alignItems: "center",
221-
gap: 8,
222-
padding: "12px 16px",
223-
marginTop: 8,
224-
borderRadius: 10,
225-
border: `1px solid ${COLORS.border}`,
226-
background: "transparent",
227-
color: COLORS.textTertiary,
228-
fontFamily: FONTS.sans,
229-
fontSize: 13,
230-
textDecoration: "none",
231-
transition: "background 0.15s, color 0.15s",
232-
}}
233-
onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.hover; e.currentTarget.style.color = COLORS.textPrimary }}
234-
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = COLORS.textTertiary }}
235-
>
236-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></svg>
237-
Block Lab — Component showcase
238-
</Link>
239244
</div>
240245
)
241246
}

frontend/src/components/blocks/TextbookRenderer.tsx

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,6 @@ const TOC_BAR_HEIGHT = 3.5
228228
const TOC_SECTION_WIDTH = 38
229229
const TOC_SUBSECTION_WIDTH = 22
230230
const TOC_BAR_GAP = 12
231-
const TOC_PERSPECTIVE = 200
232-
const TOC_MAX_ROTATE_DEG = 60
233231
const TOC_STRIP_WIDTH = 56
234232

235233
/** Scroll main content to a TOC entry (section or subsection). */
@@ -270,6 +268,10 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
270268
const [isHoveringStrip, setIsHoveringStrip] = useState(false)
271269
const rafRef = useRef<number>(0)
272270
const wheelAccRef = useRef(0)
271+
const [clickedIdx, setClickedIdx] = useState<number | null>(null)
272+
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
273+
const dragRef = useRef<{ startY: number; startIdx: number } | null>(null)
274+
const dragIdxRef = useRef<number | null>(null)
273275

274276
/* Compute fractional index from main content scroll position */
275277
useEffect(() => {
@@ -310,23 +312,29 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
310312
const onScroll = () => {
311313
cancelAnimationFrame(rafRef.current)
312314
rafRef.current = requestAnimationFrame(computeFractional)
315+
/* Clear click lock after scroll animation settles */
316+
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current)
317+
scrollTimeoutRef.current = setTimeout(() => setClickedIdx(null), 600)
313318
}
314319

315320
computeFractional()
316321
container.addEventListener("scroll", onScroll, { passive: true })
317322
return () => {
318323
container.removeEventListener("scroll", onScroll)
319324
cancelAnimationFrame(rafRef.current)
325+
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current)
320326
}
321327
}, [entries, contentScrollRef])
322328

323329
const activeEntryIndex = useMemo(() => {
324330
if (!activeEntryId) return -1
325331
return entries.findIndex((entry) => entry.id === activeEntryId)
326332
}, [activeEntryId, entries])
327-
const visualFractionalIndex = activeEntryIndex >= 0 ? activeEntryIndex : fractionalIndex
333+
const visualFractionalIndex = clickedIdx !== null ? clickedIdx : activeEntryIndex >= 0 ? activeEntryIndex : fractionalIndex
328334

329-
const handleBarClick = useCallback((entry: TocEntry) => {
335+
const handleBarClick = useCallback((entry: TocEntry, idx: number) => {
336+
setClickedIdx(idx)
337+
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current)
330338
scrollContentToEntry(entry, contentScrollRef)
331339
}, [contentScrollRef])
332340

@@ -338,13 +346,42 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
338346
if (Math.abs(wheelAccRef.current) >= threshold) {
339347
const direction = wheelAccRef.current > 0 ? 1 : -1
340348
wheelAccRef.current = 0
341-
const currentIdx = Math.round(visualFractionalIndex)
349+
const currentIdx = clickedIdx !== null ? clickedIdx : Math.round(visualFractionalIndex)
342350
const nextIdx = Math.max(0, Math.min(entries.length - 1, currentIdx + direction))
343351
if (entries[nextIdx]) {
352+
setClickedIdx(nextIdx)
353+
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current)
344354
scrollContentToEntry(entries[nextIdx], contentScrollRef)
345355
}
346356
}
347-
}, [visualFractionalIndex, entries, contentScrollRef])
357+
}, [visualFractionalIndex, clickedIdx, entries, contentScrollRef])
358+
359+
/* iOS picker drag: pointer down/move/up on strip to scroll through entries */
360+
const handleDragStart = useCallback((e: React.PointerEvent) => {
361+
const idx = clickedIdx ?? Math.round(fractionalIndex)
362+
dragRef.current = { startY: e.clientY, startIdx: idx }
363+
dragIdxRef.current = idx
364+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
365+
}, [clickedIdx, fractionalIndex])
366+
367+
const handleDragMove = useCallback((e: React.PointerEvent) => {
368+
if (!dragRef.current) return
369+
const deltaY = e.clientY - dragRef.current.startY
370+
if (Math.abs(deltaY) < 5) return
371+
const step = TOC_BAR_HEIGHT + TOC_BAR_GAP
372+
const deltaIdx = Math.round(-deltaY / step)
373+
const newIdx = Math.max(0, Math.min(entries.length - 1, dragRef.current.startIdx + deltaIdx))
374+
if (entries[newIdx] && newIdx !== dragIdxRef.current) {
375+
dragIdxRef.current = newIdx
376+
setClickedIdx(newIdx)
377+
scrollContentToEntry(entries[newIdx], contentScrollRef)
378+
}
379+
}, [entries, contentScrollRef])
380+
381+
const handleDragEnd = useCallback(() => {
382+
dragRef.current = null
383+
dragIdxRef.current = null
384+
}, [])
348385

349386
if (entries.length < 2) return null
350387

@@ -376,6 +413,9 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
376413
onMouseEnter={() => setIsHoveringStrip(true)}
377414
onMouseLeave={() => { setIsHoveringStrip(false); setHoveredIdx(null) }}
378415
onWheel={handleWheel}
416+
onPointerDown={handleDragStart}
417+
onPointerMove={handleDragMove}
418+
onPointerUp={handleDragEnd}
379419
style={{
380420
position: "fixed",
381421
right: 0,
@@ -400,7 +440,7 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
400440
boxShadow: stripShadow,
401441
backdropFilter: "blur(16px)",
402442
WebkitBackdropFilter: "blur(16px)",
403-
opacity: isHoveringStrip ? 1 : 0,
443+
opacity: isHoveringStrip ? 1 : 0.5,
404444
transition: "opacity 0.3s ease",
405445
pointerEvents: "none",
406446
}}
@@ -451,7 +491,7 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
451491
transition: "transform 0.4s cubic-bezier(0.23,1,0.32,1)",
452492
cursor: "pointer",
453493
}}
454-
onClick={() => handleBarClick(entry)}
494+
onClick={() => handleBarClick(entry, idx)}
455495
onMouseEnter={() => setHoveredIdx(idx)}
456496
onMouseLeave={() => setHoveredIdx(null)}
457497
onMouseDown={(e) => e.preventDefault()}
@@ -468,8 +508,7 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
468508
right: 0,
469509
bottom: 0,
470510
width: TOC_STRIP_WIDTH - 8,
471-
transformStyle: "preserve-3d",
472-
perspective: TOC_PERSPECTIVE,
511+
overflow: "hidden",
473512
pointerEvents: "none",
474513
}}
475514
>
@@ -479,30 +518,25 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
479518
const isSection = entry.isSection
480519
const barWidth = isSection ? TOC_SECTION_WIDTH : TOC_SUBSECTION_WIDTH
481520

482-
/* 3D cylinder rotation */
483-
const rotateDeg = Math.min(absOffset * 16, TOC_MAX_ROTATE_DEG) * Math.sign(offset)
484521
/* Position: offset from active entry (active = center) */
485522
const yFromCenter = offset * rowStep
486523

487524
const isActive = absOffset < 0.5
488525
const isHovered = hoveredIdx === idx
489526

490-
/* Opacity: when not hovering strip, only active bar visible;
491-
on hover, all nearby bars revealed clearly */
527+
/* Opacity: non-hover shows dimmed bars (image 1); hover shows all bars evenly (image 2) */
492528
const opacity = isActive
493529
? 1
494-
: isHoveringStrip
495-
? Math.max(0.15, 1 - absOffset * 0.18)
496-
: Math.max(0, 0.1 - absOffset * 0.025)
530+
: isHoveringStrip ? 0.55 : 0.3
497531

498532
/* Color: gold active, bright on hover, muted otherwise */
499533
const barColor = isActive
500534
? "#C47500"
501535
: isHovered
502536
? (isDark ? "rgba(255,255,255,0.75)" : "rgba(0,0,0,0.55)")
503537
: isDark
504-
? `rgba(255,255,255,${Math.max(0.08, opacity * 0.55)})`
505-
: `rgba(0,0,0,${Math.max(0.06, opacity * 0.45)})`
538+
? "rgba(255,255,255,0.5)"
539+
: "rgba(0,0,0,0.4)"
506540

507541
/* Hover: bar grows wider + thicker */
508542
const hoverWidthBoost = isHovered ? 10 : 0
@@ -527,7 +561,7 @@ export const SessionFloatingTOC = React.memo(function SessionFloatingTOC(props:
527561
borderRadius: 2,
528562
background: barColor,
529563
boxShadow: barShadow,
530-
transform: `translateY(${yFromCenter - TOC_BAR_HEIGHT / 2}px) rotateX(${rotateDeg}deg)`,
564+
transform: `translateY(${yFromCenter - TOC_BAR_HEIGHT / 2}px)`,
531565
transition: "transform 0.4s cubic-bezier(0.23,1,0.32,1), opacity 0.35s, width 0.2s ease, height 0.2s ease, background 0.25s, box-shadow 0.25s",
532566
opacity,
533567
transformOrigin: "center center",

0 commit comments

Comments
 (0)