@bendyline/squisq-react 2.4.7 → 2.6.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/README.md CHANGED
@@ -57,6 +57,25 @@ state rather than crashing.
57
57
  | `MediaClipLayer` | Hidden `<audio>`/`<video>` elements for timed media clips |
58
58
  | `JsonView` | Read-only viewer for JSON values bound to a Squisq-annotated schema |
59
59
 
60
+ ### Fenced-code copy control
61
+
62
+ `MarkdownRenderer` and the linear document surfaces keep code-copy UI off by
63
+ default. Opt in with `showCodeCopyButton`. Web hosts can rely on
64
+ `navigator.clipboard`; Electron or native embeddings can provide their own
65
+ clipboard bridge:
66
+
67
+ ````tsx
68
+ <LinearDocView
69
+ markdown={'```\n$ node packages/tooling/dist/cli.mjs components\n```'}
70
+ showCodeCopyButton
71
+ onCopyCode={(code, { language }) => hostClipboard.writeText(code)}
72
+ />
73
+ ````
74
+
75
+ The same two props are available on `DocPlayer` (for linear mode), and on the
76
+ standalone static `mount()` options. The callback receives the exact fence
77
+ contents, without the backtick delimiters.
78
+
60
79
  ## Layers
61
80
 
62
81
  Blocks are composed of typed layers rendered as SVG:
@@ -0,0 +1,53 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { Theme } from '@bendyline/squisq/schemas';
3
+ import { MarkdownBlockNode, HtmlPolicy } from '@bendyline/squisq/markdown';
4
+
5
+ interface CodeBlockCopyContext {
6
+ /** Authored fence language, when one was supplied. */
7
+ language?: string;
8
+ }
9
+ /**
10
+ * Host-owned clipboard adapter for fenced code blocks.
11
+ *
12
+ * The callback runs directly from the button's click handler so Electron
13
+ * bridges and browser Clipboard APIs retain their user-gesture context.
14
+ */
15
+ type CodeBlockCopyHandler = (code: string, context: CodeBlockCopyContext) => void | Promise<void>;
16
+ interface MarkdownRendererProps {
17
+ /** Block-level AST nodes to render */
18
+ nodes: MarkdownBlockNode[];
19
+ /** Optional CSS class for the wrapper element */
20
+ className?: string;
21
+ /**
22
+ * Raw HTML policy. Defaults to `sanitize`, which removes unsafe tags,
23
+ * event handlers, and executable URL schemes before rendering.
24
+ */
25
+ htmlPolicy?: HtmlPolicy;
26
+ /**
27
+ * Extra URL schemes to allow on links (e.g. a host app's internal
28
+ * navigation scheme it intercepts on click). Executable schemes are
29
+ * never allowed regardless. See {@link SanitizeUrlOptions}.
30
+ */
31
+ linkSchemes?: readonly string[];
32
+ /** Resolved Squisq theme inherited by embedded Mermaid diagrams. */
33
+ theme?: Theme;
34
+ /** Show a subtle Copy button on ordinary fenced code blocks (default: false). */
35
+ showCodeCopyButton?: boolean;
36
+ /**
37
+ * Optional host clipboard adapter. When omitted, enabled copy buttons use
38
+ * `navigator.clipboard.writeText`. Supply this for Electron, native shells,
39
+ * or hosts with their own clipboard permission/error handling.
40
+ */
41
+ onCopyCode?: CodeBlockCopyHandler;
42
+ }
43
+ /**
44
+ * Renders MarkdownBlockNode[] AST as React HTML elements.
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * <MarkdownRenderer nodes={block.contents} />
49
+ * ```
50
+ */
51
+ declare function MarkdownRenderer({ nodes, className, htmlPolicy, linkSchemes, theme, showCodeCopyButton, onCopyCode, }: MarkdownRendererProps): react_jsx_runtime.JSX.Element | null;
52
+
53
+ export { type CodeBlockCopyHandler as C, MarkdownRenderer as M, type CodeBlockCopyContext as a, type MarkdownRendererProps as b };
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  BlockRenderer,
3
3
  LinearDocView
