@wingleeio/mugen-markdown 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -162,7 +162,16 @@ const defaultTheme = {
162
162
  background: "rgba(127, 127, 127, 0.12)",
163
163
  color: "inherit",
164
164
  radius: 8,
165
- highlight: defaultTokenColors
165
+ highlight: defaultTokenColors,
166
+ header: {
167
+ show: false,
168
+ height: 38,
169
+ fontSize: 12,
170
+ background: "rgba(127, 127, 127, 0.06)",
171
+ color: "rgba(127, 127, 127, 0.85)",
172
+ borderColor: "rgba(127, 127, 127, 0.18)",
173
+ buttonBackground: "rgba(127, 127, 127, 0.04)"
174
+ }
166
175
  },
167
176
  blockquote: {
168
177
  padding: 14,
@@ -1311,7 +1320,7 @@ function lineCount(value) {
1311
1320
  function measureCodeBlock(props, ctx) {
1312
1321
  (0, _wingleeio_mugen.assertMeasurableFont)(props.font);
1313
1322
  const pad = props.padding ?? 0;
1314
- return lineCount(props.value) * props.lineHeight + 2 * pad;
1323
+ return (props.header ? props.header.height : 0) + lineCount(props.value) * props.lineHeight + 2 * pad;
1315
1324
  }
1316
1325
  const colorsCache = /* @__PURE__ */ new WeakMap();
1317
1326
  function resolveTokenColors(overrides) {
@@ -1375,9 +1384,124 @@ function HighlightedCode(props) {
1375
1384
  style: overlayStyle
1376
1385
  }));
1377
1386
  }
