tangled
alpha
login
or
join now
danabra.mov
/
rscexplorer
37
fork
atom
A tool for people curious about the React Server Components protocol
rscexplorer.dev/
rsc
react
37
fork
atom
overview
issues
pulls
pipelines
improve ux
danabra.mov
2 months ago
43aa9736
1e9eaa1a
+312
-130
13 changed files
expand all
collapse all
unified
split
src
client
embed.tsx
index.tsx
ui
App.css
App.tsx
CodeEditor.css
CodeEditor.tsx
EmbedApp.tsx
FlightLog.tsx
LivePreview.tsx
Workspace.tsx
codemirror.ts
embed.ts
vite.config.js
+1
-1
src/client/embed.tsx
···
5
import { EmbedApp } from "./ui/EmbedApp.tsx";
6
7
const container = document.getElementById("embed-root")!;
8
-
const root = createRoot(container);
9
root.render(<EmbedApp />);
···
5
import { EmbedApp } from "./ui/EmbedApp.tsx";
6
7
const container = document.getElementById("embed-root")!;
8
+
const root = createRoot(container!);
9
root.render(<EmbedApp />);
+3
-8
src/client/index.tsx
···
4
import { createRoot } from "react-dom/client";
5
import { App } from "./ui/App.tsx";
6
7
-
document.addEventListener("DOMContentLoaded", () => {
8
-
const container = document.getElementById("app");
9
-
if (!container) {
10
-
throw new Error("Could not find #app element");
11
-
}
12
-
const root = createRoot(container);
13
-
root.render(<App />);
14
-
});
···
4
import { createRoot } from "react-dom/client";
5
import { App } from "./ui/App.tsx";
6
7
+
const container = document.getElementById("app");
8
+
const root = createRoot(container!);
9
+
root.render(<App />);
0
0
0
0
0
+5
src/client/ui/App.css
···
197
border-color: #555;
198
}
199
0
0
0
0
0
200
.App-embedModal-tabs {
201
display: inline-flex;
202
background: var(--bg);
···
197
border-color: #555;
198
}
199
200
+
.App-embedModal-textarea::selection {
201
+
background: rgba(255, 213, 79, 0.3);
202
+
color: var(--text-bright);
203
+
}
204
+
205
.App-embedModal-tabs {
206
display: inline-flex;
207
background: var(--bg);
+39
-22
src/client/ui/App.tsx
···
1
-
import React, { useState, useEffect, type ChangeEvent, type MouseEvent } from "react";
2
import { version } from "react";
3
import { SAMPLES, type Sample } from "../samples.ts";
4
import REACT_VERSIONS from "../../../scripts/versions.json";
···
188
export function App(): React.ReactElement {
189
const [initialCode] = useState(getInitialCode);
190
const [currentSample, setCurrentSample] = useState<string | null>(initialCode.sampleKey);
191
-
const [workspaceCode, setWorkspaceCode] = useState<CodeState>({
192
server: initialCode.server,
193
client: initialCode.client,
194
});
195
-
const [liveCode, setLiveCode] = useState<CodeState>(workspaceCode);
196
const [showEmbedModal, setShowEmbedModal] = useState(false);
0
197
198
-
// Listen for code changes from the embed iframe
0
0
0
199
useEffect(() => {
200
const handleMessage = (event: MessageEvent): void => {
201
const data = event.data as { type?: string; code?: CodeState };
···
203
setLiveCode(data.code);
204
}
205
};
206
-
207
window.addEventListener("message", handleMessage);
208
return () => window.removeEventListener("message", handleMessage);
209
}, []);
210
211
-
// Reset liveCode when workspaceCode changes (e.g., sample switch)
212
useEffect(() => {
213
-
setLiveCode(workspaceCode);
214
-
}, [workspaceCode]);
215
-
216
-
const embedUrl = `embed.html?c=${encodeURIComponent(encodeCode(workspaceCode))}`;
217
-
218
-
const handleSave = (): void => {
219
-
saveToUrl(liveCode.server, liveCode.client);
220
-
setCurrentSample(null);
221
-
};
222
-
223
-
const isDirty = currentSample
224
-
? liveCode.server !== (SAMPLES[currentSample] as Sample).server ||
225
-
liveCode.client !== (SAMPLES[currentSample] as Sample).client
226
-
: liveCode.server !== initialCode.server || liveCode.client !== initialCode.client;
227
228
const handleSampleChange = (e: ChangeEvent<HTMLSelectElement>): void => {
229
const key = e.target.value;
···
233
server: sample.server,
234
client: sample.client,
235
};
236
-
setWorkspaceCode(newCode);
237
setCurrentSample(key);
0
0
0
0
0
238
const url = new URL(window.location.href);
239
url.searchParams.delete("c");
240
url.searchParams.set("s", key);
241
window.history.pushState({}, "", url);
242
}
243
};
0
0
0
0
0
0
0
0
0
0
244
245
return (
246
<>
···
322
</div>
323
<BuildSwitcher />
324
</header>
325
-
<iframe key={embedUrl} src={embedUrl} style={{ flex: 1, border: "none", width: "100%" }} />
0
0
0
0
326
{showEmbedModal && <EmbedModal code={liveCode} onClose={() => setShowEmbedModal(false)} />}
327
</>
328
);
···
1
+
import React, { useState, useEffect, useRef, type ChangeEvent, type MouseEvent } from "react";
2
import { version } from "react";
3
import { SAMPLES, type Sample } from "../samples.ts";
4
import REACT_VERSIONS from "../../../scripts/versions.json";
···
188
export function App(): React.ReactElement {
189
const [initialCode] = useState(getInitialCode);
190
const [currentSample, setCurrentSample] = useState<string | null>(initialCode.sampleKey);
191
+
const [liveCode, setLiveCode] = useState<CodeState>({
192
server: initialCode.server,
193
client: initialCode.client,
194
});
0
195
const [showEmbedModal, setShowEmbedModal] = useState(false);
196
+
const iframeRef = useRef<HTMLIFrameElement>(null);
197
198
+
const [initialEmbedUrl] = useState(
199
+
() => `embed.html?seamless=1&c=${encodeURIComponent(encodeCode(initialCode))}`,
200
+
);
201
+
202
useEffect(() => {
203
const handleMessage = (event: MessageEvent): void => {
204
const data = event.data as { type?: string; code?: CodeState };
···
206
setLiveCode(data.code);
207
}
208
};
0
209
window.addEventListener("message", handleMessage);
210
return () => window.removeEventListener("message", handleMessage);
211
}, []);
212
0
213
useEffect(() => {
214
+
const handlePopState = (): void => {
215
+
const newCode = getInitialCode();
216
+
setLiveCode({ server: newCode.server, client: newCode.client });
217
+
setCurrentSample(newCode.sampleKey);
218
+
iframeRef.current?.contentWindow?.postMessage(
219
+
{ type: "rscexplorer:reset", code: { server: newCode.server, client: newCode.client } },
220
+
"*",
221
+
);
222
+
};
223
+
window.addEventListener("popstate", handlePopState);
224
+
return () => window.removeEventListener("popstate", handlePopState);
225
+
}, []);
0
0
226
227
const handleSampleChange = (e: ChangeEvent<HTMLSelectElement>): void => {
228
const key = e.target.value;
···
232
server: sample.server,
233
client: sample.client,
234
};
0
235
setCurrentSample(key);
236
+
setLiveCode(newCode);
237
+
iframeRef.current?.contentWindow?.postMessage(
238
+
{ type: "rscexplorer:reset", code: newCode },
239
+
"*",
240
+
);
241
const url = new URL(window.location.href);
242
url.searchParams.delete("c");
243
url.searchParams.set("s", key);
244
window.history.pushState({}, "", url);
245
}
246
};
247
+
248
+
const handleSave = (): void => {
249
+
saveToUrl(liveCode.server, liveCode.client);
250
+
setCurrentSample(null);
251
+
};
252
+
253
+
const isDirty = currentSample
254
+
? liveCode.server !== (SAMPLES[currentSample] as Sample).server ||
255
+
liveCode.client !== (SAMPLES[currentSample] as Sample).client
256
+
: liveCode.server !== initialCode.server || liveCode.client !== initialCode.client;
257
258
return (
259
<>
···
335
</div>
336
<BuildSwitcher />
337
</header>
338
+
<iframe
339
+
ref={iframeRef}
340
+
src={initialEmbedUrl}
341
+
style={{ flex: 1, border: "none", width: "100%" }}
342
+
/>
343
{showEmbedModal && <EmbedModal code={liveCode} onClose={() => setShowEmbedModal(false)} />}
344
</>
345
);
+28
src/client/ui/CodeEditor.css
···
21
.CodeEditor .cm-editor .cm-scroller {
22
overflow: auto !important;
23
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
21
.CodeEditor .cm-editor .cm-scroller {
22
overflow: auto !important;
23
}
24
+
25
+
/* Fallback textarea styled to match CodeMirror */
26
+
.CodeEditor-fallback {
27
+
position: absolute;
28
+
top: 0;
29
+
left: 0;
30
+
right: 0;
31
+
bottom: 0;
32
+
width: 100%;
33
+
height: 100%;
34
+
padding: 16px 18px;
35
+
margin: 0;
36
+
border: none;
37
+
outline: none;
38
+
resize: none;
39
+
background: transparent;
40
+
color: #b8b8b8;
41
+
font-family: "SF Mono", "Fira Code", Menlo, monospace;
42
+
font-size: 13px;
43
+
line-height: 1.6;
44
+
caret-color: #79b8ff;
45
+
white-space: pre;
46
+
overflow: auto;
47
+
tab-size: 4;
48
+
-moz-tab-size: 4;
49
+
word-wrap: normal;
50
+
overflow-wrap: normal;
51
+
}
+42
-60
src/client/ui/CodeEditor.tsx
···
1
-
import React, { useRef, useEffect, useEffectEvent, useState } from "react";
2
-
import { EditorView, keymap } from "@codemirror/view";
3
-
import { javascript } from "@codemirror/lang-javascript";
4
-
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
5
-
import { tags } from "@lezer/highlight";
6
-
import { history, historyKeymap, defaultKeymap } from "@codemirror/commands";
7
-
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
8
import { Pane } from "./Pane.tsx";
9
import "./CodeEditor.css";
10
11
-
const highlightStyle = HighlightStyle.define([
12
-
{ tag: tags.keyword, color: "#c678dd" },
13
-
{ tag: tags.string, color: "#98c379" },
14
-
{ tag: tags.number, color: "#d19a66" },
15
-
{ tag: tags.comment, color: "#5c6370", fontStyle: "italic" },
16
-
{ tag: tags.function(tags.variableName), color: "#61afef" },
17
-
{ tag: tags.typeName, color: "#e5c07b" },
18
-
{ tag: [tags.tagName, tags.angleBracket], color: "#e06c75" },
19
-
{ tag: tags.attributeName, color: "#d19a66" },
20
-
{ tag: tags.propertyName, color: "#abb2bf" },
21
-
]);
22
-
23
-
const minimalTheme = EditorView.theme(
24
-
{
25
-
"&": { height: "100%", fontSize: "13px", backgroundColor: "transparent" },
26
-
".cm-scroller": {
27
-
overflow: "auto",
28
-
fontFamily: "'SF Mono', 'Fira Code', Menlo, monospace",
29
-
lineHeight: "1.6",
30
-
padding: "12px",
31
-
},
32
-
".cm-content": { caretColor: "#79b8ff" },
33
-
".cm-cursor": { borderLeftColor: "#79b8ff", borderLeftWidth: "2px" },
34
-
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
35
-
backgroundColor: "#3a3a3a",
36
-
},
37
-
".cm-activeLine": { backgroundColor: "transparent" },
38
-
".cm-gutters": { display: "none" },
39
-
".cm-line": { color: "#b8b8b8" },
40
-
},
41
-
{ dark: true },
42
-
);
43
44
type CodeEditorProps = {
45
defaultValue: string;
···
49
50
export function CodeEditor({ defaultValue, onChange, label }: CodeEditorProps): React.ReactElement {
51
const [initialDefaultValue] = useState(defaultValue);
0
52
const containerRef = useRef<HTMLDivElement>(null);
0
53
54
const onEditorChange = useEffectEvent((doc: string) => {
55
onChange(doc);
56
});
57
58
-
useEffect(() => {
59
-
if (!containerRef.current) return;
0
60
61
-
const onChangeExtension = EditorView.updateListener.of((update) => {
62
-
if (update.docChanged) {
63
-
onEditorChange(update.state.doc.toString());
0
0
0
0
0
64
}
65
-
});
66
67
-
const editor = new EditorView({
68
-
doc: initialDefaultValue,
69
-
extensions: [
70
-
minimalTheme,
71
-
syntaxHighlighting(highlightStyle),
72
-
javascript({ jsx: true }),
73
-
history(),
74
-
closeBrackets(),
75
-
keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
76
-
onChangeExtension,
77
-
],
78
-
parent: containerRef.current,
79
-
});
80
81
-
return () => editor.destroy();
0
0
0
82
}, [initialDefaultValue]);
83
84
return (
85
<Pane label={label}>
86
-
<div className="CodeEditor" ref={containerRef} />
0
0
0
0
0
0
0
0
0
0
0
0
87
</Pane>
88
);
89
}
···
1
+
import React, { useRef, useLayoutEffect, useEffectEvent, useState } from "react";
0
0
0
0
0
0
2
import { Pane } from "./Pane.tsx";
3
import "./CodeEditor.css";
4
5
+
let cmModule: typeof import("./codemirror.ts") | null = null;
6
+
const cmModulePromise = import("./codemirror.ts").then((mod) => {
7
+
cmModule = mod;
8
+
return mod;
9
+
});
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
10
11
type CodeEditorProps = {
12
defaultValue: string;
···
16
17
export function CodeEditor({ defaultValue, onChange, label }: CodeEditorProps): React.ReactElement {
18
const [initialDefaultValue] = useState(defaultValue);
19
+
const [cmLoaded, setCmLoaded] = useState(cmModule !== null);
20
const containerRef = useRef<HTMLDivElement>(null);
21
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
22
23
const onEditorChange = useEffectEvent((doc: string) => {
24
onChange(doc);
25
});
26
27
+
useLayoutEffect(() => {
28
+
let editorHandle: { destroy: () => void } | null = null;
29
+
let destroyed = false;
30
31
+
function initEditor(cm: typeof import("./codemirror.ts")) {
32
+
if (!destroyed) {
33
+
editorHandle = cm.createEditor(
34
+
containerRef.current!,
35
+
textareaRef.current?.value ?? initialDefaultValue,
36
+
onEditorChange,
37
+
);
38
+
setCmLoaded(true);
39
}
40
+
}
41
42
+
if (cmModule) {
43
+
initEditor(cmModule);
44
+
} else {
45
+
cmModulePromise.then(initEditor);
46
+
}
0
0
0
0
0
0
0
0
47
48
+
return () => {
49
+
destroyed = true;
50
+
editorHandle?.destroy();
51
+
};
52
}, [initialDefaultValue]);
53
54
return (
55
<Pane label={label}>
56
+
<div className="CodeEditor" ref={containerRef}>
57
+
{!cmLoaded && (
58
+
<textarea
59
+
ref={textareaRef}
60
+
className="CodeEditor-fallback"
61
+
defaultValue={initialDefaultValue}
62
+
onChange={(e) => onChange(e.target.value)}
63
+
spellCheck={false}
64
+
autoCapitalize="off"
65
+
autoCorrect="off"
66
+
/>
67
+
)}
68
+
</div>
69
</Pane>
70
);
71
}
+63
-31
src/client/ui/EmbedApp.tsx
···
1
-
import React from "react";
2
import { SAMPLES } from "../samples.ts";
3
import { Workspace } from "./Workspace.tsx";
4
import "./EmbedApp.css";
···
10
client: string;
11
};
12
13
-
function getCodeFromUrl(): CodeState {
14
const params = new URLSearchParams(window.location.search);
15
const encoded = params.get("c");
0
16
0
17
if (encoded) {
18
try {
19
const json = decodeURIComponent(escape(atob(encoded)));
20
const parsed = JSON.parse(json) as { server?: string; client?: string };
21
-
return {
22
server: (parsed.server ?? DEFAULT_SAMPLE.server).trim(),
23
client: (parsed.client ?? DEFAULT_SAMPLE.client).trim(),
24
};
25
} catch {
26
-
// Fall through to defaults
0
0
0
27
}
0
0
0
0
0
28
}
29
30
-
return {
31
-
server: DEFAULT_SAMPLE.server,
32
-
client: DEFAULT_SAMPLE.client,
33
-
};
34
}
35
36
function getFullscreenUrl(code: CodeState): string {
···
40
}
41
42
export function EmbedApp(): React.ReactElement {
43
-
const initialCode = getCodeFromUrl();
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
44
45
const handleCodeChange = (server: string, client: string): void => {
46
if (window.parent !== window) {
···
56
57
return (
58
<>
59
-
<div className="EmbedApp-header">
60
-
<span className="EmbedApp-title">RSC Explorer</span>
61
-
<a
62
-
href={getFullscreenUrl(initialCode)}
63
-
target="_blank"
64
-
rel="noopener noreferrer"
65
-
className="EmbedApp-fullscreenLink"
66
-
title="Open in RSC Explorer"
67
-
>
68
-
<svg
69
-
width="14"
70
-
height="14"
71
-
viewBox="0 0 24 24"
72
-
fill="none"
73
-
stroke="currentColor"
74
-
strokeWidth="2"
75
>
76
-
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
77
-
</svg>
78
-
</a>
79
-
</div>
0
0
0
0
0
0
0
0
0
80
<Workspace
81
-
initialServerCode={initialCode.server}
82
-
initialClientCode={initialCode.client}
0
83
onCodeChange={handleCodeChange}
84
/>
85
</>
···
1
+
import React, { useState, useEffect } from "react";
2
import { SAMPLES } from "../samples.ts";
3
import { Workspace } from "./Workspace.tsx";
4
import "./EmbedApp.css";
···
10
client: string;
11
};
12
13
+
function getParamsFromUrl(): { code: CodeState; seamless: boolean } {
14
const params = new URLSearchParams(window.location.search);
15
const encoded = params.get("c");
16
+
const seamless = params.get("seamless") === "1";
17
18
+
let code: CodeState;
19
if (encoded) {
20
try {
21
const json = decodeURIComponent(escape(atob(encoded)));
22
const parsed = JSON.parse(json) as { server?: string; client?: string };
23
+
code = {
24
server: (parsed.server ?? DEFAULT_SAMPLE.server).trim(),
25
client: (parsed.client ?? DEFAULT_SAMPLE.client).trim(),
26
};
27
} catch {
28
+
code = {
29
+
server: DEFAULT_SAMPLE.server,
30
+
client: DEFAULT_SAMPLE.client,
31
+
};
32
}
33
+
} else {
34
+
code = {
35
+
server: DEFAULT_SAMPLE.server,
36
+
client: DEFAULT_SAMPLE.client,
37
+
};
38
}
39
40
+
return { code, seamless };
0
0
0
41
}
42
43
function getFullscreenUrl(code: CodeState): string {
···
47
}
48
49
export function EmbedApp(): React.ReactElement {
50
+
const [params] = useState(getParamsFromUrl);
51
+
const [code, setCode] = useState(params.code);
52
+
const seamless = params.seamless;
53
+
54
+
// Listen for code updates from parent via postMessage
55
+
useEffect(() => {
56
+
const handleMessage = (event: MessageEvent): void => {
57
+
const data = event.data as { type?: string; code?: CodeState };
58
+
if (data?.type === "rscexplorer:reset" && data.code) {
59
+
const newServer = data.code.server.trim();
60
+
const newClient = data.code.client.trim();
61
+
setCode((prev) => {
62
+
if (prev.server === newServer && prev.client === newClient) {
63
+
return prev;
64
+
}
65
+
return { server: newServer, client: newClient };
66
+
});
67
+
}
68
+
};
69
+
70
+
window.addEventListener("message", handleMessage);
71
+
return () => window.removeEventListener("message", handleMessage);
72
+
}, []);
73
74
const handleCodeChange = (server: string, client: string): void => {
75
if (window.parent !== window) {
···
85
86
return (
87
<>
88
+
{!seamless && (
89
+
<div className="EmbedApp-header">
90
+
<span className="EmbedApp-title">RSC Explorer</span>
91
+
<a
92
+
href={getFullscreenUrl(code)}
93
+
target="_blank"
94
+
rel="noopener noreferrer"
95
+
className="EmbedApp-fullscreenLink"
96
+
title="Open in RSC Explorer"
0
0
0
0
0
0
0
97
>
98
+
<svg
99
+
width="14"
100
+
height="14"
101
+
viewBox="0 0 24 24"
102
+
fill="none"
103
+
stroke="currentColor"
104
+
strokeWidth="2"
105
+
>
106
+
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
107
+
</svg>
108
+
</a>
109
+
</div>
110
+
)}
111
<Workspace
112
+
key={`${code.server}:${code.client}`}
113
+
initialServerCode={code.server}
114
+
initialClientCode={code.client}
115
onCodeChange={handleCodeChange}
116
/>
117
</>
+1
-1
src/client/ui/FlightLog.tsx
···
145
if (entries.length === 0) {
146
return (
147
<div className="FlightLog-output">
148
-
<span className="FlightLog-empty FlightLog-empty--waiting">Compiling</span>
149
</div>
150
);
151
}
···
145
if (entries.length === 0) {
146
return (
147
<div className="FlightLog-output">
148
+
<span className="FlightLog-empty FlightLog-empty--waiting">Loading</span>
149
</div>
150
);
151
}
+2
-2
src/client/ui/LivePreview.tsx
···
102
103
let statusText = "";
104
if (isLoading) {
105
-
statusText = "Compiling";
106
} else if (isAtStart) {
107
statusText = "Ready";
108
} else if (isAtEnd) {
···
182
</div>
183
<div className="LivePreview-container">
184
{isLoading ? (
185
-
<span className="LivePreview-empty">Compiling...</span>
186
) : showPlaceholder ? (
187
<span className="LivePreview-empty">{isAtStart ? "Step to begin..." : "Loading..."}</span>
188
) : flightPromise ? (
···
102
103
let statusText = "";
104
if (isLoading) {
105
+
statusText = "Loading";
106
} else if (isAtStart) {
107
statusText = "Ready";
108
} else if (isAtEnd) {
···
182
</div>
183
<div className="LivePreview-container">
184
{isLoading ? (
185
+
<span className="LivePreview-empty">Loading...</span>
186
) : showPlaceholder ? (
187
<span className="LivePreview-empty">{isAtStart ? "Step to begin..." : "Loading..."}</span>
188
) : flightPromise ? (
+9
-2
src/client/ui/Workspace.tsx
···
24
25
useEffect(() => {
26
const abort = new AbortController();
0
0
0
27
WorkspaceSession.create(serverCode, clientCode, abort.signal).then((nextSession) => {
28
if (!abort.signal.aborted) {
0
29
setSession(nextSession);
30
}
31
});
32
-
return () => abort.abort();
0
0
0
33
}, [serverCode, clientCode, resetKey]);
34
35
function handleServerChange(code: string) {
···
68
{isLoading ? (
69
<div className="Workspace-loadingOutput">
70
<span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting">
71
-
Compiling
72
</span>
73
</div>
74
) : isError ? (
···
24
25
useEffect(() => {
26
const abort = new AbortController();
27
+
const timeoutId = setTimeout(() => {
28
+
setSession(null);
29
+
}, 1000);
30
WorkspaceSession.create(serverCode, clientCode, abort.signal).then((nextSession) => {
31
if (!abort.signal.aborted) {
32
+
clearTimeout(timeoutId);
33
setSession(nextSession);
34
}
35
});
36
+
return () => {
37
+
clearTimeout(timeoutId);
38
+
abort.abort();
39
+
};
40
}, [serverCode, clientCode, resetKey]);
41
42
function handleServerChange(code: string) {
···
75
{isLoading ? (
76
<div className="Workspace-loadingOutput">
77
<span className="Workspace-loadingEmpty Workspace-loadingEmpty--waiting">
78
+
Loading
79
</span>
80
</div>
81
) : isError ? (
+75
src/client/ui/codemirror.ts
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
import { EditorView, keymap } from "@codemirror/view";
2
+
import { javascript } from "@codemirror/lang-javascript";
3
+
import { syntaxHighlighting, HighlightStyle } from "@codemirror/language";
4
+
import { tags } from "@lezer/highlight";
5
+
import { history, historyKeymap, defaultKeymap } from "@codemirror/commands";
6
+
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
7
+
8
+
const highlightStyle = HighlightStyle.define([
9
+
{ tag: tags.keyword, color: "#c678dd" },
10
+
{ tag: tags.string, color: "#98c379" },
11
+
{ tag: tags.number, color: "#d19a66" },
12
+
{ tag: tags.comment, color: "#5c6370", fontStyle: "italic" },
13
+
{ tag: tags.function(tags.variableName), color: "#61afef" },
14
+
{ tag: tags.typeName, color: "#e5c07b" },
15
+
{ tag: [tags.tagName, tags.angleBracket], color: "#e06c75" },
16
+
{ tag: tags.attributeName, color: "#d19a66" },
17
+
{ tag: tags.propertyName, color: "#abb2bf" },
18
+
]);
19
+
20
+
const minimalTheme = EditorView.theme(
21
+
{
22
+
"&": { height: "100%", fontSize: "13px", backgroundColor: "transparent" },
23
+
".cm-scroller": {
24
+
overflow: "auto",
25
+
fontFamily: "'SF Mono', 'Fira Code', Menlo, monospace",
26
+
lineHeight: "1.6",
27
+
padding: "12px",
28
+
},
29
+
".cm-content": { caretColor: "#79b8ff" },
30
+
".cm-cursor": { borderLeftColor: "#79b8ff", borderLeftWidth: "2px" },
31
+
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
32
+
backgroundColor: "#3a3a3a",
33
+
},
34
+
".cm-activeLine": { backgroundColor: "transparent" },
35
+
".cm-gutters": { display: "none" },
36
+
".cm-line": { color: "#b8b8b8" },
37
+
},
38
+
{ dark: true },
39
+
);
40
+
41
+
export type EditorHandle = {
42
+
view: EditorView;
43
+
destroy: () => void;
44
+
};
45
+
46
+
export function createEditor(
47
+
container: HTMLElement,
48
+
doc: string,
49
+
onChange: (doc: string) => void,
50
+
): EditorHandle {
51
+
const onChangeExtension = EditorView.updateListener.of((update) => {
52
+
if (update.docChanged) {
53
+
onChange(update.state.doc.toString());
54
+
}
55
+
});
56
+
57
+
const view = new EditorView({
58
+
doc,
59
+
extensions: [
60
+
minimalTheme,
61
+
syntaxHighlighting(highlightStyle),
62
+
javascript({ jsx: true }),
63
+
history(),
64
+
closeBrackets(),
65
+
keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
66
+
onChangeExtension,
67
+
],
68
+
parent: container,
69
+
});
70
+
71
+
return {
72
+
view,
73
+
destroy: () => view.destroy(),
74
+
};
75
+
}
+8
-2
src/embed.ts
···
27
type EmbedOptions = {
28
server: string;
29
client: string;
0
30
};
31
32
type EmbedControl = {
···
66
*/
67
export function mount(
68
container: string | HTMLElement,
69
-
{ server, client }: EmbedOptions,
70
): EmbedControl {
71
const el =
72
typeof container === "string" ? document.querySelector<HTMLElement>(container) : container;
···
75
throw new Error(`RSC Explorer: Container not found: ${container}`);
76
}
77
0
0
0
0
0
78
const iframe = document.createElement("iframe");
79
-
iframe.src = getEmbedUrl();
80
iframe.style.cssText =
81
"width: 100%; height: 100%; border: 1px solid #e0e0e0; border-radius: 8px;";
82
···
27
type EmbedOptions = {
28
server: string;
29
client: string;
30
+
seamless?: boolean;
31
};
32
33
type EmbedControl = {
···
67
*/
68
export function mount(
69
container: string | HTMLElement,
70
+
{ server, client, seamless }: EmbedOptions,
71
): EmbedControl {
72
const el =
73
typeof container === "string" ? document.querySelector<HTMLElement>(container) : container;
···
76
throw new Error(`RSC Explorer: Container not found: ${container}`);
77
}
78
79
+
const embedUrl = new URL(getEmbedUrl());
80
+
if (seamless) {
81
+
embedUrl.searchParams.set("seamless", "1");
82
+
}
83
+
84
const iframe = document.createElement("iframe");
85
+
iframe.src = embedUrl.href;
86
iframe.style.cssText =
87
"width: 100%; height: 100%; border: 1px solid #e0e0e0; border-radius: 8px;";
88
+36
-1
vite.config.js
···
68
};
69
}
70
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
71
export default defineConfig(({ mode }) => ({
72
-
plugins: [react(), rolldownWorkerPlugin(), serveEmbedPlugin()],
73
server: { port: 3333 },
74
define: {
75
"process.env.NODE_ENV": JSON.stringify(mode === "development" ? "development" : "production"),
···
68
};
69
}
70
71
+
function preloadCodemirrorPlugin() {
72
+
return {
73
+
name: "preload-codemirror",
74
+
transformIndexHtml(html, { bundle, filename }) {
75
+
if (!bundle) return; // dev mode
76
+
const tags = [];
77
+
78
+
const cmChunk = Object.keys(bundle).find((k) => k.includes("codemirror"));
79
+
if (cmChunk) {
80
+
tags.push({
81
+
tag: "link",
82
+
attrs: { rel: "modulepreload", href: "/" + cmChunk },
83
+
injectTo: "head",
84
+
});
85
+
}
86
+
87
+
// From index.html, prefetch embed resources for the iframe
88
+
if (filename.endsWith("index.html")) {
89
+
const embedChunk = Object.keys(bundle).find(
90
+
(k) => k.startsWith("assets/embed") && k.endsWith(".js"),
91
+
);
92
+
if (embedChunk) {
93
+
tags.push({
94
+
tag: "link",
95
+
attrs: { rel: "preload", href: "/" + embedChunk, as: "script", crossorigin: true },
96
+
injectTo: "head",
97
+
});
98
+
}
99
+
}
100
+
101
+
return tags;
102
+
},
103
+
};
104
+
}
105
+
106
export default defineConfig(({ mode }) => ({
107
+
plugins: [react(), rolldownWorkerPlugin(), serveEmbedPlugin(), preloadCodemirrorPlugin()],
108
server: { port: 3333 },
109
define: {
110
"process.env.NODE_ENV": JSON.stringify(mode === "development" ? "development" : "production"),