4
- } from "./chunk-3HYSWEKG.js";
4
+ } from "./chunk-U6P7HBUY.js";
5
5
  import {
6
6
  useAudioSync,
7
7
  useDocPlayback,
8
8
  useMediaSchedule,
9
9
  useViewportOrientation
10
- } from "./chunk-BOZJ655L.js";
10
+ } from "./chunk-MVJQL2W2.js";
11
11
  import {
12
12
  useAutoSurface
13
13
  } from "./chunk-TT6ENR6T.js";
@@ -1243,7 +1243,10 @@ function DocControlsSlideshow({
1243
1243
  }
1244
1244
 
1245
1245
  // src/docPlayer/playerAppearance.ts
1246
- import { resolveThemeForDoc } from "@bendyline/squisq/doc";
1246
+ import {
1247
+ resolveCoverSlideSettings,
1248
+ resolveThemeForDoc
1249
+ } from "@bendyline/squisq/doc";
1247
1250
  function readFrontmatterSetting(frontmatter, canonical, legacy) {
1248
1251
  if (!frontmatter) return void 0;
1249
1252
  return Object.prototype.hasOwnProperty.call(frontmatter, canonical) ? frontmatter[canonical] : frontmatter[legacy];
@@ -1291,20 +1294,14 @@ function resolvePipPosition(value) {
1291
1294
  };
1292
1295
  return aliases[normalized];
1293
1296
  }
1294
- function resolveBoolean(value) {
1295
- if (typeof value === "boolean") return value;
1296
- if (typeof value !== "string") return void 0;
1297
- const normalized = value.trim().toLowerCase();
1298
- if (normalized === "true" || normalized === "yes" || normalized === "on" || normalized === "show" || normalized === "visible") {
1299
- return true;
1300
- }
1301
- if (normalized === "false" || normalized === "no" || normalized === "off" || normalized === "hide" || normalized === "hidden") {
1302
- return false;
1303
- }
1304
- return void 0;
1305
- }
1306
1297
  function resolveDocPlayerAppearance(doc, overrides = {}) {
1307
1298
  const frontmatter = doc.frontmatter;
1299
+ const cover = resolveCoverSlideSettings(frontmatter, {
1300
+ ...overrides.showCoverSlide !== void 0 ? { enabled: overrides.showCoverSlide } : {},
1301
+ ...overrides.coverSlideTemplate !== void 0 ? { template: overrides.coverSlideTemplate } : {},
1302
+ ...overrides.coverSlideDuration !== void 0 ? { duration: overrides.coverSlideDuration } : {},
1303
+ ...overrides.coverSlidePlayback !== void 0 ? { playback: overrides.coverSlidePlayback } : {}
1304
+ });
1308
1305
  return {
1309
1306
  theme: overrides.theme ?? resolveThemeForDoc(doc),
1310
1307
  videoPresentation: overrides.videoPresentation ?? resolveVideoPresentation(
@@ -1315,7 +1312,10 @@ function resolveDocPlayerAppearance(doc, overrides = {}) {
1315
1312
  pipPosition: overrides.pipPosition ?? resolvePipPosition(
1316
1313
  readFrontmatterSetting(frontmatter, "squisq-pip-position", "pip-position")
1317
1314
  ) ?? "bottom-right",
1318
- showCoverSlide: overrides.showCoverSlide ?? resolveBoolean(readFrontmatterSetting(frontmatter, "squisq-cover-slide", "cover-slide")) ?? true
1315
+ showCoverSlide: cover.enabled,
1316
+ coverSlideTemplate: cover.template,
1317
+ coverSlideDuration: cover.duration,
1318
+ coverSlidePlayback: cover.playback
1319
1319
  };
1320
1320
  }
1321
1321
 
@@ -1567,17 +1567,36 @@ function isVisiblyPresented(video) {
1567
1567
  }
1568
1568
  return true;
1569
1569
  }
1570
+ function createCaptureWatchdog(ownerDocument, timeoutMs, expire) {
1571
+ let timeout = null;
1572
+ const clear = () => {
1573
+ if (timeout === null) return;
1574
+ clearTimeout(timeout);
1575
+ timeout = null;
1576
+ };
1577
+ return {
1578
+ arm: () => {
1579
+ clear();
1580
+ if (ownerDocument.visibilityState !== "visible") return;
1581
+ timeout = setTimeout(expire, timeoutMs);
1582
+ },
1583
+ clear
1584
+ };
1585
+ }
1570
1586
  function seekVideoByAssignment(video, targetTime, timeoutMs = DEFAULT_VIDEO_FRAME_TIMEOUT_MS) {
1571
1587
  video.pause();
1572
1588
  return new Promise((resolve, reject) => {
1573
1589
  let settled = false;
1574
1590
  let videoFrameRequest = null;
1591
+ const ownerDocument = video.ownerDocument;
1592
+ const isInactive = () => ownerDocument.visibilityState !== "visible";
1575
1593
  const cleanup = () => {
1576
- clearTimeout(timeout);
1594
+ watchdog.clear();
1577
1595
  clearInterval(readinessPoll);
1578
1596
  video.removeEventListener("seeked", handleMediaReady);
1579
1597
  video.removeEventListener("loadeddata", handleMediaReady);
1580
1598
  video.removeEventListener("canplay", handleMediaReady);
1599
+ ownerDocument.removeEventListener("visibilitychange", handleVisibilityChange);
1581
1600
  if (videoFrameRequest !== null && typeof video.cancelVideoFrameCallback === "function") {
1582
1601
  video.cancelVideoFrameCallback(videoFrameRequest);
1583
1602
  }
@@ -1615,34 +1634,48 @@ function seekVideoByAssignment(video, targetTime, timeoutMs = DEFAULT_VIDEO_FRAM
1615
1634
  function handleMediaReady() {
1616
1635
  requestPresentedFrame();
1617
1636
  }
1618
- const timeout = setTimeout(fail, timeoutMs);
1637
+ const watchdog = createCaptureWatchdog(ownerDocument, timeoutMs, fail);
1638
+ const assignTargetTime = () => {
1639
+ try {
1640
+ video.currentTime = reachableMediaTime(video, targetTime);
1641
+ queueMicrotask(requestPresentedFrame);
1642
+ return true;
1643
+ } catch (error) {
1644
+ settled = true;
1645
+ cleanup();
1646
+ reject(error);
1647
+ return false;
1648
+ }
1649
+ };
1650
+ function handleVisibilityChange() {
1651
+ if (settled) return;
1652
+ if (isInactive()) {
1653
+ watchdog.clear();
1654
+ return;
1655
+ }
1656
+ requestPresentedFrame();
1657
+ if (settled) return;
1658
+ if (!video.seeking && !isAtReachableMediaTime(video, targetTime) && !assignTargetTime()) {
1659
+ return;
1660
+ }
1661
+ watchdog.arm();
1662
+ }
1619
1663
  const readinessPoll = setInterval(requestPresentedFrame, VIDEO_FRAME_READINESS_POLL_MS);
1620
1664
  video.addEventListener("seeked", handleMediaReady);
1621
1665
  video.addEventListener("loadeddata", handleMediaReady);
1622
1666
  video.addEventListener("canplay", handleMediaReady);
1623
- try {
1624
- video.currentTime = reachableMediaTime(video, targetTime);
1625
- queueMicrotask(requestPresentedFrame);
1626
- } catch (error) {
1627
- settled = true;
1628
- cleanup();
1629
- reject(error);
1630
- }
1667
+ ownerDocument.addEventListener("visibilitychange", handleVisibilityChange);
1668
+ watchdog.arm();
1669
+ assignTargetTime();
1631
1670
  });
1632
1671
  }
1633
1672
  function advanceSequentialCaptureVideo(video, targetTime, timeoutMs) {
1634
1673
  return new Promise((resolve, reject) => {
1635
1674
  let settled = false;
1636
- let timeout = null;
1637
1675
  const ownerDocument = video.ownerDocument;
1638
1676
  const isInactive = () => ownerDocument.visibilityState !== "visible";
1639
- const clearWatchdog = () => {
1640
- if (timeout === null) return;
1641
- clearTimeout(timeout);
1642
- timeout = null;
1643
- };
1644
1677
  const cleanup = () => {
1645
- clearWatchdog();
1678
+ watchdog.clear();
1646
1679
  clearInterval(readinessPoll);
1647
1680
  video.removeEventListener("timeupdate", check);
1648
1681
  video.removeEventListener("loadeddata", check);
@@ -1696,21 +1729,17 @@ function advanceSequentialCaptureVideo(video, targetTime, timeoutMs) {
1696
1729
  handlePlaybackFailure();
1697
1730
  }
1698
1731
  };
1699
- const armWatchdog = () => {
1700
- clearWatchdog();
1701
- if (settled || isInactive()) return;
1702
- timeout = setTimeout(fail, timeoutMs);
1703
- };
1732
+ const watchdog = createCaptureWatchdog(ownerDocument, timeoutMs, fail);
1704
1733
  const pauseWhileInactive = () => {
1705
1734
  video.pause();
1706
- clearWatchdog();
1735
+ watchdog.clear();
1707
1736
  };
1708
1737
  const resumeWhenActive = () => {
1709
1738
  if (settled || isInactive()) return;
1710
1739
  check();
1711
1740
  if (settled) return;
1712
1741
  startPlayback();
1713
- armWatchdog();
1742
+ watchdog.arm();
1714
1743
  };
1715
1744
  function handleVisibilityChange() {
1716
1745
  if (ownerDocument.visibilityState !== "visible") {
@@ -1818,6 +1847,9 @@ function DocPlayerContent({
1818
1847
  forceViewport,
1819
1848
  displayMode = "video",
1820
1849
  showCoverSlide,
1850
+ coverSlideTemplate,
1851
+ coverSlideDuration,
1852
+ coverSlidePlayback,
1821
1853
  coverVisible,
1822
1854
  theme,
1823
1855
  surface,
@@ -1827,7 +1859,9 @@ function DocPlayerContent({
1827
1859
  pipShape,
1828
1860
  pipPosition,
1829
1861
  enableSwipe = true,
1830
- globalKeyboardShortcuts = false
1862
+ globalKeyboardShortcuts = false,
1863
+ showCodeCopyButton = false,
1864
+ onCopyCode
1831
1865
  }) {
1832
1866
  const isSlideshowMode = displayMode === "slideshow";
1833
1867
  const isLinearMode = displayMode === "linear";
@@ -1844,12 +1878,17 @@ function DocPlayerContent({
1844
1878
  const params = new URLSearchParams(window.location.search);
1845
1879
  return params.get("debug") === "true";
1846
1880
  }, []);
1881
+ const syntheticDuration = useMemo3(
1882
+ () => audioMode === "synthetic" ? getDocPlaybackDuration(doc) : 0,
1883
+ [audioMode, doc]
1884
+ );
1847
1885
  const internalAudio = useAudioSync(
1848
1886
  audioRef,
1849
1887
  doc.audio,
1850
1888
  basePath,
1851
1889
  !externalAudioController,
1852
- audioMode
1890
+ audioMode,
1891
+ syntheticDuration
1853
1892
  );
1854
1893
  const audio = externalAudioController || internalAudio;
1855
1894
  useEffect5(() => {
@@ -1973,9 +2012,23 @@ function DocPlayerContent({
1973
2012
  pipSize,
1974
2013
  pipShape,
1975
2014
  pipPosition,
1976
- showCoverSlide
2015
+ showCoverSlide,
2016
+ coverSlideTemplate,
2017
+ coverSlideDuration,
2018
+ coverSlidePlayback
1977
2019
  }),
1978
- [doc, theme, videoPresentation, pipSize, pipShape, pipPosition, showCoverSlide]
2020
+ [
2021
+ doc,
2022
+ theme,
2023
+ videoPresentation,
2024
+ pipSize,
2025
+ pipShape,
2026
+ pipPosition,
2027
+ showCoverSlide,
2028
+ coverSlideTemplate,
2029
+ coverSlideDuration,
2030
+ coverSlidePlayback
2031
+ ]
1979
2032
  );
1980
2033
  const effectiveTheme = useMemo3(() => {
1981
2034
  const base = appearance.theme;
@@ -2018,7 +2071,7 @@ function DocPlayerContent({
2018
2071
  if (!appearance.showCoverSlide) return null;
2019
2072
  if (!startBlockConfig) return null;
2020
2073
  const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
2021
- const layers = expandCoverBlock(startBlockConfig, context);
2074
+ const layers = expandCoverBlock(startBlockConfig, context, appearance.coverSlideTemplate);
2022
2075
  return {
2023
2076
  id: "cover-block",
2024
2077
  startTime: -1,
@@ -2028,7 +2081,13 @@ function DocPlayerContent({
2028
2081
  audioSegment: -1,
2029
2082
  layers
2030
2083
  };
2031
- }, [doc.startBlock, activeViewport, effectiveTheme, appearance.showCoverSlide]);
2084
+ }, [
2085
+ doc.startBlock,
2086
+ activeViewport,
2087
+ effectiveTheme,
2088
+ appearance.showCoverSlide,
2089
+ appearance.coverSlideTemplate
2090
+ ]);
2032
2091
  const hasManagedCover = !!coverBlock;
2033
2092
  const [slideshowCoverVisible, setSlideshowCoverVisible] = useState5(false);
2034
2093
  const [isSlideshowPickerOpen, setIsSlideshowPickerOpen] = useState5(false);
@@ -2066,9 +2125,12 @@ function DocPlayerContent({
2066
2125
  coverWasShowing.current = false;
2067
2126
  hasPlayedOnce.current = true;
2068
2127
  setCoverGraceActive(true);
2069
- coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3e3);
2128
+ coverGraceTimer.current = setTimeout(
2129
+ () => setCoverGraceActive(false),
2130
+ appearance.coverSlideDuration * 1e3
2131
+ );
2070
2132
  }
2071
- }, [isPlaying, coverBlock, renderMode, isSlideshowMode]);
2133
+ }, [isPlaying, coverBlock, renderMode, isSlideshowMode, appearance.coverSlideDuration]);
2072
2134
  useEffect5(() => () => clearTimeout(coverGraceTimer.current), []);
2073
2135
  useEffect5(() => () => clearTimeout(tapFeedbackTimer.current), []);
2074
2136
  const showVideoCoverBlock = !isSlideshowMode && !isLinearMode && !!coverBlock && (coverForced || coverGraceActive || !isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay);
@@ -2605,7 +2667,9 @@ function DocPlayerContent({
2605
2667
  viewport: activeViewport,
2606
2668
  theme,
2607
2669
  surface,
2608
- animationsEnabled
2670
+ animationsEnabled,
2671
+ showCodeCopyButton,
2672
+ onCopyCode
2609
2673
  }
2610
2674
  )
2611
2675
  }
@@ -16,7 +16,7 @@ function useMediaSchedule(schedule, currentTime) {
16
16
  }
17
17
 
18
18
  // src/hooks/useAudioSync.ts
19
- import { useState, useEffect, useRef, useCallback } from "react";
19
+ import { useState, useEffect, useMemo as useMemo2, useRef, useCallback } from "react";
20
20
  import { fetchResourceBytes, isResourceUrlAllowed } from "@bendyline/squisq/markdown";
21
21
 
22
22
  // src/hooks/AudioController.ts
@@ -61,7 +61,14 @@ function resolveAudioUrl(src, basePath) {
61
61
  if (!basePath) return src;
62
62
  return `${basePath.replace(/\/$/, "")}/${src.replace(/^\//, "")}`;
63
63
  }
64
- function useAudioSync(audioRef, audioTrack, basePath = "", enabled = true, mode = "media") {
64
+ function useAudioSync(audioRef, track, basePath = "", enabled = true, mode = "media", syntheticDuration = 0) {
65
+ const audioTrack = useMemo2(() => {
66
+ if (mode !== "synthetic" || track?.segments?.length || syntheticDuration <= 0) return track;
67
+ return {
68
+ ...track,
69
+ segments: [{ src: "", name: "synthetic", duration: syntheticDuration, startTime: 0 }]
70
+ };
71
+ }, [track, mode, syntheticDuration]);
65
72
  const resourcePolicy = useResourcePolicy();
66
73
  const [currentTime, setCurrentTime] = useState(0);
67
74
  const [isPlaying, setIsPlaying] = useState(false);
@@ -468,7 +475,7 @@ function useAudioSync(audioRef, audioTrack, basePath = "", enabled = true, mode
468
475
  }
469
476
 
470
477
  // src/hooks/useDocPlayback.ts
471
- import { useMemo as useMemo2, useCallback as useCallback2, useRef as useRef2 } from "react";
478
+ import { useMemo as useMemo3, useCallback as useCallback2, useRef as useRef2 } from "react";
472
479
  import {
473
480
  DEFAULT_THEME,
474
481
  getBlockAtTime,
@@ -489,7 +496,7 @@ function useDocPlayback(script, currentTime, options = {}) {
489
496
  onSeek,
490
497
  useAudioSegmentTiming = true
491
498
  } = options;
492
- const blocks = useMemo2(() => {
499
+ const blocks = useMemo3(() => {
493
500
  if (!script?.blocks) {
494
501
  return [];
495
502
  }
@@ -532,20 +539,20 @@ function useDocPlayback(script, currentTime, options = {}) {
532
539
  theme,
533
540
  useAudioSegmentTiming
534
541
  ]);
535
- const currentBlock = useMemo2(() => getBlockAtTime(blocks, currentTime), [blocks, currentTime]);
536
- const currentBlockIndex = useMemo2(
542
+ const currentBlock = useMemo3(() => getBlockAtTime(blocks, currentTime), [blocks, currentTime]);
543
+ const currentBlockIndex = useMemo3(
537
544
  () => currentBlock ? blocks.indexOf(currentBlock) : -1,
538
545
  [blocks, currentBlock]
539
546
  );
540
- const blockTime = useMemo2(() => {
547
+ const blockTime = useMemo3(() => {
541
548
  if (!currentBlock) return 0;
542
549
  return Math.max(0, currentTime - currentBlock.startTime);
543
550
  }, [currentBlock, currentTime]);
544
- const blockProgress = useMemo2(() => {
551
+ const blockProgress = useMemo3(() => {
545
552
  if (!currentBlock || currentBlock.duration === 0) return 0;
546
553
  return Math.min(1, blockTime / currentBlock.duration);
547
554
  }, [currentBlock, blockTime]);
548
- const docProgress = useMemo2(() => {
555
+ const docProgress = useMemo3(() => {
549
556
  if (!script || script.duration === 0) return 0;
550
557
  return Math.min(1, currentTime / script.duration);
551
558
  }, [script, currentTime]);
@@ -612,7 +619,7 @@ function useDocPlayback(script, currentTime, options = {}) {
612
619
  }
613
620
 
614
621
  // src/hooks/useViewportOrientation.ts
615
- import { useState as useState2, useEffect as useEffect2, useMemo as useMemo3 } from "react";
622
+ import { useState as useState2, useEffect as useEffect2, useMemo as useMemo4 } from "react";
616
623
  import {
617
624
  VIEWPORT_PRESETS as VIEWPORT_PRESETS2
618
625
  } from "@bendyline/squisq/doc";
@@ -661,11 +668,11 @@ function useViewportOrientation() {
661
668
  clearTimeout(timeoutId);
662
669
  };
663
670
  }, []);
664
- const orientation = useMemo3(
671
+ const orientation = useMemo4(
665
672
  () => getOrientationFromWindow(windowSize.width, windowSize.height),
666
673
  [windowSize.width, windowSize.height]
667
674
  );
668
- const viewport = useMemo3(() => getViewportForOrientation(orientation), [orientation]);
675
+ const viewport = useMemo4(() => getViewportForOrientation(orientation), [orientation]);
669
676
  return {
670
677
  viewport,
671
678
  orientation,
@@ -50,13 +50,72 @@ function InlineAudioPlayer({
50
50
  }
51
51
 
52
52
  // src/MarkdownRenderer.tsx
53
- import { Fragment } from "react";
53
+ import { Fragment, useEffect, useRef, useState } from "react";
54
54
  import {
55
55
  sanitizeHtmlNodes,
56
56
  sanitizeUrl
57
57
  } from "@bendyline/squisq/markdown";
58
58
  import { jsx as jsx3, jsxs } from "react/jsx-runtime";
59
59
  var DEFAULT_CTX = { htmlPolicy: "sanitize" };
60
+ var CODE_COPY_LABELS = {
61
+ idle: "Copy",
62
+ copying: "Copying\u2026",
63
+ copied: "Copied",
64
+ failed: "Copy failed"
65
+ };
66
+ async function writeCodeToClipboard(code, language, onCopyCode) {
67
+ if (onCopyCode) {
68
+ await onCopyCode(code, language === void 0 ? {} : { language });
69
+ return;
70
+ }
71
+ if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
72
+ throw new Error("Clipboard access is unavailable in this host");
73
+ }
74
+ await navigator.clipboard.writeText(code);
75
+ }
76
+ function CodeBlock({
77
+ value,
78
+ language,
79
+ ctx
80
+ }) {
81
+ const [status, setStatus] = useState("idle");
82
+ const resetTimer = useRef();
83
+ useEffect(
84
+ () => () => {
85
+ if (resetTimer.current !== void 0) clearTimeout(resetTimer.current);
86
+ },
87
+ []
88
+ );
89
+ const pre = /* @__PURE__ */ jsx3("pre", { className: "squisq-md-code-block", children: /* @__PURE__ */ jsx3("code", { className: language ? `language-${language}` : void 0, children: value }) });
90
+ if (!ctx.showCodeCopyButton) return pre;
91
+ const handleCopy = async () => {
92
+ if (status === "copying") return;
93
+ setStatus("copying");
94
+ try {
95
+ await writeCodeToClipboard(value, language ?? void 0, ctx.onCopyCode);
96
+ setStatus("copied");
97
+ } catch {
98
+ setStatus("failed");
99
+ }
100
+ if (resetTimer.current !== void 0) clearTimeout(resetTimer.current);
101
+ resetTimer.current = setTimeout(() => setStatus("idle"), 1600);
102
+ };
103
+ return /* @__PURE__ */ jsxs("div", { className: "squisq-md-code-frame", children: [
104
+ pre,
105
+ /* @__PURE__ */ jsx3(
106
+ "button",
107
+ {
108
+ type: "button",
109
+ className: "squisq-md-code-copy",
110
+ "data-copy-state": status,
111
+ disabled: status === "copying",
112
+ onClick: () => void handleCopy(),
113
+ "aria-label": "Copy code to clipboard",
114
+ children: CODE_COPY_LABELS[status]
115
+ }
116
+ )
117
+ ] });
118
+ }
60
119
  function renderInline(nodes, keyPrefix = "", ctx = DEFAULT_CTX) {
61
120
  return nodes.map((node, i) => {
62
121
  const key = `${keyPrefix}i${i}`;
@@ -181,7 +240,7 @@ function renderBlock(node, key, ctx = DEFAULT_CTX) {
181
240
  key
182
241
  );
183
242
  }
184
- return /* @__PURE__ */ jsx3("pre", { className: "squisq-md-code-block", children: /* @__PURE__ */ jsx3("code", { className: node.lang ? `language-${node.lang}` : void 0, children: node.value }) }, key);
243
+ return /* @__PURE__ */ jsx3(CodeBlock, { value: node.value, language: node.lang, ctx }, key);
185
244
  case "thematicBreak":
186
245
  return /* @__PURE__ */ jsx3("hr", { className: "squisq-md-hr" }, key);
187
246
  case "table":
@@ -393,10 +452,18 @@ function MarkdownRenderer({
393
452
  className,
394
453
  htmlPolicy = "sanitize",
395
454
  linkSchemes,
396
- theme
455
+ theme,
456
+ showCodeCopyButton = false,
457
+ onCopyCode
397
458
  }) {
398
459
  if (!nodes || nodes.length === 0) return null;
399
- return /* @__PURE__ */ jsx3("div", { className: `squisq-md ${className || ""}`, children: renderBlocks(nodes, "", { htmlPolicy, linkSchemes, theme }) });
460
+ return /* @__PURE__ */ jsx3("div", { className: `squisq-md ${className || ""}`, children: renderBlocks(nodes, "", {
461
+ htmlPolicy,
462
+ linkSchemes,
463
+ theme,
464
+ showCodeCopyButton,
465
+ onCopyCode
466
+ }) });
400
467
  }
401
468
 
402
469
  export {