@wingleeio/mugen-markdown 0.1.0 → 0.2.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),
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;
@@ -383,8 +398,9 @@ declare namespace Markdown {
383
398
  //#region src/primitives/code-block.d.ts
384
399
  /**
385
400
  * 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.
401
+ * its height is simply `lineCount × lineHeight + 2 × padding` (plus an optional
402
+ * fixed-height {@link CodeBlockHeader} bar), independent of the row width. That
403
+ * makes it trivially and exactly measurable.
388
404
  *
389
405
  * Syntax highlighting is layered on as pure paint, never layout: the `<code>`
390
406
  * text renders immediately (plain, selectable, accessible), the language is
@@ -393,6 +409,27 @@ declare namespace Markdown {
393
409
  * turns `color: transparent` in the same frame. Highlighting therefore can't
394
410
  * block first paint and can't ever change the measured height.
395
411
  */
412
+ /**
413
+ * Optional chrome bar above the code: the language on the left, a
414
+ * copy-to-clipboard button on the right. Its fixed `height` is added to the
415
+ * block's measured height, so the block still measures exactly what it paints.
416
+ * Pure decoration otherwise — the bar never wraps and never grows.
417
+ */
418
+ interface CodeBlockHeader {
419
+ /** Left-aligned label; falls back to {@link CodeBlockProps.lang}, then `code`. */
420
+ label?: string;
421
+ /** Fixed bar height in px (folded into the measured height). */
422
+ height: number;
423
+ /** Label + button font size in px. */
424
+ fontSize: number;
425
+ /** Monospace family for the label/button; defaults to `monospace`. */
426
+ fontFamily?: string;
427
+ background?: string;
428
+ color?: string;
429
+ borderColor?: string;
430
+ /** Copy-button fill. */
431
+ buttonBackground?: string;
432
+ }
396
433
  interface CodeBlockProps<C extends string = string> {
397
434
  /** Raw code text. Newlines determine the line count. */
398
435
  value: string;
@@ -413,6 +450,11 @@ interface CodeBlockProps<C extends string = string> {
413
450
  * registered profile are highlighted either way.
414
451
  */
415
452
  highlight?: Partial<CodeTokenColors> | false;
453
+ /**
454
+ * A chrome bar above the code (language label + copy button). Omit for the
455
+ * bare `<pre>`. Its `height` is folded into the measured height.
456
+ */
457
+ header?: CodeBlockHeader;
416
458
  style?: MeasurableStyle;
417
459
  className?: SafeClassName<C>;
418
460
  }
@@ -495,4 +537,4 @@ declare function registerLanguage(names: string | readonly string[], p: Language
495
537
  /** The profile for a fence language, or `null` when the language is unknown. */
496
538
  declare function profileFor(lang: string | undefined): LanguageProfile | null;
497
539
  //#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 };
540
+ export { type Blockquote, type BoxProps, type Code, CodeBlock, type CodeBlockHeader, 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 };
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;
@@ -383,8 +398,9 @@ declare namespace Markdown {
383
398
  //#region src/primitives/code-block.d.ts
384
399
  /**
385
400
  * 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.
401
+ * its height is simply `lineCount × lineHeight + 2 × padding` (plus an optional
402
+ * fixed-height {@link CodeBlockHeader} bar), independent of the row width. That
403
+ * makes it trivially and exactly measurable.
388
404
  *
389
405
  * Syntax highlighting is layered on as pure paint, never layout: the `<code>`
390
406
  * text renders immediately (plain, selectable, accessible), the language is
@@ -393,6 +409,27 @@ declare namespace Markdown {
393
409
  * turns `color: transparent` in the same frame. Highlighting therefore can't
394
410
  * block first paint and can't ever change the measured height.
395
411
  */
412
+ /**
413
+ * Optional chrome bar above the code: the language on the left, a
414
+ * copy-to-clipboard button on the right. Its fixed `height` is added to the
415
+ * block's measured height, so the block still measures exactly what it paints.
416
+ * Pure decoration otherwise — the bar never wraps and never grows.
417
+ */
418
+ interface CodeBlockHeader {
419
+ /** Left-aligned label; falls back to {@link CodeBlockProps.lang}, then `code`. */
420
+ label?: string;
421
+ /** Fixed bar height in px (folded into the measured height). */
422
+ height: number;
423
+ /** Label + button font size in px. */
424
+ fontSize: number;
425
+ /** Monospace family for the label/button; defaults to `monospace`. */
426
+ fontFamily?: string;
427
+ background?: string;
428
+ color?: string;
429
+ borderColor?: string;
430
+ /** Copy-button fill. */
431
+ buttonBackground?: string;
432
+ }
396
433
  interface CodeBlockProps<C extends string = string> {
397
434
  /** Raw code text. Newlines determine the line count. */
398
435
  value: string;
@@ -413,6 +450,11 @@ interface CodeBlockProps<C extends string = string> {
413
450
  * registered profile are highlighted either way.
414
451
  */
415
452
  highlight?: Partial<CodeTokenColors> | false;
453
+ /**
454
+ * A chrome bar above the code (language label + copy button). Omit for the
455
+ * bare `<pre>`. Its `height` is folded into the measured height.
456
+ */
457
+ header?: CodeBlockHeader;
416
458
  style?: MeasurableStyle;
417
459
  className?: SafeClassName<C>;
418
460
  }
@@ -495,4 +537,4 @@ declare function registerLanguage(names: string | readonly string[], p: Language
495
537
  /** The profile for a fence language, or `null` when the language is unknown. */
496
538
  declare function profileFor(lang: string | undefined): LanguageProfile | null;
497
539
  //#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 };
540
+ export { type Blockquote, type BoxProps, type Code, CodeBlock, type CodeBlockHeader, 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 };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { Fragment, createElement, useEffect, useLayoutEffect, useRef } from "react";
1
+ import { Fragment, createElement, useEffect, useLayoutEffect, useRef, useState } from "react";
2
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";
3
3
  import { createIncremarkParser } from "@incremark/core";
4
4
  import { measureRichInlineStats, prepareRichInline } from "@chenglou/pretext/rich-inline";
@@ -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),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wingleeio/mugen-markdown",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",