From d13644ebc4ba2d36894849881ebee8bd2154c1d0 Mon Sep 17 00:00:00 2001 From: Duncan Mackenzie Date: Mon, 10 Aug 2026 16:26:41 -0700 Subject: [PATCH] Make the Event History code walkthrough zoomable, keep highlighted lines in view Adds a click-to-expand overlay to WorkflowWalkthrough (used by all four Event History demos across every SDK language page), matching the site's existing image/Mermaid zoom pattern but keeping the walkthrough fully interactive while zoomed instead of showing a static snapshot. Also: - Fixes the code panel clipping long lines instead of letting them scroll horizontally. - Scrolls the highlighted line(s) into view automatically when the step changes, in both the inline and zoomed views. --- .../WorkflowWalkthrough.js | 153 +++++++++++++++++- .../workflow-walkthrough.module.css | 86 ++++++++++ 2 files changed, 236 insertions(+), 3 deletions(-) diff --git a/src/components/Demos/EventHistoryWalkthrough/WorkflowWalkthrough.js b/src/components/Demos/EventHistoryWalkthrough/WorkflowWalkthrough.js index 73fcf7b1ae..ba5e9eac41 100644 --- a/src/components/Demos/EventHistoryWalkthrough/WorkflowWalkthrough.js +++ b/src/components/Demos/EventHistoryWalkthrough/WorkflowWalkthrough.js @@ -1,6 +1,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { usePrismTheme } from '@docusaurus/theme-common'; import { Highlight } from 'prism-react-renderer'; +import { FaExpand } from 'react-icons/fa'; import Prism from './prism-languages'; import styles from './workflow-walkthrough.module.css'; @@ -44,9 +46,46 @@ function CodePanel({ code, lines, language }) { const prismTheme = usePrismTheme(); const active = useMemo(() => new Set(lines ?? []), [lines]); const hasActive = active.size > 0; + const wrapRef = useRef(null); + + useEffect(() => { + const container = wrapRef.current; + if (!container) return; + const activeEls = container.querySelectorAll('[data-active="true"]'); + if (activeEls.length === 0) return; + + // A step's active lines are usually a fresh statement, not a continuation + // of whatever a previous long line scrolled to — start back at the left. + container.scrollLeft = 0; + + const first = activeEls[0]; + const last = activeEls[activeEls.length - 1]; + const viewTop = container.scrollTop; + const viewHeight = container.clientHeight; + // `offsetTop` is relative to the nearest *positioned* ancestor, which + // isn't necessarily this container (.demo needs `position: relative` for + // the zoom button, which makes it the offsetParent instead) — measure + // against the container's own box instead so this can't silently drift. + const containerTop = container.getBoundingClientRect().top; + const rangeTop = first.getBoundingClientRect().top - containerTop + viewTop; + const rangeBottom = last.getBoundingClientRect().bottom - containerTop + viewTop; + const padding = 12; + + let nextScrollTop = viewTop; + if (rangeBottom - rangeTop > viewHeight || rangeTop < viewTop) { + // Doesn't fit in one screen, or starts above the fold: lead with the + // first active line rather than whichever end happens to be reachable. + nextScrollTop = rangeTop - padding; + } else if (rangeBottom > viewTop + viewHeight) { + // Starts in view but runs past the bottom: pull its end into view. + nextScrollTop = rangeBottom - viewHeight + padding; + } + container.scrollTop = Math.max(0, nextScrollTop); + }, [lines]); return (
{ + initialFocusRef?.current?.focus(); + }, [initialFocusRef]); + + useEffect(() => { + const handleKey = (event) => { + if (event.key === 'Escape') onClose(); + }; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + document.addEventListener('keydown', handleKey); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', handleKey); + }; + }, [onClose]); + + return createPortal( +
+
event.stopPropagation()}> + + {children} +
+
, + document.body + ); +} + /** * Step-by-step walkthrough of a Workflow Definition: highlights the lines the * Worker is running and accumulates whatever the walkthrough tracks alongside @@ -235,6 +316,9 @@ function Controls({ steps, currentStep, onStep, onPrev, onNext }) { */ export default function WorkflowWalkthrough({ code, language, steps, columns = [], ariaLabel }) { const [currentStep, setCurrentStep] = useState(1); + const [isZoomed, setIsZoomed] = useState(false); + const zoomButtonRef = useRef(null); + const zoomedDemoRef = useRef(null); const step = steps[currentStep - 1]; const onKeyDown = (event) => { @@ -242,8 +326,28 @@ export default function WorkflowWalkthrough({ code, language, steps, columns = [ if (event.key === 'ArrowRight') setCurrentStep((s) => Math.min(steps.length, s + 1)); }; - return ( -
+ // Return focus to the trigger button on close, matching standard dialog + // behavior. (The reverse — focusing into the overlay on open — happens in + // ZoomOverlay itself, keyed off its own mount.) This has to be an effect + // rather than a plain call in the close handler: `setIsZoomed(false)` + // hasn't committed yet at that point, so the trigger button's ancestor is + // still `inert` and the focus() call would silently no-op. Skipping the + // first render keeps it from also firing (and stealing focus) on mount. + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + if (!isZoomed) { + zoomButtonRef.current?.focus(); + } + }, [isZoomed]); + + const closeZoom = () => setIsZoomed(false); + + const body = ( + <>
@@ -256,6 +360,49 @@ export default function WorkflowWalkthrough({ code, language, steps, columns = [ onPrev={() => setCurrentStep(Math.max(1, currentStep - 1))} onNext={() => setCurrentStep(Math.min(steps.length, currentStep + 1))} /> -
+ + ); + + return ( + <> + {/* `inert` while zoomed: without it this copy stays in the tab order, + hidden behind the overlay backdrop, so keyboard/screen-reader users + could still reach a widget they can't see. */} +
+ + {body} +
+ + {isZoomed && ( + +
+ {body} +
+
+ )} + ); } diff --git a/src/components/Demos/EventHistoryWalkthrough/workflow-walkthrough.module.css b/src/components/Demos/EventHistoryWalkthrough/workflow-walkthrough.module.css index 7687f60f63..fe69f4ed33 100644 --- a/src/components/Demos/EventHistoryWalkthrough/workflow-walkthrough.module.css +++ b/src/components/Demos/EventHistoryWalkthrough/workflow-walkthrough.module.css @@ -1,4 +1,5 @@ .demo { + position: relative; margin: 2rem 0; border: 1px solid var(--ifm-color-emphasis-300); background: var(--ifm-background-color); @@ -10,6 +11,85 @@ outline-offset: 2px; } +.zoomButton { + position: absolute; + top: 0.6rem; + right: 0.75rem; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 1.9rem; + height: 1.9rem; + padding: 0; + border: 1px solid var(--ifm-color-emphasis-300); + background: var(--ifm-background-surface-color); + color: var(--ifm-color-emphasis-700); + cursor: pointer; +} + +.zoomButton:hover, +.zoomButton:focus-visible { + color: var(--ifm-font-color-base); + border-color: var(--ifm-color-emphasis-400); +} + +.zoomOverlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 4vh 4vw; + background: rgba(0, 0, 0, 0.82); + backdrop-filter: blur(4px); + z-index: calc(var(--ifm-z-index-fixed) + 10); + animation: workflowZoomFadeIn 0.15s ease-out; +} + +.zoomCard { + width: min(95vw, 1400px); + max-height: 90vh; + overflow: auto; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); +} + +.zoomCard .demo { + margin: 0; +} + +.zoomClose { + position: fixed; + top: 16px; + right: 24px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + line-height: 1; + color: #fff; + background: rgba(0, 0, 0, 0.4); + border: none; + border-radius: 50%; + cursor: pointer; + transition: background 0.15s ease; +} + +.zoomClose:hover { + background: rgba(0, 0, 0, 0.7); +} + +@keyframes workflowZoomFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + .content { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); @@ -40,6 +120,12 @@ line-height: 1.6; color: inherit; background: transparent; + /* Without this,
 stretches to exactly fill .codeWrap (its containing
+     block) instead of sizing to its longest line, so no child box is ever
+     actually wider than .codeWrap — .codeWrap's `overflow: auto` then has
+     nothing to scroll, and long lines just clip at the edge instead. */
+  width: max-content;
+  min-width: 100%;
 }
 
 .line {