1387
+ function legacyCopy(text) {
1388
+ if (typeof document === "undefined") return false;
1389
+ const ta = document.createElement("textarea");
1390
+ ta.value = text;
1391
+ ta.setAttribute("readonly", "");
1392
+ ta.style.position = "fixed";
1393
+ ta.style.top = "0";
1394
+ ta.style.left = "0";
1395
+ ta.style.opacity = "0";
1396
+ ta.style.pointerEvents = "none";
1397
+ document.body.appendChild(ta);
1398
+ ta.select();
1399
+ let ok = false;
1400
+ try {
1401
+ ok = document.execCommand("copy");
1402
+ } catch {
1403
+ ok = false;
1404
+ }
1405
+ document.body.removeChild(ta);
1406
+ return ok;
1407
+ }
1408
+ async function copyText(text) {
1409
+ const clip = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
1410
+ if (clip?.writeText != null) try {
1411
+ await clip.writeText(text);
1412
+ return true;
1413
+ } catch {}
1414
+ return legacyCopy(text);
1415
+ }
1416
+ /** Copies the block's raw text; flips to "Copied" for ~1.6s on success. */
1417
+ function CopyButton(props) {
1418
+ const [copied, setCopied] = (0, react.useState)(false);
1419
+ const [hovered, setHovered] = (0, react.useState)(false);
1420
+ const timer = (0, react.useRef)(null);
1421
+ (0, react.useEffect)(() => () => {
1422
+ if (timer.current != null) clearTimeout(timer.current);
1423
+ }, []);
1424
+ const onClick = () => {
1425
+ copyText(props.value).then((ok) => {
1426
+ if (!ok) return;
1427
+ setCopied(true);
1428
+ if (timer.current != null) clearTimeout(timer.current);
1429
+ timer.current = setTimeout(() => setCopied(false), 1600);
1430
+ }).catch(() => {});
1431
+ };
1432
+ const style = {
1433
+ display: "inline-flex",
1434
+ flex: "0 0 auto",
1435
+ alignItems: "center",
1436
+ justifyContent: "center",
1437
+ whiteSpace: "nowrap",
1438
+ cursor: "pointer",
1439
+ borderRadius: 8,
1440
+ border: `1px solid ${props.borderColor ?? "rgba(127, 127, 127, 0.2)"}`,
1441
+ ...props.background != null ? { background: props.background } : null,
1442
+ ...props.color != null ? { color: props.color } : null,
1443
+ padding: "4px 9px",
1444
+ minWidth: "4.6em",
1445
+ fontFamily: "inherit",
1446
+ fontSize: `${props.fontSize}px`,
1447
+ lineHeight: 1,
1448
+ opacity: hovered || copied ? 1 : .82,
1449
+ transition: "opacity 120ms ease"
1450
+ };
1451
+ return (0, react.createElement)("button", {
1452
+ type: "button",
1453
+ onClick,
1454
+ onMouseEnter: () => setHovered(true),
1455
+ onMouseLeave: () => setHovered(false),
1456
+ "aria-label": copied ? "Copied" : "Copy code",
1457
+ style
1458
+ }, copied ? "Copied" : "Copy");
1459
+ }
1460
+ /** The fixed-height chrome bar: language label left, copy button right. */
1461
+ function CodeHeader(props) {
1462
+ const barStyle = {
1463
+ display: "flex",
1464
+ flex: "0 0 auto",
1465
+ alignItems: "center",
1466
+ justifyContent: "space-between",
1467
+ gap: 8,
1468
+ height: `${props.height}px`,
1469
+ boxSizing: "border-box",
1470
+ padding: "0 12px",
1471
+ fontFamily: props.fontFamily,
1472
+ ...props.radius != null ? {
1473
+ borderTopLeftRadius: `${props.radius}px`,
1474
+ borderTopRightRadius: `${props.radius}px`
1475
+ } : null,
1476
+ ...props.background != null ? { background: props.background } : null,
1477
+ ...props.borderColor != null ? { borderBottom: `1px solid ${props.borderColor}` } : null
1478
+ };
1479
+ const labelStyle = {
1480
+ minWidth: 0,
1481
+ overflow: "hidden",
1482
+ textOverflow: "ellipsis",
1483
+ whiteSpace: "nowrap",
1484
+ fontSize: `${props.fontSize}px`,
1485
+ letterSpacing: "0.02em",
1486
+ fontVariantLigatures: "none",
1487
+ ...props.color != null ? { color: props.color } : null
1488
+ };
1489
+ return (0, react.createElement)("div", { style: barStyle }, (0, react.createElement)("span", {
1490
+ key: "lang",
1491
+ style: labelStyle
1492
+ }, props.label), (0, react.createElement)(CopyButton, {
1493
+ key: "copy",
1494
+ value: props.value,
1495
+ fontSize: props.fontSize,
1496
+ color: props.color,
1497
+ borderColor: props.borderColor,
1498
+ background: props.buttonBackground
1499
+ }));
1500
+ }
1378
1501
  function renderCodeBlock(props) {
1379
1502
  const pad = props.padding ?? 0;
1380
1503
  const profile = props.highlight === false ? null : profileFor(props.lang);
1504
+ const header = props.header;
1381
1505
  const preStyle = {
1382
1506
  margin: 0,
1383
1507
  padding: `${pad}px`,
@@ -1386,7 +1510,10 @@ function renderCodeBlock(props) {
1386
1510
  boxSizing: "border-box",
1387
1511
  ...props.background != null ? { background: props.background } : null,
1388
1512
  ...props.color != null ? { color: props.color } : null,
1389
- ...props.radius != null ? { borderRadius: `${props.radius}px` } : null,
1513
+ ...props.radius != null ? header == null ? { borderRadius: `${props.radius}px` } : {
1514
+ borderBottomLeftRadius: `${props.radius}px`,
1515
+ borderBottomRightRadius: `${props.radius}px`
1516
+ } : null,
1390
1517
  ...profile != null ? {
1391
1518
  position: "relative",
1392
1519
  tabSize: 8
@@ -1399,17 +1526,13 @@ function renderCodeBlock(props) {
1399
1526
  margin: 0,
1400
1527
  padding: 0
1401
1528
  };
1402
- if (profile == null) return (0, react.createElement)("pre", {
1403
- className: props.className,
1529
+ const pre = (0, react.createElement)("pre", {
1530
+ ...header == null ? { className: props.className } : null,
1404
1531
  style: preStyle
1405
- }, (0, react.createElement)("code", {
1532
+ }, profile == null ? (0, react.createElement)("code", {
1406
1533
  style: codeStyle,
1407
1534
  ...props.lang ? { "data-lang": props.lang } : null
1408
- }, props.value));
1409
- return (0, react.createElement)("pre", {
1410
- className: props.className,
1411
- style: preStyle
1412
- }, (0, react.createElement)(HighlightedCode, {
1535
+ }, props.value) : (0, react.createElement)(HighlightedCode, {
1413
1536
  value: props.value,
1414
1537
  lang: props.lang,
1415
1538
  font: props.font,
@@ -1419,6 +1542,20 @@ function renderCodeBlock(props) {
1419
1542
  colors: resolveTokenColors(props.highlight === false ? void 0 : props.highlight),
1420
1543
  codeStyle
1421
1544
  }));
1545
+ if (header == null) return pre;
1546
+ return (0, react.createElement)("div", { className: props.className }, (0, react.createElement)(CodeHeader, {
1547
+ key: "header",
1548
+ label: header.label ?? props.lang ?? "code",
1549
+ value: props.value,
1550
+ height: header.height,
1551
+ fontSize: header.fontSize,
1552
+ fontFamily: header.fontFamily ?? "monospace",
1553
+ ...props.radius != null ? { radius: props.radius } : null,
1554
+ background: header.background,
1555
+ color: header.color,
1556
+ borderColor: header.borderColor,
1557
+ buttonBackground: header.buttonBackground
1558
+ }), pre);
1422
1559
  }
1423
1560
  /** A measurable fenced-code primitive (no wrapping; height from line count). */
1424
1561
  const CodeBlock = (0, _wingleeio_mugen.markPrimitive)(renderCodeBlock, {
@@ -1632,7 +1769,16 @@ const defaultComponents = {
1632
1769
  background: c.background,
1633
1770
  radius: c.radius,
1634
1771
  highlight: c.highlight,
1635
- ...c.color !== "inherit" ? { color: c.color } : null
1772
+ ...c.color !== "inherit" ? { color: c.color } : null,
1773
+ ...c.header.show ? { header: {
1774
+ height: c.header.height,
1775
+ fontSize: c.header.fontSize,
1776
+ fontFamily: ctx.theme.monoFamily,
1777
+ background: c.header.background,
1778
+ color: c.header.color,
1779
+ borderColor: c.header.borderColor,
1780
+ buttonBackground: c.header.buttonBackground
1781
+ } } : null
1636
1782
  });
1637
1783
  },
1638
1784
  list: ({ node, ctx }) => renderList(node, ctx),
@@ -1817,6 +1963,256 @@ function renderMarkdown(source, options = {}) {
1817
1963
  return createContext(resolveTheme(options.theme), mergeComponents(options.components)).renderBlocks(ast.children);
1818
1964
  }
1819
1965
  //#endregion
1966
+ //#region src/primitives/fade.tsx
1967
+ /**
1968
+ * Streaming fade-in for `<Markdown fade>`.
1969
+ *
1970
+ * The same trick the code highlighter uses, pointed at motion instead of
1971
+ * colour: the markdown DOM commits and lays out instantly (so heights,
1972
+ * selection, and stick-to-bottom stay honest), and a background-coloured veil
1973
+ * is painted over just-arrived characters and dissolved — which *reads* as the
1974
+ * text fading in. Nothing about the row ever animates; layout is done the
1975
+ * moment the text lands.
1976
+ *
1977
+ * Unlike a list-level overlay, the veil canvas lives **inside** the markdown's
1978
+ * own box (`position: absolute; inset: 0`), so it scrolls with the content and
1979
+ * needs no viewport-geometry tracking. The painter idles (no rAF) until a
1980
+ * DOM mutation arrives, so leaving `fade` on for a settled block costs nothing.
1981
+ */
1982
+ const EMA_SEED_MS = 160;
1983
+ const MIN_FADE_MS = 120;
1984
+ const MAX_FADE_MS = 400;
1985
+ const MAX_VEILS = 32;
1986
+ function commonPrefixLength(a, b) {
1987
+ const n = Math.min(a.length, b.length);
1988
+ let i = 0;
1989
+ while (i < n && a.charCodeAt(i) === b.charCodeAt(i)) i++;
1990
+ return i;
1991
+ }
1992
+ function inChrome(node, container) {
1993
+ for (let p = node.parentElement; p != null && p !== container; p = p.parentElement) if (p.tagName === "BUTTON") return true;
1994
+ return false;
1995
+ }
1996
+ function contentTextFilter(container) {
1997
+ return { acceptNode: (n) => inChrome(n, container) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT };
1998
+ }
1999
+ /** `container.textContent`, minus interactive chrome (see {@link inChrome}). */
2000
+ function contentText(container) {
2001
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, contentTextFilter(container));
2002
+ let s = "";
2003
+ for (let n = walker.nextNode(); n != null; n = walker.nextNode()) s += n.data;
2004
+ return s;
2005
+ }
2006
+ let scratch;
2007
+ function scratchCtx() {
2008
+ if (scratch === void 0) {
2009
+ const c = typeof document === "undefined" ? null : document.createElement("canvas");
2010
+ if (c == null) return scratch = null;
2011
+ c.width = 1;
2012
+ c.height = 1;
2013
+ scratch = c.getContext("2d", { willReadFrequently: true });
2014
+ }
2015
+ return scratch;
2016
+ }
2017
+ /** The opaque colour behind `el`: every ancestor's backgroundColor composited. */
2018
+ function effectiveBackground(el, cache) {
2019
+ const cached = cache.get(el);
2020
+ if (cached !== void 0) return cached;
2021
+ const layers = [];
2022
+ for (let cur = el; cur != null; cur = cur.parentElement) {
2023
+ const bg = getComputedStyle(cur).backgroundColor;
2024
+ if (bg && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)") layers.push(bg);
2025
+ }
2026
+ const s = scratchCtx();
2027
+ if (s == null) return "#808080";
2028
+ s.globalAlpha = 1;
2029
+ s.fillStyle = "#ffffff";
2030
+ s.fillRect(0, 0, 1, 1);
2031
+ for (let i = layers.length - 1; i >= 0; i--) {
2032
+ try {
2033
+ s.fillStyle = layers[i];
2034
+ } catch {
2035
+ continue;
2036
+ }
2037
+ s.fillRect(0, 0, 1, 1);
2038
+ }
2039
+ const d = s.getImageData(0, 0, 1, 1).data;
2040
+ const css = `rgb(${d[0]}, ${d[1]}, ${d[2]})`;
2041
+ cache.set(el, css);
2042
+ return css;
2043
+ }
2044
+ /**
2045
+ * Paints the dissolving veil over a single content element's newly-arrived text.
2046
+ * Driven by a MutationObserver: a rAF loop runs only while veils are alive, then
2047
+ * stops until the next mutation, so an idle (settled) block uses no frames.
2048
+ */
2049
+ var FadePainter = class {
2050
+ content = null;
2051
+ canvas = null;
2052
+ ctx = null;
2053
+ prevText = "";
2054
+ veils = [];
2055
+ ema = EMA_SEED_MS;
2056
+ lastAppend = 0;
2057
+ raf = 0;
2058
+ running = false;
2059
+ mo = null;
2060
+ attach(content, canvas) {
2061
+ if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
2062
+ const ctx = canvas.getContext("2d");
2063
+ if (ctx == null) return;
2064
+ this.content = content;
2065
+ this.canvas = canvas;
2066
+ this.ctx = ctx;
2067
+ this.prevText = contentText(content);
2068
+ this.mo = new MutationObserver(() => this.wake());
2069
+ this.mo.observe(content, {
2070
+ subtree: true,
2071
+ childList: true,
2072
+ characterData: true
2073
+ });
2074
+ }
2075
+ destroy() {
2076
+ this.mo?.disconnect();
2077
+ this.mo = null;
2078
+ if (this.raf !== 0) cancelAnimationFrame(this.raf);
2079
+ this.raf = 0;
2080
+ this.running = false;
2081
+ this.content = null;
2082
+ this.canvas = null;
2083
+ this.ctx = null;
2084
+ }
2085
+ wake() {
2086
+ if (this.running || this.content == null) return;
2087
+ this.running = true;
2088
+ this.raf = requestAnimationFrame(this.frame);
2089
+ }
2090
+ frame = () => {
2091
+ const content = this.content;
2092
+ const canvas = this.canvas;
2093
+ const ctx = this.ctx;
2094
+ if (content == null || canvas == null || ctx == null) {
2095
+ this.running = false;
2096
+ return;
2097
+ }
2098
+ const now = performance.now();
2099
+ const text = contentText(content);
2100
+ if (text !== this.prevText) {
2101
+ const prefix = commonPrefixLength(this.prevText, text);
2102
+ this.veils = this.veils.map((v) => ({
2103
+ ...v,
2104
+ start: Math.min(v.start, prefix),
2105
+ end: Math.min(v.end, prefix)
2106
+ })).filter((v) => v.end > v.start);
2107
+ if (text.length > prefix) {
2108
+ if (this.lastAppend > 0) this.ema = this.ema * .7 + Math.min(now - this.lastAppend, 1e3) * .3;
2109
+ this.lastAppend = now;
2110
+ this.veils.push({
2111
+ start: prefix,
2112
+ end: text.length,
2113
+ t0: now
2114
+ });
2115
+ if (this.veils.length > MAX_VEILS) this.veils.splice(0, this.veils.length - MAX_VEILS);
2116
+ }
2117
+ this.prevText = text;
2118
+ }
2119
+ const duration = Math.min(MAX_FADE_MS, Math.max(MIN_FADE_MS, this.ema * 3));
2120
+ const boost = 1 + .3 * Math.max(0, this.veils.length - 2);
2121
+ this.veils = this.veils.filter((v) => (now - v.t0) * boost < duration);
2122
+ const dpr = typeof devicePixelRatio === "number" && devicePixelRatio > 0 ? devicePixelRatio : 1;
2123
+ const w = content.clientWidth;
2124
+ const h = content.clientHeight;
2125
+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
2126
+ canvas.width = Math.round(w * dpr);
2127
+ canvas.height = Math.round(h * dpr);
2128
+ }
2129
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2130
+ ctx.clearRect(0, 0, w, h);
2131
+ if (this.veils.length > 0) {
2132
+ const origin = canvas.getBoundingClientRect();
2133
+ const bgCache = /* @__PURE__ */ new Map();
2134
+ const groups = /* @__PURE__ */ new Map();
2135
+ const minStart = this.veils.reduce((m, v) => Math.min(m, v.start), Infinity);
2136
+ const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT, contentTextFilter(content));
2137
+ let base = 0;
2138
+ let node = walker.nextNode();
2139
+ while (node != null) {
2140
+ const len = node.data.length;
2141
+ if (len > 0 && base + len > minStart) for (let vi = 0; vi < this.veils.length; vi++) {
2142
+ const v = this.veils[vi];
2143
+ const s = Math.max(v.start - base, 0);
2144
+ const e = Math.min(v.end - base, len);
2145
+ if (e <= s) continue;
2146
+ const parent = node.parentElement;
2147
+ if (parent == null) continue;
2148
+ const bg = effectiveBackground(parent, bgCache);
2149
+ const key = `${vi}|${bg}`;
2150
+ let group = groups.get(key);
2151
+ if (group === void 0) {
2152
+ const p = Math.min(1, (now - v.t0) * boost / duration);
2153
+ group = {
2154
+ alpha: Math.pow(1 - p, 1.6),
2155
+ bg,
2156
+ path: new Path2D()
2157
+ };
2158
+ groups.set(key, group);
2159
+ }
2160
+ const range = document.createRange();
2161
+ range.setStart(node, s);
2162
+ range.setEnd(node, e);
2163
+ for (const r of range.getClientRects()) group.path.rect(r.left - origin.left - 1, r.top - origin.top - 1, r.width + 2, r.height + 2);
2164
+ }
2165
+ base += len;
2166
+ node = walker.nextNode();
2167
+ }
2168
+ for (const group of groups.values()) {
2169
+ ctx.globalAlpha = group.alpha;
2170
+ ctx.fillStyle = group.bg;
2171
+ ctx.fill(group.path);
2172
+ }
2173
+ ctx.globalAlpha = 1;
2174
+ }
2175
+ if (this.veils.length > 0) this.raf = requestAnimationFrame(this.frame);
2176
+ else {
2177
+ this.running = false;
2178
+ this.raf = 0;
2179
+ }
2180
+ };
2181
+ };
2182
+ function renderFadeMarkdown(props) {
2183
+ const contentRef = (0, react.useRef)(null);
2184
+ const canvasRef = (0, react.useRef)(null);
2185
+ (0, react.useEffect)(() => {
2186
+ const content = contentRef.current;
2187
+ const canvas = canvasRef.current;
2188
+ if (content == null || canvas == null) return;
2189
+ const painter = new FadePainter();
2190
+ painter.attach(content, canvas);
2191
+ return () => painter.destroy();
2192
+ }, []);
2193
+ return (0, react.createElement)("div", { style: { position: "relative" } }, (0, react.createElement)("div", { ref: contentRef }, props.children), (0, react.createElement)("canvas", {
2194
+ ref: canvasRef,
2195
+ "aria-hidden": true,
2196
+ style: {
2197
+ position: "absolute",
2198
+ inset: 0,
2199
+ width: "100%",
2200
+ height: "100%",
2201
+ pointerEvents: "none"
2202
+ }
2203
+ }));
2204
+ }
2205
+ /**
2206
+ * Wraps a measured markdown subtree with a streaming fade-in canvas. Its height
2207
+ * is exactly the subtree's height — the canvas overlay is out of flow and never
2208
+ * measured.
2209
+ */
2210
+ const FadeMarkdown = (0, _wingleeio_mugen.markPrimitive)(renderFadeMarkdown, {
2211
+ name: "FadeMarkdown",
2212
+ measure: (props, ctx) => (0, _wingleeio_mugen.measureChildren)(props.children, ctx),
2213
+ naturalWidth: (props, ctx) => (0, _wingleeio_mugen.naturalWidthOf)(props.children, ctx)
2214
+ });
2215
+ //#endregion
1820
2216
  //#region src/markdown.tsx
1821
2217
  /**
1822
2218
  * Render markdown as a tree of mugen primitives.
@@ -1839,7 +2235,9 @@ function renderMarkdown(source, options = {}) {
1839
2235
  * and a deep-partial `theme`.
1840
2236
  */
1841
2237
  function Markdown(props) {
1842
- return renderMarkdown(props.source, props);
2238
+ const content = renderMarkdown(props.source, props);
2239
+ if (props.fade && content != null) return (0, react.createElement)(FadeMarkdown, null, content);
2240
+ return content;
1843
2241
  }
1844
2242
  Markdown.displayName = "Markdown";
1845
2243
  //#endregion
@@ -1860,6 +2258,7 @@ function defineMarkdownComponents(components) {
1860
2258
  }
1861
2259
  //#endregion
1862
2260
  exports.CodeBlock = CodeBlock;
2261
+ exports.FadeMarkdown = FadeMarkdown;
1863
2262
  Object.defineProperty(exports, "HStack", {
1864
2263
  enumerable: true,
1865
2264
  get: function() {
package/dist/index.d.cts CHANGED
@@ -110,6 +110,21 @@ interface MarkdownTheme {
110
110
  * change a block's measured height.
111
111
  */
112
112
  highlight: CodeTokenColors | false;
113
+ /**
114
+ * Optional chrome bar above the code — the language on the left, a
115
+ * copy-to-clipboard button on the right. `show: false` (the default) keeps
116
+ * the bare `<pre>`. When shown, the bar's fixed `height` is folded into the
117
+ * block's measured height, so computed and painted heights stay identical.
118
+ */
119
+ header: {
120
+ /** Render the chrome bar. Off by default. */show: boolean; /** Fixed bar height in px (counted in the measured height). */
121
+ height: number; /** Label + button font size in px. */
122
+ fontSize: number; /** Bar background. */
123
+ background: string; /** Label + button text colour. */
124
+ color: string; /** Bottom hairline + button border colour. */
125
+ borderColor: string; /** Copy-button fill. */
126
+ buttonBackground: string;
127
+ };
113
128
  };
114
129
  blockquote: {
115
130
  padding: number;
@@ -354,6 +369,13 @@ declare function renderMarkdown(source: string, options?: RenderMarkdownOptions)
354
369
  interface MarkdownProps extends RenderMarkdownOptions {
355
370
  /** The markdown source to render. */
356
371
  source: string;
372
+ /**
373
+ * Fade just-arrived text in as the source streams. The DOM still commits and
374
+ * lays out instantly (heights stay exact); a veil over new characters
375
+ * dissolves, which reads as a fade-in. Leaving it on for a settled block is
376
+ * free. Honours `prefers-reduced-motion`.
377
+ */
378
+ fade?: boolean;
357
379
  }
358
380
  /**
359
381
  * Render markdown as a tree of mugen primitives.
@@ -383,8 +405,9 @@ declare namespace Markdown {
383
405
  //#region src/primitives/code-block.d.ts
384
406
  /**
385
407
  * A fenced code block. Code does not wrap — long lines scroll horizontally — so
386
- * its height is simply `lineCount × lineHeight + 2 × padding`, independent of the
387
- * row width. That makes it trivially and exactly measurable.
408
+ * its height is simply `lineCount × lineHeight + 2 × padding` (plus an optional
409
+ * fixed-height {@link CodeBlockHeader} bar), independent of the row width. That
410
+ * makes it trivially and exactly measurable.
388
411
  *
389
412
  * Syntax highlighting is layered on as pure paint, never layout: the `<code>`
390
413
  * text renders immediately (plain, selectable, accessible), the language is
@@ -393,6 +416,27 @@ declare namespace Markdown {
393
416
  * turns `color: transparent` in the same frame. Highlighting therefore can't
394
417
  * block first paint and can't ever change the measured height.
395
418
  */
419
+ /**
420
+ * Optional chrome bar above the code: the language on the left, a
421
+ * copy-to-clipboard button on the right. Its fixed `height` is added to the
422
+ * block's measured height, so the block still measures exactly what it paints.
423
+ * Pure decoration otherwise — the bar never wraps and never grows.
424
+ */
425
+ interface CodeBlockHeader {
426
+ /** Left-aligned label; falls back to {@link CodeBlockProps.lang}, then `code`. */
427
+ label?: string;
428
+ /** Fixed bar height in px (folded into the measured height). */
429
+ height: number;
430
+ /** Label + button font size in px. */
431
+ fontSize: number;
432
+ /** Monospace family for the label/button; defaults to `monospace`. */
433
+ fontFamily?: string;
434
+ background?: string;
435
+ color?: string;
436
+ borderColor?: string;
437
+ /** Copy-button fill. */
438
+ buttonBackground?: string;
439
+ }
396
440
  interface CodeBlockProps<C extends string = string> {
397
441
  /** Raw code text. Newlines determine the line count. */
398
442
  value: string;
@@ -413,6 +457,11 @@ interface CodeBlockProps<C extends string = string> {
413
457
  * registered profile are highlighted either way.
414
458
  */
415
459
  highlight?: Partial<CodeTokenColors> | false;
460
+ /**
461
+ * A chrome bar above the code (language label + copy button). Omit for the
462
+ * bare `<pre>`. Its `height` is folded into the measured height.
463
+ */
464
+ header?: CodeBlockHeader;
416
465
  style?: MeasurableStyle;
417
466
  className?: SafeClassName<C>;
418
467
  }
@@ -454,6 +503,18 @@ interface TableBlockProps<C extends string = string> {
454
503
  /** A measurable GFM-table primitive with aligned, content-proportional columns. */
455
504
  declare const TableBlock: <C extends string = string>(props: TableBlockProps<C>) => ReactElement;
456
505
  //#endregion
506
+ //#region src/primitives/fade.d.ts
507
+ interface FadeMarkdownProps {
508
+ /** The rendered markdown subtree to veil. */
509
+ children?: ReactNode;
510
+ }
511
+ /**
512
+ * Wraps a measured markdown subtree with a streaming fade-in canvas. Its height
513
+ * is exactly the subtree's height — the canvas overlay is out of flow and never
514
+ * measured.
515
+ */
516
+ declare const FadeMarkdown: (props: FadeMarkdownProps) => ReactElement;
517
+ //#endregion
457
518
  //#region src/highlight/languages.d.ts
458
519
  /**
459
520
  * Language profiles for the built-in tokenizer. A profile is a small data
@@ -495,4 +556,4 @@ declare function registerLanguage(names: string | readonly string[], p: Language
495
556
  /** The profile for a fence language, or `null` when the language is unknown. */
496
557
  declare function profileFor(lang: string | undefined): LanguageProfile | null;
497
558
  //#endregion
498
- export { type Blockquote, type BoxProps, type Code, CodeBlock, type CodeBlockProps, type CodeTokenColors, type DeepPartial, type Font, HStack, type HStackProps, type Heading, type Html, type Image, type InlineFormat, type InlineTextOptions, type LanguageProfile, type Link, type List, type ListItem, Markdown, type MarkdownComponent, type MarkdownComponentProps, type MarkdownComponents, type MarkdownParseOptions, type MarkdownProps, type MarkdownRenderContext, type MarkdownTheme, type Paragraph, type PhrasingContent, type PrimitiveComponent, type RenderMarkdownOptions, type ResolvedMarkdownComponents, RichText, type RichTextProps, type RichTextRun, type Root, type RootContent, type Table, TableBlock, type TableBlockProps, type TableCell, type TableRow, Text, type TextProps, type ThematicBreak, type TokenType, VStack, type VStackProps, baseFormat, clearParseCache, clearRichTextCache, composeFont, defaultComponents, defaultTheme, defaultTokenColors, defineMarkdownComponents, definePrimitive, flattenInline, parseMarkdown, profileFor, registerLanguage, renderMarkdown, resolveTheme };
559
+ export { type Blockquote, type BoxProps, type Code, CodeBlock, type CodeBlockHeader, type CodeBlockProps, type CodeTokenColors, type DeepPartial, FadeMarkdown, type Font, HStack, type HStackProps, type Heading, type Html, type Image, type InlineFormat, type InlineTextOptions, type LanguageProfile, type Link, type List, type ListItem, Markdown, type MarkdownComponent, type MarkdownComponentProps, type MarkdownComponents, type MarkdownParseOptions, type MarkdownProps, type MarkdownRenderContext, type MarkdownTheme, type Paragraph, type PhrasingContent, type PrimitiveComponent, type RenderMarkdownOptions, type ResolvedMarkdownComponents, RichText, type RichTextProps, type RichTextRun, type Root, type RootContent, type Table, TableBlock, type TableBlockProps, type TableCell, type TableRow, Text, type TextProps, type ThematicBreak, type TokenType, VStack, type VStackProps, baseFormat, clearParseCache, clearRichTextCache, composeFont, defaultComponents, defaultTheme, defaultTokenColors, defineMarkdownComponents, definePrimitive, flattenInline, parseMarkdown, profileFor, registerLanguage, renderMarkdown, resolveTheme };
package/dist/index.d.mts CHANGED
@@ -110,6 +110,21 @@ interface MarkdownTheme {
110
110
  * change a block's measured height.
111
111
  */
112
112
  highlight: CodeTokenColors | false;
113
+ /**
114
+ * Optional chrome bar above the code — the language on the left, a
115
+ * copy-to-clipboard button on the right. `show: false` (the default) keeps
116
+ * the bare `<pre>`. When shown, the bar's fixed `height` is folded into the
117
+ * block's measured height, so computed and painted heights stay identical.
118
+ */
119
+ header: {
120
+ /** Render the chrome bar. Off by default. */show: boolean; /** Fixed bar height in px (counted in the measured height). */
121
+ height: number; /** Label + button font size in px. */
122
+ fontSize: number; /** Bar background. */
123
+ background: string; /** Label + button text colour. */
124
+ color: string; /** Bottom hairline + button border colour. */
125
+ borderColor: string; /** Copy-button fill. */
126
+ buttonBackground: string;
127
+ };
113
128
  };
114
129
  blockquote: {
115
130
  padding: number;
@@ -354,6 +369,13 @@ declare function renderMarkdown(source: string, options?: RenderMarkdownOptions)
354
369
  interface MarkdownProps extends RenderMarkdownOptions {
355
370
  /** The markdown source to render. */
356
371
  source: string;
372
+ /**
373
+ * Fade just-arrived text in as the source streams. The DOM still commits and
374
+ * lays out instantly (heights stay exact); a veil over new characters
375
+ * dissolves, which reads as a fade-in. Leaving it on for a settled block is
376
+ * free. Honours `prefers-reduced-motion`.
377
+ */
378
+ fade?: boolean;
357
379
  }
358
380
  /**
359
381
  * Render markdown as a tree of mugen primitives.
@@ -383,8 +405,9 @@ declare namespace Markdown {
383
405
  //#region src/primitives/code-block.d.ts
384
406
  /**
385
407
  * A fenced code block. Code does not wrap — long lines scroll horizontally — so
386
- * its height is simply `lineCount × lineHeight + 2 × padding`, independent of the
387
- * row width. That makes it trivially and exactly measurable.
408
+ * its height is simply `lineCount × lineHeight + 2 × padding` (plus an optional
409
+ * fixed-height {@link CodeBlockHeader} bar), independent of the row width. That
410
+ * makes it trivially and exactly measurable.
388
411
  *
389
412
  * Syntax highlighting is layered on as pure paint, never layout: the `<code>`
390
413
  * text renders immediately (plain, selectable, accessible), the language is
@@ -393,6 +416,27 @@ declare namespace Markdown {
393
416
  * turns `color: transparent` in the same frame. Highlighting therefore can't
394
417
  * block first paint and can't ever change the measured height.
395
418
  */
419
+ /**
420
+ * Optional chrome bar above the code: the language on the left, a
421
+ * copy-to-clipboard button on the right. Its fixed `height` is added to the
422
+ * block's measured height, so the block still measures exactly what it paints.
423
+ * Pure decoration otherwise — the bar never wraps and never grows.
424
+ */
425
+ interface CodeBlockHeader {
426
+ /** Left-aligned label; falls back to {@link CodeBlockProps.lang}, then `code`. */
427
+ label?: string;
428
+ /** Fixed bar height in px (folded into the measured height). */
429
+ height: number;
430
+ /** Label + button font size in px. */
431
+ fontSize: number;
432
+ /** Monospace family for the label/button; defaults to `monospace`. */
433
+ fontFamily?: string;
434
+ background?: string;
435
+ color?: string;
436
+ borderColor?: string;
437
+ /** Copy-button fill. */
438
+ buttonBackground?: string;
439
+ }
396
440
  interface CodeBlockProps<C extends string = string> {
397
441
  /** Raw code text. Newlines determine the line count. */
398
442
  value: string;
@@ -413,6 +457,11 @@ interface CodeBlockProps<C extends string = string> {
413
457
  * registered profile are highlighted either way.
414
458
  */
415
459
  highlight?: Partial<CodeTokenColors> | false;
460
+ /**
461
+ * A chrome bar above the code (language label + copy button). Omit for the
462
+ * bare `<pre>`. Its `height` is folded into the measured height.
463
+ */
464
+ header?: CodeBlockHeader;
416
465
  style?: MeasurableStyle;
417
466
  className?: SafeClassName<C>;
418
467
  }
@@ -454,6 +503,18 @@ interface TableBlockProps<C extends string = string> {
454
503
  /** A measurable GFM-table primitive with aligned, content-proportional columns. */
455
504
  declare const TableBlock: <C extends string = string>(props: TableBlockProps<C>) => ReactElement;
456
505
  //#endregion
506
+ //#region src/primitives/fade.d.ts
507
+ interface FadeMarkdownProps {
508
+ /** The rendered markdown subtree to veil. */
509
+ children?: ReactNode;
510
+ }
511
+ /**
512
+ * Wraps a measured markdown subtree with a streaming fade-in canvas. Its height
513
+ * is exactly the subtree's height — the canvas overlay is out of flow and never
514
+ * measured.
515
+ */
516
+ declare const FadeMarkdown: (props: FadeMarkdownProps) => ReactElement;
517
+ //#endregion
457
518
  //#region src/highlight/languages.d.ts
458
519
  /**
459
520
  * Language profiles for the built-in tokenizer. A profile is a small data
@@ -495,4 +556,4 @@ declare function registerLanguage(names: string | readonly string[], p: Language
495
556
  /** The profile for a fence language, or `null` when the language is unknown. */
496
557
  declare function profileFor(lang: string | undefined): LanguageProfile | null;
497
558
  //#endregion
498
- export { type Blockquote, type BoxProps, type Code, CodeBlock, type CodeBlockProps, type CodeTokenColors, type DeepPartial, type Font, HStack, type HStackProps, type Heading, type Html, type Image, type InlineFormat, type InlineTextOptions, type LanguageProfile, type Link, type List, type ListItem, Markdown, type MarkdownComponent, type MarkdownComponentProps, type MarkdownComponents, type MarkdownParseOptions, type MarkdownProps, type MarkdownRenderContext, type MarkdownTheme, type Paragraph, type PhrasingContent, type PrimitiveComponent, type RenderMarkdownOptions, type ResolvedMarkdownComponents, RichText, type RichTextProps, type RichTextRun, type Root, type RootContent, type Table, TableBlock, type TableBlockProps, type TableCell, type TableRow, Text, type TextProps, type ThematicBreak, type TokenType, VStack, type VStackProps, baseFormat, clearParseCache, clearRichTextCache, composeFont, defaultComponents, defaultTheme, defaultTokenColors, defineMarkdownComponents, definePrimitive, flattenInline, parseMarkdown, profileFor, registerLanguage, renderMarkdown, resolveTheme };
559
+ export { type Blockquote, type BoxProps, type Code, CodeBlock, type CodeBlockHeader, type CodeBlockProps, type CodeTokenColors, type DeepPartial, FadeMarkdown, type Font, HStack, type HStackProps, type Heading, type Html, type Image, type InlineFormat, type InlineTextOptions, type LanguageProfile, type Link, type List, type ListItem, Markdown, type MarkdownComponent, type MarkdownComponentProps, type MarkdownComponents, type MarkdownParseOptions, type MarkdownProps, type MarkdownRenderContext, type MarkdownTheme, type Paragraph, type PhrasingContent, type PrimitiveComponent, type RenderMarkdownOptions, type ResolvedMarkdownComponents, RichText, type RichTextProps, type RichTextRun, type Root, type RootContent, type Table, TableBlock, type TableBlockProps, type TableCell, type TableRow, Text, type TextProps, type ThematicBreak, type TokenType, VStack, type VStackProps, baseFormat, clearParseCache, clearRichTextCache, composeFont, defaultComponents, defaultTheme, defaultTokenColors, defineMarkdownComponents, definePrimitive, flattenInline, parseMarkdown, profileFor, registerLanguage, renderMarkdown, resolveTheme };
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { Fragment, createElement, useEffect, useLayoutEffect, useRef } from "react";
2
- import { HStack, HStack as HStack$1, Text, VStack, VStack as VStack$1, assertMeasurableFont, definePrimitive, definePrimitive as definePrimitive$1, fontEpoch, fontLonghands, fontWithLineHeight, markPrimitive, naturalWidthOf } from "@wingleeio/mugen";
1
+ import { Fragment, createElement, useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { HStack, HStack as HStack$1, Text, VStack, VStack as VStack$1, assertMeasurableFont, definePrimitive, definePrimitive as definePrimitive$1, fontEpoch, fontLonghands, fontWithLineHeight, markPrimitive, measureChildren, naturalWidthOf } from "@wingleeio/mugen";
3
3
  import { createIncremarkParser } from "@incremark/core";
4
4
  import { measureRichInlineStats, prepareRichInline } from "@chenglou/pretext/rich-inline";
5
5
  //#region src/parse.ts
@@ -161,7 +161,16 @@ const defaultTheme = {
161
161
  background: "rgba(127, 127, 127, 0.12)",
162
162
  color: "inherit",
163
163
  radius: 8,
164
- highlight: defaultTokenColors
164
+ highlight: defaultTokenColors,
165
+ header: {
166
+ show: false,
167
+ height: 38,
168
+ fontSize: 12,
169
+ background: "rgba(127, 127, 127, 0.06)",
170
+ color: "rgba(127, 127, 127, 0.85)",
171
+ borderColor: "rgba(127, 127, 127, 0.18)",
172
+ buttonBackground: "rgba(127, 127, 127, 0.04)"
173
+ }
165
174
  },
166
175
  blockquote: {
167
176
  padding: 14,
@@ -1310,7 +1319,7 @@ function lineCount(value) {
1310
1319
  function measureCodeBlock(props, ctx) {
1311
1320
  assertMeasurableFont(props.font);
1312
1321
  const pad = props.padding ?? 0;
1313
- return lineCount(props.value) * props.lineHeight + 2 * pad;
1322
+ return (props.header ? props.header.height : 0) + lineCount(props.value) * props.lineHeight + 2 * pad;
1314
1323
  }
1315
1324
  const colorsCache = /* @__PURE__ */ new WeakMap();
1316
1325
  function resolveTokenColors(overrides) {
@@ -1374,9 +1383,124 @@ function HighlightedCode(props) {
1374
1383
  style: overlayStyle
1375
1384
  }));
1376
1385
  }
1386
+ function legacyCopy(text) {
1387
+ if (typeof document === "undefined") return false;
1388
+ const ta = document.createElement("textarea");
1389
+ ta.value = text;
1390
+ ta.setAttribute("readonly", "");
1391
+ ta.style.position = "fixed";
1392
+ ta.style.top = "0";
1393
+ ta.style.left = "0";
1394
+ ta.style.opacity = "0";
1395
+ ta.style.pointerEvents = "none";
1396
+ document.body.appendChild(ta);
1397
+ ta.select();
1398
+ let ok = false;
1399
+ try {
1400
+ ok = document.execCommand("copy");
1401
+ } catch {
1402
+ ok = false;
1403
+ }
1404
+ document.body.removeChild(ta);
1405
+ return ok;
1406
+ }
1407
+ async function copyText(text) {
1408
+ const clip = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
1409
+ if (clip?.writeText != null) try {
1410
+ await clip.writeText(text);
1411
+ return true;
1412
+ } catch {}
1413
+ return legacyCopy(text);
1414
+ }
1415
+ /** Copies the block's raw text; flips to "Copied" for ~1.6s on success. */
1416
+ function CopyButton(props) {
1417
+ const [copied, setCopied] = useState(false);
1418
+ const [hovered, setHovered] = useState(false);
1419
+ const timer = useRef(null);
1420
+ useEffect(() => () => {
1421
+ if (timer.current != null) clearTimeout(timer.current);
1422
+ }, []);
1423
+ const onClick = () => {
1424
+ copyText(props.value).then((ok) => {
1425
+ if (!ok) return;
1426
+ setCopied(true);
1427
+ if (timer.current != null) clearTimeout(timer.current);
1428
+ timer.current = setTimeout(() => setCopied(false), 1600);
1429
+ }).catch(() => {});
1430
+ };
1431
+ const style = {
1432
+ display: "inline-flex",
1433
+ flex: "0 0 auto",
1434
+ alignItems: "center",
1435
+ justifyContent: "center",
1436
+ whiteSpace: "nowrap",
1437
+ cursor: "pointer",
1438
+ borderRadius: 8,
1439
+ border: `1px solid ${props.borderColor ?? "rgba(127, 127, 127, 0.2)"}`,
1440
+ ...props.background != null ? { background: props.background } : null,
1441
+ ...props.color != null ? { color: props.color } : null,
1442
+ padding: "4px 9px",
1443
+ minWidth: "4.6em",
1444
+ fontFamily: "inherit",
1445
+ fontSize: `${props.fontSize}px`,
1446
+ lineHeight: 1,
1447
+ opacity: hovered || copied ? 1 : .82,
1448
+ transition: "opacity 120ms ease"
1449
+ };
1450
+ return createElement("button", {
1451
+ type: "button",
1452
+ onClick,
1453
+ onMouseEnter: () => setHovered(true),
1454
+ onMouseLeave: () => setHovered(false),
1455
+ "aria-label": copied ? "Copied" : "Copy code",
1456
+ style
1457
+ }, copied ? "Copied" : "Copy");
1458
+ }
1459
+ /** The fixed-height chrome bar: language label left, copy button right. */
1460
+ function CodeHeader(props) {
1461
+ const barStyle = {
1462
+ display: "flex",
1463
+ flex: "0 0 auto",
1464
+ alignItems: "center",
1465
+ justifyContent: "space-between",
1466
+ gap: 8,
1467
+ height: `${props.height}px`,
1468
+ boxSizing: "border-box",
1469
+ padding: "0 12px",
1470
+ fontFamily: props.fontFamily,
1471
+ ...props.radius != null ? {
1472
+ borderTopLeftRadius: `${props.radius}px`,
1473
+ borderTopRightRadius: `${props.radius}px`
1474
+ } : null,
1475
+ ...props.background != null ? { background: props.background } : null,
1476
+ ...props.borderColor != null ? { borderBottom: `1px solid ${props.borderColor}` } : null
1477
+ };
1478
+ const labelStyle = {
1479
+ minWidth: 0,
1480
+ overflow: "hidden",
1481
+ textOverflow: "ellipsis",
1482
+ whiteSpace: "nowrap",
1483
+ fontSize: `${props.fontSize}px`,
1484
+ letterSpacing: "0.02em",
1485
+ fontVariantLigatures: "none",
1486
+ ...props.color != null ? { color: props.color } : null
1487
+ };
1488
+ return createElement("div", { style: barStyle }, createElement("span", {
1489
+ key: "lang",
1490
+ style: labelStyle
1491
+ }, props.label), createElement(CopyButton, {
1492
+ key: "copy",
1493
+ value: props.value,
1494
+ fontSize: props.fontSize,
1495
+ color: props.color,
1496
+ borderColor: props.borderColor,
1497
+ background: props.buttonBackground
1498
+ }));
1499
+ }
1377
1500
  function renderCodeBlock(props) {
1378
1501
  const pad = props.padding ?? 0;
1379
1502
  const profile = props.highlight === false ? null : profileFor(props.lang);
1503
+ const header = props.header;
1380
1504
  const preStyle = {
1381
1505
  margin: 0,
1382
1506
  padding: `${pad}px`,
@@ -1385,7 +1509,10 @@ function renderCodeBlock(props) {
1385
1509
  boxSizing: "border-box",
1386
1510
  ...props.background != null ? { background: props.background } : null,
1387
1511
  ...props.color != null ? { color: props.color } : null,
1388
- ...props.radius != null ? { borderRadius: `${props.radius}px` } : null,
1512
+ ...props.radius != null ? header == null ? { borderRadius: `${props.radius}px` } : {
1513
+ borderBottomLeftRadius: `${props.radius}px`,
1514
+ borderBottomRightRadius: `${props.radius}px`
1515
+ } : null,
1389
1516
  ...profile != null ? {
1390
1517
  position: "relative",
1391
1518
  tabSize: 8
@@ -1398,17 +1525,13 @@ function renderCodeBlock(props) {
1398
1525
  margin: 0,
1399
1526
  padding: 0
1400
1527
  };
1401
- if (profile == null) return createElement("pre", {
1402
- className: props.className,
1528
+ const pre = createElement("pre", {
1529
+ ...header == null ? { className: props.className } : null,
1403
1530
  style: preStyle
1404
- }, createElement("code", {
1531
+ }, profile == null ? createElement("code", {
1405
1532
  style: codeStyle,
1406
1533
  ...props.lang ? { "data-lang": props.lang } : null
1407
- }, props.value));
1408
- return createElement("pre", {
1409
- className: props.className,
1410
- style: preStyle
1411
- }, createElement(HighlightedCode, {
1534
+ }, props.value) : createElement(HighlightedCode, {
1412
1535
  value: props.value,
1413
1536
  lang: props.lang,
1414
1537
  font: props.font,
@@ -1418,6 +1541,20 @@ function renderCodeBlock(props) {
1418
1541
  colors: resolveTokenColors(props.highlight === false ? void 0 : props.highlight),
1419
1542
  codeStyle
1420
1543
  }));
1544
+ if (header == null) return pre;
1545
+ return createElement("div", { className: props.className }, createElement(CodeHeader, {
1546
+ key: "header",
1547
+ label: header.label ?? props.lang ?? "code",
1548
+ value: props.value,
1549
+ height: header.height,
1550
+ fontSize: header.fontSize,
1551
+ fontFamily: header.fontFamily ?? "monospace",
1552
+ ...props.radius != null ? { radius: props.radius } : null,
1553
+ background: header.background,
1554
+ color: header.color,
1555
+ borderColor: header.borderColor,
1556
+ buttonBackground: header.buttonBackground
1557
+ }), pre);
1421
1558
  }
1422
1559
  /** A measurable fenced-code primitive (no wrapping; height from line count). */
1423
1560
  const CodeBlock = markPrimitive(renderCodeBlock, {
@@ -1631,7 +1768,16 @@ const defaultComponents = {
1631
1768
  background: c.background,
1632
1769
  radius: c.radius,
1633
1770
  highlight: c.highlight,
1634
- ...c.color !== "inherit" ? { color: c.color } : null
1771
+ ...c.color !== "inherit" ? { color: c.color } : null,
1772
+ ...c.header.show ? { header: {
1773
+ height: c.header.height,
1774
+ fontSize: c.header.fontSize,
1775
+ fontFamily: ctx.theme.monoFamily,
1776
+ background: c.header.background,
1777
+ color: c.header.color,
1778
+ borderColor: c.header.borderColor,
1779
+ buttonBackground: c.header.buttonBackground
1780
+ } } : null
1635
1781
  });
1636
1782
  },
1637
1783
  list: ({ node, ctx }) => renderList(node, ctx),
@@ -1816,6 +1962,256 @@ function renderMarkdown(source, options = {}) {
1816
1962
  return createContext(resolveTheme(options.theme), mergeComponents(options.components)).renderBlocks(ast.children);
1817
1963
  }
1818
1964
  //#endregion
1965
+ //#region src/primitives/fade.tsx
1966
+ /**
1967
+ * Streaming fade-in for `<Markdown fade>`.
1968
+ *
1969
+ * The same trick the code highlighter uses, pointed at motion instead of
1970
+ * colour: the markdown DOM commits and lays out instantly (so heights,
1971
+ * selection, and stick-to-bottom stay honest), and a background-coloured veil
1972
+ * is painted over just-arrived characters and dissolved — which *reads* as the
1973
+ * text fading in. Nothing about the row ever animates; layout is done the
1974
+ * moment the text lands.
1975
+ *
1976
+ * Unlike a list-level overlay, the veil canvas lives **inside** the markdown's
1977
+ * own box (`position: absolute; inset: 0`), so it scrolls with the content and
1978
+ * needs no viewport-geometry tracking. The painter idles (no rAF) until a
1979
+ * DOM mutation arrives, so leaving `fade` on for a settled block costs nothing.
1980
+ */
1981
+ const EMA_SEED_MS = 160;
1982
+ const MIN_FADE_MS = 120;
1983
+ const MAX_FADE_MS = 400;
1984
+ const MAX_VEILS = 32;
1985
+ function commonPrefixLength(a, b) {
1986
+ const n = Math.min(a.length, b.length);
1987
+ let i = 0;
1988
+ while (i < n && a.charCodeAt(i) === b.charCodeAt(i)) i++;
1989
+ return i;
1990
+ }
1991
+ function inChrome(node, container) {
1992
+ for (let p = node.parentElement; p != null && p !== container; p = p.parentElement) if (p.tagName === "BUTTON") return true;
1993
+ return false;
1994
+ }
1995
+ function contentTextFilter(container) {
1996
+ return { acceptNode: (n) => inChrome(n, container) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT };
1997
+ }
1998
+ /** `container.textContent`, minus interactive chrome (see {@link inChrome}). */
1999
+ function contentText(container) {
2000
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, contentTextFilter(container));
2001
+ let s = "";
2002
+ for (let n = walker.nextNode(); n != null; n = walker.nextNode()) s += n.data;
2003
+ return s;
2004
+ }
2005
+ let scratch;
2006
+ function scratchCtx() {
2007
+ if (scratch === void 0) {
2008
+ const c = typeof document === "undefined" ? null : document.createElement("canvas");
2009
+ if (c == null) return scratch = null;
2010
+ c.width = 1;
2011
+ c.height = 1;
2012
+ scratch = c.getContext("2d", { willReadFrequently: true });
2013
+ }
2014
+ return scratch;
2015
+ }
2016
+ /** The opaque colour behind `el`: every ancestor's backgroundColor composited. */
2017
+ function effectiveBackground(el, cache) {
2018
+ const cached = cache.get(el);
2019
+ if (cached !== void 0) return cached;
2020
+ const layers = [];
2021
+ for (let cur = el; cur != null; cur = cur.parentElement) {
2022
+ const bg = getComputedStyle(cur).backgroundColor;
2023
+ if (bg && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)") layers.push(bg);
2024
+ }
2025
+ const s = scratchCtx();
2026
+ if (s == null) return "#808080";
2027
+ s.globalAlpha = 1;
2028
+ s.fillStyle = "#ffffff";
2029
+ s.fillRect(0, 0, 1, 1);
2030
+ for (let i = layers.length - 1; i >= 0; i--) {
2031
+ try {
2032
+ s.fillStyle = layers[i];
2033
+ } catch {
2034
+ continue;
2035
+ }
2036
+ s.fillRect(0, 0, 1, 1);
2037
+ }
2038
+ const d = s.getImageData(0, 0, 1, 1).data;
2039
+ const css = `rgb(${d[0]}, ${d[1]}, ${d[2]})`;
2040
+ cache.set(el, css);
2041
+ return css;
2042
+ }
2043
+ /**
2044
+ * Paints the dissolving veil over a single content element's newly-arrived text.
2045
+ * Driven by a MutationObserver: a rAF loop runs only while veils are alive, then
2046
+ * stops until the next mutation, so an idle (settled) block uses no frames.
2047
+ */
2048
+ var FadePainter = class {
2049
+ content = null;
2050
+ canvas = null;
2051
+ ctx = null;
2052
+ prevText = "";
2053
+ veils = [];
2054
+ ema = EMA_SEED_MS;
2055
+ lastAppend = 0;
2056
+ raf = 0;
2057
+ running = false;
2058
+ mo = null;
2059
+ attach(content, canvas) {
2060
+ if (typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
2061
+ const ctx = canvas.getContext("2d");
2062
+ if (ctx == null) return;
2063
+ this.content = content;
2064
+ this.canvas = canvas;
2065
+ this.ctx = ctx;
2066
+ this.prevText = contentText(content);
2067
+ this.mo = new MutationObserver(() => this.wake());
2068
+ this.mo.observe(content, {
2069
+ subtree: true,
2070
+ childList: true,
2071
+ characterData: true
2072
+ });
2073
+ }
2074
+ destroy() {
2075
+ this.mo?.disconnect();
2076
+ this.mo = null;
2077
+ if (this.raf !== 0) cancelAnimationFrame(this.raf);
2078
+ this.raf = 0;
2079
+ this.running = false;
2080
+ this.content = null;
2081
+ this.canvas = null;
2082
+ this.ctx = null;
2083
+ }
2084
+ wake() {
2085
+ if (this.running || this.content == null) return;
2086
+ this.running = true;
2087
+ this.raf = requestAnimationFrame(this.frame);
2088
+ }
2089
+ frame = () => {
2090
+ const content = this.content;
2091
+ const canvas = this.canvas;
2092
+ const ctx = this.ctx;
2093
+ if (content == null || canvas == null || ctx == null) {
2094
+ this.running = false;
2095
+ return;
2096
+ }
2097
+ const now = performance.now();
2098
+ const text = contentText(content);
2099
+ if (text !== this.prevText) {
2100
+ const prefix = commonPrefixLength(this.prevText, text);
2101
+ this.veils = this.veils.map((v) => ({
2102
+ ...v,
2103
+ start: Math.min(v.start, prefix),
2104
+ end: Math.min(v.end, prefix)
2105
+ })).filter((v) => v.end > v.start);
2106
+ if (text.length > prefix) {
2107
+ if (this.lastAppend > 0) this.ema = this.ema * .7 + Math.min(now - this.lastAppend, 1e3) * .3;
2108
+ this.lastAppend = now;
2109
+ this.veils.push({
2110
+ start: prefix,
2111
+ end: text.length,
2112
+ t0: now
2113
+ });
2114
+ if (this.veils.length > MAX_VEILS) this.veils.splice(0, this.veils.length - MAX_VEILS);
2115
+ }
2116
+ this.prevText = text;
2117
+ }
2118
+ const duration = Math.min(MAX_FADE_MS, Math.max(MIN_FADE_MS, this.ema * 3));
2119
+ const boost = 1 + .3 * Math.max(0, this.veils.length - 2);
2120
+ this.veils = this.veils.filter((v) => (now - v.t0) * boost < duration);
2121
+ const dpr = typeof devicePixelRatio === "number" && devicePixelRatio > 0 ? devicePixelRatio : 1;
2122
+ const w = content.clientWidth;
2123
+ const h = content.clientHeight;
2124
+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
2125
+ canvas.width = Math.round(w * dpr);
2126
+ canvas.height = Math.round(h * dpr);
2127
+ }
2128
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2129
+ ctx.clearRect(0, 0, w, h);
2130
+ if (this.veils.length > 0) {
2131
+ const origin = canvas.getBoundingClientRect();
2132
+ const bgCache = /* @__PURE__ */ new Map();
2133
+ const groups = /* @__PURE__ */ new Map();
2134
+ const minStart = this.veils.reduce((m, v) => Math.min(m, v.start), Infinity);
2135
+ const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT, contentTextFilter(content));
2136
+ let base = 0;
2137
+ let node = walker.nextNode();
2138
+ while (node != null) {
2139
+ const len = node.data.length;
2140
+ if (len > 0 && base + len > minStart) for (let vi = 0; vi < this.veils.length; vi++) {
2141
+ const v = this.veils[vi];
2142
+ const s = Math.max(v.start - base, 0);
2143
+ const e = Math.min(v.end - base, len);
2144
+ if (e <= s) continue;
2145
+ const parent = node.parentElement;
2146
+ if (parent == null) continue;
2147
+ const bg = effectiveBackground(parent, bgCache);
2148
+ const key = `${vi}|${bg}`;
2149
+ let group = groups.get(key);
2150
+ if (group === void 0) {
2151
+ const p = Math.min(1, (now - v.t0) * boost / duration);
2152
+ group = {
2153
+ alpha: Math.pow(1 - p, 1.6),
2154
+ bg,
2155
+ path: new Path2D()
2156
+ };
2157
+ groups.set(key, group);
2158
+ }
2159
+ const range = document.createRange();
2160
+ range.setStart(node, s);
2161
+ range.setEnd(node, e);
2162
+ for (const r of range.getClientRects()) group.path.rect(r.left - origin.left - 1, r.top - origin.top - 1, r.width + 2, r.height + 2);
2163
+ }
2164
+ base += len;
2165
+ node = walker.nextNode();
2166
+ }
2167
+ for (const group of groups.values()) {
2168
+ ctx.globalAlpha = group.alpha;
2169
+ ctx.fillStyle = group.bg;
2170
+ ctx.fill(group.path);
2171
+ }
2172
+ ctx.globalAlpha = 1;
2173
+ }
2174
+ if (this.veils.length > 0) this.raf = requestAnimationFrame(this.frame);
2175
+ else {
2176
+ this.running = false;
2177
+ this.raf = 0;
2178
+ }
2179
+ };
2180
+ };
2181
+ function renderFadeMarkdown(props) {
2182
+ const contentRef = useRef(null);
2183
+ const canvasRef = useRef(null);
2184
+ useEffect(() => {
2185
+ const content = contentRef.current;
2186
+ const canvas = canvasRef.current;
2187
+ if (content == null || canvas == null) return;
2188
+ const painter = new FadePainter();
2189
+ painter.attach(content, canvas);
2190
+ return () => painter.destroy();
2191
+ }, []);
2192
+ return createElement("div", { style: { position: "relative" } }, createElement("div", { ref: contentRef }, props.children), createElement("canvas", {
2193
+ ref: canvasRef,
2194
+ "aria-hidden": true,
2195
+ style: {
2196
+ position: "absolute",
2197
+ inset: 0,
2198
+ width: "100%",
2199
+ height: "100%",
2200
+ pointerEvents: "none"
2201
+ }
2202
+ }));
2203
+ }
2204
+ /**
2205
+ * Wraps a measured markdown subtree with a streaming fade-in canvas. Its height
2206
+ * is exactly the subtree's height — the canvas overlay is out of flow and never
2207
+ * measured.
2208
+ */
2209
+ const FadeMarkdown = markPrimitive(renderFadeMarkdown, {
2210
+ name: "FadeMarkdown",
2211
+ measure: (props, ctx) => measureChildren(props.children, ctx),
2212
+ naturalWidth: (props, ctx) => naturalWidthOf(props.children, ctx)
2213
+ });
2214
+ //#endregion
1819
2215
  //#region src/markdown.tsx
1820
2216
  /**
1821
2217
  * Render markdown as a tree of mugen primitives.
@@ -1838,7 +2234,9 @@ function renderMarkdown(source, options = {}) {
1838
2234
  * and a deep-partial `theme`.
1839
2235
  */
1840
2236
  function Markdown(props) {
1841
- return renderMarkdown(props.source, props);
2237
+ const content = renderMarkdown(props.source, props);
2238
+ if (props.fade && content != null) return createElement(FadeMarkdown, null, content);
2239
+ return content;
1842
2240
  }
1843
2241
  Markdown.displayName = "Markdown";
1844
2242
  //#endregion
@@ -1858,4 +2256,4 @@ function defineMarkdownComponents(components) {
1858
2256
  return components;
1859
2257
  }
1860
2258
  //#endregion
1861
- export { CodeBlock, HStack, Markdown, RichText, TableBlock, Text, VStack, baseFormat, clearParseCache, clearRichTextCache, composeFont, defaultComponents, defaultTheme, defaultTokenColors, defineMarkdownComponents, definePrimitive, flattenInline, parseMarkdown, profileFor, registerLanguage, renderMarkdown, resolveTheme };
2259
+ export { CodeBlock, FadeMarkdown, HStack, Markdown, RichText, TableBlock, Text, VStack, baseFormat, clearParseCache, clearRichTextCache, composeFont, defaultComponents, defaultTheme, defaultTokenColors, defineMarkdownComponents, definePrimitive, flattenInline, parseMarkdown, profileFor, registerLanguage, renderMarkdown, resolveTheme };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wingleeio/mugen-markdown",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Measurable markdown for mugen — incremark-parsed, rendered with mugen primitives so the virtualizer's tree walker computes exact row heights.",
5
5
  "license": "MIT",
6
6
  "author": "Wing Lee <contact@winglee.io>",
@@ -65,7 +65,7 @@
65
65
  "tsdown": "^0.22.2",
66
66
  "typescript": "^6.0.3",
67
67
  "vitest": "^4.1.8",
68
- "@wingleeio/mugen": "0.3.0"
68
+ "@wingleeio/mugen": "0.3.4"
69
69
  },
70
70
  "scripts": {
71
71
  "build": "tsdown",