A runaway preview script (sync infinite loop, or a re-render loop outpacing the bootstrap's own coalescing) had nothing detecting it - the frame just spun. The bootstrap now heartbeats every second, and the parent tears the iframe down if it goes _WATCHDOG_MS silent, whatever the cause. _coerce_tool_calls recovers a tool call guessed from `content` for models with no native tool_calls field. That guess is weaker evidence than the API's own structured field - a model can land on JSON shaped like a call while only meaning to describe one - so an action tool recovered this way now always requires approval, even under the "allow" policy that lets a native tool_calls field run unattended.
647 lines
24 KiB
React
647 lines
24 KiB
React
import { useEffect, useRef, useState } from "react";
|
|
|
|
// Which languages get a live sandboxed preview (RenderBlock) instead of a plain
|
|
// syntax block (CodeBlock), and how each becomes a document body, lives in
|
|
// ./preview/languages.js. A language like `js` is deliberately absent —
|
|
// auto-executing bare script isn't this feature's job (see RenderBlock's doc
|
|
// comment for the sandboxing model).
|
|
import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js";
|
|
|
|
// Parse content into an array of {type, value, lang, streaming} blocks.
|
|
// Handles:
|
|
// - ```lang\ncode``` (well-formed)
|
|
// - ```code``` (no language, no newline — model formatting bug)
|
|
// - ```lang\ncode (unclosed — streaming in progress)
|
|
function parseBlocks(content) {
|
|
const blocks = [];
|
|
let i = 0;
|
|
|
|
while (i < content.length) {
|
|
const fenceStart = content.indexOf("```", i);
|
|
|
|
if (fenceStart === -1) {
|
|
if (i < content.length) blocks.push({ type: "text", value: content.slice(i) });
|
|
break;
|
|
}
|
|
|
|
if (fenceStart > i) blocks.push({ type: "text", value: content.slice(i, fenceStart) });
|
|
|
|
let j = fenceStart + 3;
|
|
let lang = "";
|
|
|
|
// Language specifier is valid only when word-chars are followed by a newline.
|
|
// If there's no newline (e.g. ```pythonprint(...)) treat everything as code.
|
|
const langMatch = content.slice(j).match(/^(\w+)(\r?\n)/);
|
|
if (langMatch) {
|
|
lang = langMatch[1];
|
|
j += langMatch[0].length;
|
|
} else if (/^\r?\n/.test(content.slice(j))) {
|
|
j += content[j] === "\r" ? 2 : 1;
|
|
}
|
|
// else: no newline — leave j as-is, lang stays ""
|
|
|
|
const closeIdx = content.indexOf("```", j);
|
|
if (closeIdx === -1) {
|
|
blocks.push({ type: "code", lang, value: content.slice(j), streaming: true });
|
|
i = content.length;
|
|
} else {
|
|
blocks.push({ type: "code", lang, value: content.slice(j, closeIdx).replace(/\n$/, ""), streaming: false });
|
|
i = closeIdx + 3;
|
|
}
|
|
}
|
|
|
|
return blocks;
|
|
}
|
|
|
|
export function Markdown({ content }) {
|
|
if (!content) return null;
|
|
const blocks = parseBlocks(content);
|
|
return (
|
|
<div style={{ lineHeight: "1.6" }}>
|
|
{blocks.map((block, i) => {
|
|
if (block.type !== "code") return <TextBlock key={i} text={block.value} />;
|
|
const lang = (block.lang || "").toLowerCase();
|
|
return RENDERABLE_LANGS.has(lang)
|
|
? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
|
|
: <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />;
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CodeBlock({ lang, value, streaming }) {
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const copy = () => {
|
|
navigator.clipboard.writeText(value.trimEnd()).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div style={{
|
|
background: "#0d0d0d",
|
|
border: "1px solid #2a2a2a",
|
|
borderRadius: "6px",
|
|
margin: "0.5rem 0",
|
|
overflow: "hidden",
|
|
}}>
|
|
<div style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: "0.3rem 0.75rem",
|
|
background: "#161616",
|
|
borderBottom: "1px solid #2a2a2a",
|
|
}}>
|
|
<span style={{ fontSize: "0.75rem", color: "#666", fontFamily: "monospace" }}>
|
|
{lang || "code"}
|
|
{streaming && <span style={{ color: "#444", marginLeft: "0.5rem" }}>●</span>}
|
|
</span>
|
|
{!streaming && (
|
|
<button onClick={copy} style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
color: copied ? "#4caf50" : "#555",
|
|
cursor: "pointer",
|
|
fontSize: "0.75rem",
|
|
padding: "0.1rem 0.3rem",
|
|
}}>
|
|
{copied ? "Copied!" : "Copy"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<pre style={{
|
|
padding: "0.75rem 1rem",
|
|
overflowX: "auto",
|
|
fontSize: "0.85rem",
|
|
lineHeight: "1.5",
|
|
margin: 0,
|
|
fontFamily: "monospace",
|
|
}}>
|
|
<code>{value.trimEnd()}</code>
|
|
</pre>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Content-Security-Policy for the rendered preview. Together with the iframe's
|
|
// `sandbox` attribute below, this is the entire trust boundary for model-
|
|
// authored HTML/SVG, so it stays conservative rather than convenient:
|
|
// - script-src/style-src 'unsafe-inline' inline <script>/<style> in the
|
|
// fence run (that's the whole point - charts, small interactive demos),
|
|
// but nothing else is allowed to load.
|
|
// - img-src/font-src data: embedded (base64) images/fonts
|
|
// work; remote https:// ones silently fail to load, on purpose.
|
|
// - connect-src 'none' no fetch/XHR/WebSocket out - a
|
|
// model-authored block can't phone home or probe the LAN.
|
|
// - default-src 'none' blanket deny for everything else
|
|
// (frames, media, workers, ...) not explicitly allowed above.
|
|
// - base-uri 'none' base-uri does NOT fall back to
|
|
// default-src, so it has to be named explicitly or a <base> tag would slip
|
|
// through the blanket deny above.
|
|
const _RENDER_CSP =
|
|
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
|
|
"img-src data:; font-src data:; connect-src 'none'; frame-src 'none'; " +
|
|
"form-action 'none'; base-uri 'none';";
|
|
|
|
// Injected ahead of the model's markup in every preview document, so it is
|
|
// installed before that markup's own scripts can throw. The literal
|
|
// `</script>` below is safe unescaped because this module is emitted as an
|
|
// external .js asset - it is never inlined into index.html, where the HTML
|
|
// parser would end the surrounding script tag early.
|
|
//
|
|
// postMessage is the one channel an opaque-origin sandboxed frame still has to
|
|
// the parent, and this is the entire protocol over it: one message shape,
|
|
// outbound only, carrying a content height and an error string. Nothing flows
|
|
// the other way. The parent treats both fields as untrusted data - the height
|
|
// is clamped and the message is rendered as text, never as markup - because
|
|
// they were produced by the same code the sandbox exists to contain.
|
|
//
|
|
// Without this the frame is silent: a preview whose script throws just renders
|
|
// blank, which is why the server-side validator in synapse/tools.py has to
|
|
// guess at runtime failures it can't observe.
|
|
const _PREVIEW_BOOTSTRAP = `<script>
|
|
(function () {
|
|
var observers = [];
|
|
// Measure the body box, never documentElement: <html>'s scrollHeight is at
|
|
// least the viewport, i.e. at least whatever height the parent just applied,
|
|
// so feeding it back would make every preview climb to the cap. body height
|
|
// is auto, so its scrollHeight tracks content alone; its own margins sit
|
|
// outside that box and have to be added back by hand.
|
|
var measure = function () {
|
|
var b = document.body;
|
|
if (!b) return 0;
|
|
var cs = getComputedStyle(b);
|
|
return b.scrollHeight
|
|
+ (parseFloat(cs.marginTop) || 0)
|
|
+ (parseFloat(cs.marginBottom) || 0);
|
|
};
|
|
// The first error is remembered and re-sent with every later message. A
|
|
// document can throw while parsing, before the parent has attached its
|
|
// listener, and a dropped error leaves a blank frame with no explanation -
|
|
// the exact failure this bootstrap exists to prevent. Re-sending costs
|
|
// nothing: the parent setting the same string twice is a no-op.
|
|
var firstErr = "";
|
|
var post = function (err) {
|
|
if (err && !firstErr) firstErr = String(err).slice(0, 500);
|
|
try {
|
|
parent.postMessage({ __nexusPreview: 1, h: measure(), err: firstErr }, "*");
|
|
} catch (e) { /* parent went away - nothing to report to */ }
|
|
};
|
|
|
|
// Coalesce bursts: one re-render can fire many mutations.
|
|
var pending = 0;
|
|
var soon = function () {
|
|
if (pending) return;
|
|
pending = setTimeout(function () { pending = 0; post(); }, 50);
|
|
};
|
|
window.onerror = function (msg, src, line, col, err) {
|
|
// Line numbers are document-relative; the user reads them against their own
|
|
// source in the Code tab. Subtract everything above it: the shell, this
|
|
// bootstrap, and for JSX the inlined view library and import stubs.
|
|
// (No backticks anywhere in here - this whole script is a template literal.)
|
|
var off = (window.__previewLineOffset | 0);
|
|
var n = line - off;
|
|
// Walk the stack for the innermost frame that lands in the user's own code.
|
|
// The top frame is often shell: a component that throws while rendering is
|
|
// caught and rethrown by the view library, and a stubbed import throws from
|
|
// the stub. Both sit above the user's first line, so they subtract to less
|
|
// than 1 and the next frame down is the one worth reporting.
|
|
if (err && err.stack) {
|
|
var re = /:(\\d+):\\d+/g, m;
|
|
while ((m = re.exec(String(err.stack)))) {
|
|
var cand = (+m[1]) - off;
|
|
if (cand >= 1) { n = cand; break; }
|
|
}
|
|
}
|
|
post(n >= 1 ? msg + " (line " + n + ")" : msg);
|
|
return false;
|
|
};
|
|
window.addEventListener("unhandledrejection", function (e) {
|
|
var r = e.reason;
|
|
post("Unhandled promise rejection: " + ((r && r.message) || r));
|
|
});
|
|
window.addEventListener("load", function () {
|
|
post();
|
|
// Two observers, because neither covers the other's case. A
|
|
// MutationObserver catches content and inline-style changes - what a
|
|
// component re-render does - and runs off the microtask queue. A
|
|
// ResizeObserver catches size changes with no DOM change behind them, such
|
|
// as a CSS transition or a media query, but is delivered as part of the
|
|
// rendering lifecycle, so a frame that is never composited never gets one.
|
|
// The references are held so neither is collected while still observing.
|
|
if (window.MutationObserver && document.body) {
|
|
observers.push(new MutationObserver(soon));
|
|
observers[observers.length - 1].observe(document.body, {
|
|
childList: true, subtree: true, attributes: true, characterData: true
|
|
});
|
|
}
|
|
if (window.ResizeObserver && document.body) {
|
|
observers.push(new ResizeObserver(soon));
|
|
observers[observers.length - 1].observe(document.body);
|
|
}
|
|
setTimeout(post, 300); // late paints: fonts, async draws, first rAF frame
|
|
// Heartbeat: the parent's watchdog needs a message even when nothing is
|
|
// changing, or an idle-but-alive frame reads the same as a hung one.
|
|
setInterval(post, 1000);
|
|
});
|
|
})();
|
|
</script>`;
|
|
|
|
// Substituted with the real line offset once the document is assembled and its
|
|
// shell can be measured. Sits on one line so replacing it can't shift any.
|
|
const _OFFSET_TOKEN = "__PREVIEW_LINE_OFFSET__";
|
|
|
|
/**
|
|
* Build the sandboxed document for a fence. Returns {doc, error}: a language
|
|
* whose source doesn't parse (JSX, today) has no document to show, and the
|
|
* caller renders the message instead of a frame.
|
|
*
|
|
* The shell - charset, CSP, bootstrap - is identical for every language; only
|
|
* the body differs, so only that part goes through the registry. Nothing about
|
|
* the sandboxing is per-language and shouldn't be: SVG can carry <script> and
|
|
* event-handler attributes exactly like HTML can, and transformed JSX is just
|
|
* more script. Every language is contained the same way.
|
|
*/
|
|
async function buildSrcDoc(lang, value) {
|
|
const entry = PREVIEW_LANGS[lang];
|
|
if (!entry) return { doc: null, error: `No preview for '${lang}'.` };
|
|
|
|
let body;
|
|
try {
|
|
body = await entry.toBody(value);
|
|
} catch (e) {
|
|
return { doc: null, error: e && e.message ? e.message : String(e) };
|
|
}
|
|
|
|
const head =
|
|
"<!doctype html><html><head><meta charset=\"utf-8\">" +
|
|
`<meta http-equiv="Content-Security-Policy" content="${_RENDER_CSP}">` +
|
|
`<script>window.__previewLineOffset=${_OFFSET_TOKEN};</script>` +
|
|
_PREVIEW_BOOTSTRAP +
|
|
"</head><body style=\"margin:0\">";
|
|
|
|
// Lines of shell above the user's own code: the document head, plus whatever
|
|
// the language puts in the body ahead of it (the Preact build, for JSX).
|
|
const offset = (head.match(/\n/g) || []).length + body.userOffset;
|
|
|
|
return {
|
|
doc: (head + body.html + "</body></html>").replace(_OFFSET_TOKEN, String(offset)),
|
|
error: "",
|
|
};
|
|
}
|
|
|
|
// Auto-height bounds. The frame is sized from content, and content sized in
|
|
// viewport/percentage units is therefore sized from the frame - a body with its
|
|
// own margin makes that loop grow by the margin on every pass. Measuring the
|
|
// body box rather than documentElement is what actually settles that loop;
|
|
// _MAX_PREVIEW_H then caps anything still climbing within a few iterations.
|
|
//
|
|
// _MAX_H_STEPS is only a last resort against a document that oscillates
|
|
// forever, so it is generous: an interactive component legitimately changes
|
|
// height on every click, and a tight budget would freeze the frame mid-session
|
|
// at whatever size it happened to reach.
|
|
const _MIN_PREVIEW_H = 160;
|
|
const _MAX_PREVIEW_H = 720;
|
|
const _MAX_H_STEPS = 60;
|
|
|
|
// A frame that never posts again — a synchronous `while(true)` in the user's
|
|
// own script, or a runaway re-render loop the bootstrap's own coalescing
|
|
// can't outpace — has nothing else to signal it. Silence past this long since
|
|
// mount (or since the last message) is treated as hung and the frame is torn
|
|
// down; the bootstrap's 1s heartbeat means a merely-idle-but-alive frame never
|
|
// gets close to this.
|
|
const _WATCHDOG_MS = 6000;
|
|
|
|
// Live preview for a renderable fenced block: a Preview/Code toggle rendered
|
|
// via a sandboxed iframe whose document is an encoded data: URL.
|
|
//
|
|
// Trust boundary: `sandbox="allow-scripts"` — deliberately without
|
|
// allow-same-origin, allow-forms, allow-popups, or allow-top-navigation. No
|
|
// allow-same-origin forces the iframe onto an opaque origin, which is what
|
|
// actually matters here: even the inline scripts the CSP allows to run can't
|
|
// read this app's cookies/localStorage, can't call its API (no credentialed
|
|
// or same-origin fetch is possible), and can't reach `window.parent`. The CSP
|
|
// above blocks resource and script-initiated network access. The embedding
|
|
// document's `frame-src data:` policy in index.html closes a separate CSP gap:
|
|
// a child is otherwise allowed to navigate its own browsing context to a URL.
|
|
// The initial data: document is allowed and inherits the parent policy, while
|
|
// an http(s) navigation is rejected before its request is sent. Nothing here
|
|
// substitutes for a general code-execution sandbox (Docker, WASM, etc.);
|
|
// model-authored code runs only inside the browser's sandboxed frame.
|
|
function RenderBlock({ lang, value, streaming }) {
|
|
const [tab, setTab] = useState("preview");
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const copy = () => {
|
|
navigator.clipboard.writeText(value.trimEnd()).then(() => {
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 1500);
|
|
});
|
|
};
|
|
|
|
// Don't preview a block whose fence hasn't closed yet - it's incomplete
|
|
// markup by definition, and re-pointing an iframe at a half-formed
|
|
// document on every streamed token is both wasteful and flickery. Code view
|
|
// already has its own streaming indicator (the same dot CodeBlock uses).
|
|
const showPreview = tab === "preview" && !streaming;
|
|
|
|
return (
|
|
<div style={{
|
|
background: "#0d0d0d",
|
|
border: "1px solid #2a2a2a",
|
|
borderRadius: "6px",
|
|
margin: "0.5rem 0",
|
|
overflow: "hidden",
|
|
}}>
|
|
<div style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: "0.3rem 0.75rem",
|
|
background: "#161616",
|
|
borderBottom: "1px solid #2a2a2a",
|
|
}}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
|
<TabButton active={tab === "preview"} disabled={streaming} onClick={() => setTab("preview")}>
|
|
Preview
|
|
</TabButton>
|
|
<TabButton active={tab === "code"} onClick={() => setTab("code")}>
|
|
Code
|
|
</TabButton>
|
|
<span style={{ fontSize: "0.7rem", color: "#555", fontFamily: "monospace", marginLeft: "0.25rem" }}>
|
|
{lang}
|
|
{streaming && <span style={{ color: "#444", marginLeft: "0.4rem" }}>●</span>}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
{showPreview && (
|
|
<button onClick={() => setExpanded((e) => !e)} style={_chromeButtonStyle("#555")}>
|
|
{expanded ? "Collapse" : "Expand"}
|
|
</button>
|
|
)}
|
|
{!streaming && (
|
|
<button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
|
|
{copied ? "Copied!" : "Copy"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{showPreview ? (
|
|
// Keyed by the markup: new markup is a new document, so remounting is
|
|
// what resets the reported error and measured height. No reset effect.
|
|
<PreviewFrame key={`${lang}:${value}`} lang={lang} value={value} expanded={expanded} />
|
|
) : (
|
|
<pre style={{
|
|
padding: "0.75rem 1rem",
|
|
overflowX: "auto",
|
|
fontSize: "0.85rem",
|
|
lineHeight: "1.5",
|
|
margin: 0,
|
|
fontFamily: "monospace",
|
|
}}>
|
|
<code>{value.trimEnd()}</code>
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The sandboxed frame plus the two things it reports back: its content height
|
|
// and its first uncaught error. Split out of RenderBlock so the caller can key
|
|
// it by markup - a fresh document then gets fresh state by remounting.
|
|
function PreviewFrame({ lang, value, expanded }) {
|
|
const [error, setError] = useState("");
|
|
const [doc, setDoc] = useState("");
|
|
const [buildError, setBuildError] = useState("");
|
|
const [height, setHeight] = useState(240);
|
|
const [hung, setHung] = useState(false);
|
|
const frameRef = useRef(null);
|
|
const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
|
|
const stepsRef = useRef(0);
|
|
const lastMsgRef = useRef(0); // set for real by the watchdog effect below
|
|
|
|
// Receive the bootstrap's reports. The frame is on an opaque origin, so
|
|
// e.origin is the string "null" and proves nothing - identify the sender by
|
|
// its window instead, which content inside the sandbox cannot forge.
|
|
useEffect(() => {
|
|
const onMessage = (e) => {
|
|
if (!frameRef.current || e.source !== frameRef.current.contentWindow) return;
|
|
const data = e.data;
|
|
if (!data || data.__nexusPreview !== 1) return;
|
|
lastMsgRef.current = Date.now();
|
|
|
|
if (typeof data.err === "string" && data.err) setError(data.err);
|
|
|
|
if (typeof data.h === "number" && Number.isFinite(data.h) && stepsRef.current < _MAX_H_STEPS) {
|
|
const next = Math.min(_MAX_PREVIEW_H, Math.max(_MIN_PREVIEW_H, Math.round(data.h)));
|
|
if (Math.abs(next - heightRef.current) >= 8) {
|
|
heightRef.current = next;
|
|
stepsRef.current += 1;
|
|
setHeight(next);
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener("message", onMessage);
|
|
return () => window.removeEventListener("message", onMessage);
|
|
}, []);
|
|
|
|
// Watchdog: a frame that goes silent past _WATCHDOG_MS — most likely a
|
|
// synchronous infinite loop in the model's own script, which blocks even
|
|
// the bootstrap's heartbeat from ever running — gets torn down rather than
|
|
// left spinning. Checked on an interval rather than a single timeout so a
|
|
// message arriving late (slow compile, heavy first paint) keeps resetting
|
|
// the clock instead of tripping early.
|
|
useEffect(() => {
|
|
lastMsgRef.current = Date.now();
|
|
const id = setInterval(() => {
|
|
if (Date.now() - lastMsgRef.current > _WATCHDOG_MS) {
|
|
setHung(true);
|
|
clearInterval(id);
|
|
}
|
|
}, 1000);
|
|
return () => clearInterval(id);
|
|
}, [lang, value]);
|
|
|
|
useEffect(() => {
|
|
let current = true;
|
|
setDoc("");
|
|
setBuildError("");
|
|
buildSrcDoc(lang, value).then((result) => {
|
|
if (!current) return;
|
|
setDoc(result.doc || "");
|
|
setBuildError(result.error || "");
|
|
});
|
|
return () => { current = false; };
|
|
}, [lang, value]);
|
|
|
|
// A build failure (JSX that doesn't parse) has no document to show at all, so
|
|
// the message stands in for the frame rather than sitting under it. A hung
|
|
// frame tears down the same way: dropping frameUrl unmounts the iframe,
|
|
// which is what actually stops a runaway script from holding the tab.
|
|
const frameUrl = doc && !hung ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
|
|
const shown = hung
|
|
? "Preview stopped responding (likely an infinite loop) and was stopped."
|
|
: buildError || error;
|
|
|
|
return (
|
|
<>
|
|
{frameUrl && (
|
|
<iframe
|
|
ref={frameRef}
|
|
title="rendered output"
|
|
sandbox="allow-scripts"
|
|
src={frameUrl}
|
|
style={{
|
|
width: "100%",
|
|
height: expanded ? "70vh" : `${height}px`,
|
|
border: "none",
|
|
background: "#fff",
|
|
display: "block",
|
|
}}
|
|
/>
|
|
)}
|
|
{shown && (
|
|
<div style={{
|
|
background: "#2a1414",
|
|
borderTop: "1px solid #4a2020",
|
|
color: "#ff8a80",
|
|
fontFamily: "monospace",
|
|
fontSize: "0.75rem",
|
|
padding: "0.4rem 0.75rem",
|
|
// Text from inside the sandbox: rendered as a string, and wrapped
|
|
// rather than allowed to stretch the block.
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-word",
|
|
}}>
|
|
{shown}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function _chromeButtonStyle(color) {
|
|
return {
|
|
background: "transparent",
|
|
border: "none",
|
|
color,
|
|
cursor: "pointer",
|
|
fontSize: "0.75rem",
|
|
padding: "0.1rem 0.3rem",
|
|
};
|
|
}
|
|
|
|
function TabButton({ active, disabled, onClick, children }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
style={{
|
|
background: active ? "#262626" : "transparent",
|
|
border: "none",
|
|
borderRadius: "4px",
|
|
color: disabled ? "#3a3a3a" : active ? "#eee" : "#888",
|
|
cursor: disabled ? "default" : "pointer",
|
|
fontSize: "0.75rem",
|
|
padding: "0.15rem 0.5rem",
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function TextBlock({ text }) {
|
|
const lines = text.split("\n");
|
|
const elements = [];
|
|
let i = 0;
|
|
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
|
|
const hMatch = line.match(/^(#{1,3})\s+(.+)/);
|
|
if (hMatch) {
|
|
const level = hMatch[1].length;
|
|
const sizes = { 1: "1.2rem", 2: "1.05rem", 3: "0.95rem" };
|
|
elements.push(
|
|
<div key={i} style={{ fontWeight: "700", fontSize: sizes[level], margin: "0.6rem 0 0.2rem", color: "#fff" }}>
|
|
{inlineMarkdown(hMatch[2])}
|
|
</div>
|
|
);
|
|
i++; continue;
|
|
}
|
|
|
|
if (/^[-*]\s+/.test(line)) {
|
|
const items = [];
|
|
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
|
items.push(<li key={i}>{inlineMarkdown(lines[i].replace(/^[-*]\s+/, ""))}</li>);
|
|
i++;
|
|
}
|
|
elements.push(<ul key={`ul-${i}`} style={{ paddingLeft: "1.25rem", margin: "0.25rem 0" }}>{items}</ul>);
|
|
continue;
|
|
}
|
|
|
|
if (/^\d+\.\s+/.test(line)) {
|
|
const items = [];
|
|
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
|
items.push(<li key={i}>{inlineMarkdown(lines[i].replace(/^\d+\.\s+/, ""))}</li>);
|
|
i++;
|
|
}
|
|
elements.push(<ol key={`ol-${i}`} style={{ paddingLeft: "1.25rem", margin: "0.25rem 0" }}>{items}</ol>);
|
|
continue;
|
|
}
|
|
|
|
if (/^---+$/.test(line.trim())) {
|
|
elements.push(<hr key={i} style={{ border: "none", borderTop: "1px solid #333", margin: "0.5rem 0" }} />);
|
|
i++; continue;
|
|
}
|
|
|
|
if (line.trim() === "") {
|
|
elements.push(<div key={i} style={{ height: "0.4rem" }} />);
|
|
i++; continue;
|
|
}
|
|
|
|
elements.push(<div key={i}>{inlineMarkdown(line)}</div>);
|
|
i++;
|
|
}
|
|
|
|
return <>{elements}</>;
|
|
}
|
|
|
|
function inlineMarkdown(text) {
|
|
const parts = [];
|
|
const re = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g;
|
|
let last = 0;
|
|
let m;
|
|
|
|
while ((m = re.exec(text)) !== null) {
|
|
if (m.index > last) parts.push(text.slice(last, m.index));
|
|
if (m[2] !== undefined) {
|
|
parts.push(<strong key={m.index}>{m[2]}</strong>);
|
|
} else if (m[3] !== undefined) {
|
|
parts.push(<em key={m.index}>{m[3]}</em>);
|
|
} else if (m[4] !== undefined) {
|
|
parts.push(
|
|
<code key={m.index} style={{
|
|
background: "#1e1e1e",
|
|
border: "1px solid #333",
|
|
borderRadius: "3px",
|
|
padding: "0.1rem 0.35rem",
|
|
fontSize: "0.85em",
|
|
fontFamily: "monospace",
|
|
}}>
|
|
{m[4]}
|
|
</code>
|
|
);
|
|
}
|
|
last = m.index + m[0].length;
|
|
}
|
|
|
|
if (last < text.length) parts.push(text.slice(last));
|
|
return parts.length === 1 && typeof parts[0] === "string" ? parts[0] : parts;
|
|
}
|