Skip to content

Commit 9d244ec

Browse files
committed
improve playground ux
1 parent 963fad5 commit 9d244ec

3 files changed

Lines changed: 79 additions & 7 deletions

File tree

playground/src/app/page.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
"use client";
22

3-
import { useCallback, useEffect, useRef, useState } from "react";
3+
import {
4+
useCallback,
5+
useEffect,
6+
useLayoutEffect,
7+
useRef,
8+
useState,
9+
} from "react";
410
import {
511
subscribeConnectionStatus,
612
type ConnectionStatus,
@@ -24,6 +30,11 @@ import packageJson from "../../package.json";
2430

2531
const VERSION_LABEL = `v${packageJson.version}`;
2632

33+
// Run the scroll-restore synchronously after the index re-mounts so the
34+
// previously-open row is centered before paint (no flash of scroll-top).
35+
const useIsoLayoutEffect =
36+
typeof window !== "undefined" ? useLayoutEffect : useEffect;
37+
2738
// Stable empty map so a view that does not own the current results re-renders
2839
// with an empty object rather than a fresh `{}` each render.
2940
const EMPTY_RESULTS: Record<string, TestEntry> = {};
@@ -136,6 +147,8 @@ function urlForSelection(selection: Selection): string {
136147
export default function PlaygroundPage() {
137148
const [status, setStatus] = useState<ConnectionStatus | null>(null);
138149
const [selection, setSelection] = useState<Selection>(null);
150+
// The last method viewed, kept highlighted in the index after "← INDEX".
151+
const [lastViewed, setLastViewed] = useState<Selection>(null);
139152
const [paletteOpen, setPaletteOpen] = useState(false);
140153
const [testResults, setTestResults] = useState<Record<string, TestEntry>>({});
141154
const [isTestRunning, setIsTestRunning] = useState(false);
@@ -145,6 +158,9 @@ export default function PlaygroundPage() {
145158
"autotest" | "diagnosis" | null
146159
>(null);
147160
const abortRef = useRef<AbortController | null>(null);
161+
// The method open when "← INDEX" was clicked, so the index can re-center on
162+
// it instead of jumping to the top.
163+
const pendingScrollRef = useRef<Selection>(null);
148164

149165
// Hydrate selection from the URL on mount and respond to back/forward.
150166
useEffect(() => {
@@ -190,6 +206,28 @@ export default function PlaygroundPage() {
190206
setPaletteOpen(false);
191207
}, []);
192208

209+
// Going back to the index: remember the open method so the index can scroll
210+
// it into the center of view rather than resetting to the top.
211+
const handleBack = useCallback(() => {
212+
pendingScrollRef.current = selection;
213+
setLastViewed(selection);
214+
setSelection(null);
215+
}, [selection]);
216+
217+
useIsoLayoutEffect(() => {
218+
if (selection !== null) return;
219+
const target = pendingScrollRef.current;
220+
pendingScrollRef.current = null;
221+
if (!target?.method) return;
222+
const el = document.querySelector(
223+
`[data-testid="method-${target.service}-${target.method}"]`,
224+
);
225+
if (el instanceof HTMLElement) {
226+
el.scrollIntoView({ block: "center" });
227+
el.focus({ preventScroll: true });
228+
}
229+
}, [selection]);
230+
193231
const handleRunTests = useCallback(
194232
async (mode: "all" | "safe") => {
195233
if (isTestRunning) return;
@@ -305,7 +343,7 @@ export default function PlaygroundPage() {
305343
</p>
306344
<ServiceTable
307345
services={services}
308-
activeMethod={selection}
346+
activeMethod={selection ?? lastViewed}
309347
testResults={testResults}
310348
onSelect={(s, m) => setSelection({ service: s, method: m })}
311349
/>
@@ -318,7 +356,7 @@ export default function PlaygroundPage() {
318356
isRunning={isTestRunning}
319357
onRun={handleRunDiagnosis}
320358
onStop={handleStopTests}
321-
onBack={() => setSelection(null)}
359+
onBack={handleBack}
322360
/>
323361
) : isAutoTest ? (
324362
<AutoTestView
@@ -328,13 +366,13 @@ export default function PlaygroundPage() {
328366
onRun={handleRunTests}
329367
onStop={handleStopTests}
330368
onRetry={handleRetryTest}
331-
onBack={() => setSelection(null)}
369+
onBack={handleBack}
332370
/>
333371
) : selection ? (
334372
<MethodView
335373
service={selection.service}
336374
method={selection.method}
337-
onBack={() => setSelection(null)}
375+
onBack={handleBack}
338376
/>
339377
) : (
340378
<div className="empty-state">

playground/src/components/ExampleEditor.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,21 @@
33
import dynamic from "next/dynamic";
44
import { useEffect, useState } from "react";
55
import {
6+
configureLoader,
67
MONACO_THEME_DARK,
78
MONACO_THEME_LIGHT,
89
setupMonaco,
910
} from "@/src/lib/monaco-setup";
1011
import type { Monaco } from "@monaco-editor/react";
1112

13+
// Configure the loader to use the bundled monaco-editor before the Editor
14+
// component mounts and calls loader.init(), so it never falls back to the CDN.
1215
const Editor = dynamic(
13-
async () => (await import("@monaco-editor/react")).default,
16+
async () => {
17+
const mod = await import("@monaco-editor/react");
18+
await configureLoader();
19+
return mod.default;
20+
},
1421
{ ssr: false },
1522
);
1623

playground/src/lib/monaco-setup.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
11
import type { Monaco } from "@monaco-editor/react";
22
import { loader } from "@monaco-editor/react";
3+
import type { Environment } from "monaco-editor";
34
import { truapiDts } from "@parity/truapi/playground/codegen/truapi-dts";
45
import { rxjsFiles } from "./codegen/rxjs-dts";
56

67
export const MONACO_THEME_LIGHT = "truapi-light";
78
export const MONACO_THEME_DARK = "truapi-dark";
89

10+
// Bundle the web workers from the local `monaco-editor` package. Without this,
11+
// the editor falls back to the AMD loader's `require.toUrl`, which the ESM
12+
// build does not provide (TypeError: reading 'toUrl'). webpack emits each
13+
// `new Worker(new URL(...))` as its own chunk served from our own origin.
14+
function monacoWorker(label: string): Worker {
15+
if (label === "typescript" || label === "javascript") {
16+
return new Worker(
17+
new URL(
18+
"monaco-editor/esm/vs/language/typescript/ts.worker.js",
19+
import.meta.url,
20+
),
21+
);
22+
}
23+
return new Worker(
24+
new URL("monaco-editor/esm/vs/editor/editor.worker.js", import.meta.url),
25+
);
26+
}
27+
928
let loaderConfigured = false;
10-
async function configureLoader(): Promise<void> {
29+
/**
30+
* Point `@monaco-editor/react`'s loader at the bundled `monaco-editor` package
31+
* instead of its default jsdelivr CDN. Must run before the Editor mounts and
32+
* calls `loader.init()`, otherwise the loader falls back to the CDN.
33+
*/
34+
export async function configureLoader(): Promise<void> {
1135
if (loaderConfigured) return;
1236
loaderConfigured = true;
37+
(
38+
globalThis as typeof globalThis & { MonacoEnvironment?: Environment }
39+
).MonacoEnvironment = { getWorker: (_id, label) => monacoWorker(label) };
1340
// Lazy-import monaco-editor on the client only. Importing it eagerly at
1441
// module scope crashes Next's SSR prerender (`window is not defined`).
1542
const monaco = await import("monaco-editor");

0 commit comments

Comments
 (0)