@markgrafhq/starlight-markgraf 0.0.35 → 0.0.36

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
@@ -53,3 +53,20 @@ import Markgraf from "@markgrafhq/starlight-markgraf/Markgraf.astro";
53
53
 
54
54
  <Markgraf src={`seed 1\nscene v1 { + a: A }`} />
55
55
  ```
56
+
57
+ Players include the floating play, scrub, time, and speed controls by default.
58
+ The Astro component accepts `controls={false}` when a page should hide them.
59
+
60
+ Custom controls belong in a React island so the control function stays in the
61
+ browser rather than crossing Astro's serialization boundary:
62
+
63
+ ```tsx
64
+ import { StarlightMarkgrafPlayer } from "@markgrafhq/starlight-markgraf";
65
+
66
+ export const Diagram = ({ src }: { src: string }) => (
67
+ <StarlightMarkgrafPlayer
68
+ src={src}
69
+ controls={(api) => <MyDiagramControls api={api} />}
70
+ />
71
+ );
72
+ ```
@@ -0,0 +1,16 @@
1
+ import { type ReactNode } from "react";
2
+ import { type MarkgrafApi } from "@markgrafhq/markgraf-react";
3
+ type Renderer = "canvas" | "svg";
4
+ export type MarkgrafControls = boolean | ((api: MarkgrafApi<Element>) => ReactNode);
5
+ export interface MarkgrafPlayerProps {
6
+ src: string;
7
+ renderer?: Renderer;
8
+ className?: string;
9
+ /**
10
+ * Shows the transport bar by default. Pass `false` to hide it, or a render
11
+ * function to replace it with controls driven by the same player API.
12
+ */
13
+ controls?: MarkgrafControls;
14
+ }
15
+ export declare const StarlightMarkgrafPlayer: ({ src, renderer, className, controls, }: MarkgrafPlayerProps) => import("react").JSX.Element;
16
+ export {};
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { useMarkgraf } from "@markgrafhq/markgraf-react";
4
+ const playIcon = (_jsx("svg", { viewBox: "0 0 15 15", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M3.24 2.05a.5.5 0 0 1 .52.02l8.5 5a.5.5 0 0 1 0 .86l-8.5 5A.5.5 0 0 1 3 12.5v-10a.5.5 0 0 1 .24-.45Z" }) }));
5
+ const pauseIcon = (_jsx("svg", { viewBox: "0 0 15 15", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M3.5 2.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v10a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5v-10Zm5 0A.5.5 0 0 1 9 2h2a.5.5 0 0 1 .5.5v10a.5.5 0 0 1-.5.5H9a.5.5 0 0 1-.5-.5v-10Z" }) }));
6
+ const speedIcon = (_jsx("svg", { className: "markgraf-doc-speed-icon", viewBox: "0 0 15 15", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M3.14 5.64a.5.5 0 0 1 .71 0L7.5 9.29l3.65-3.65a.5.5 0 0 1 .7.71l-4 4a.5.5 0 0 1-.7 0l-4-4a.5.5 0 0 1 0-.71Z" }) }));
7
+ const useDocumentTheme = () => {
8
+ const [theme, setTheme] = useState("light");
9
+ useEffect(() => {
10
+ const root = document.documentElement;
11
+ const update = () => setTheme(root.dataset.theme === "dark" ? "dark" : "light");
12
+ update();
13
+ const observer = new MutationObserver(update);
14
+ observer.observe(root, {
15
+ attributes: true,
16
+ attributeFilter: ["data-theme"],
17
+ });
18
+ return () => observer.disconnect();
19
+ }, []);
20
+ return theme;
21
+ };
22
+ const formatTime = (seconds) => {
23
+ const safe = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
24
+ const minutes = Math.floor(safe / 60);
25
+ const remainder = safe - minutes * 60;
26
+ return `${minutes}:${remainder.toFixed(1).padStart(4, "0")}`;
27
+ };
28
+ const DefaultControls = ({ api, speed, setSpeed, }) => {
29
+ const duration = Number.isFinite(api.duration)
30
+ ? Math.max(0, api.duration)
31
+ : 0;
32
+ const time = Number.isFinite(api.time)
33
+ ? Math.max(0, Math.min(api.time, duration))
34
+ : 0;
35
+ return (_jsxs("div", { className: "markgraf-doc-controls", "aria-label": "Diagram playback controls", children: [_jsx("button", { className: "markgraf-doc-play", type: "button", "aria-label": api.playing ? "Pause diagram" : "Play diagram", "aria-pressed": api.playing, disabled: !api.ready, onClick: api.toggle, children: api.playing ? pauseIcon : playIcon }), _jsx("input", { className: "markgraf-doc-scrub", type: "range", "aria-label": "Diagram time", min: "0", max: Math.max(duration, 0.001), step: "0.01", value: time, disabled: !api.ready, onChange: (event) => api.seek(event.currentTarget.valueAsNumber) }), _jsxs("output", { className: "markgraf-doc-time", "aria-live": "off", children: [formatTime(time), " / ", formatTime(duration)] }), _jsxs("div", { className: "markgraf-doc-speed-control", children: [_jsxs("select", { className: "markgraf-doc-speed", "aria-label": "Playback speed", value: speed, disabled: !api.ready, onChange: (event) => setSpeed(Number(event.currentTarget.value)), children: [_jsx("option", { value: "0.5", children: "0.5\u00D7" }), _jsx("option", { value: "1", children: "1\u00D7" }), _jsx("option", { value: "1.5", children: "1.5\u00D7" }), _jsx("option", { value: "2", children: "2\u00D7" })] }), speedIcon] })] }));
36
+ };
37
+ const Controls = ({ api, controls, }) => {
38
+ const [speed, setSpeed] = useState(1);
39
+ useEffect(() => {
40
+ if (controls === true && api.ready)
41
+ api.setSpeed(speed);
42
+ }, [api.ready, controls, speed]);
43
+ if (typeof controls === "function")
44
+ return controls(api);
45
+ if (!controls)
46
+ return null;
47
+ return _jsx(DefaultControls, { api: api, speed: speed, setSpeed: setSpeed });
48
+ };
49
+ const CanvasPlayer = ({ src, theme, className, controls, }) => {
50
+ const api = useMarkgraf(src, { renderer: "canvas", theme });
51
+ return (_jsxs("div", { className: `markgraf-doc-player ${className ?? ""}`, "data-markgraf-theme": theme, children: [_jsx("canvas", { className: "markgraf-player markgraf-doc-stage", ref: api.elementRef, onClick: api.toggle, "aria-label": "Animated Markgraf diagram" }), _jsx(Controls, { api: api, controls: controls })] }));
52
+ };
53
+ const SvgPlayer = ({ src, theme, className, controls, }) => {
54
+ const api = useMarkgraf(src, { renderer: "svg", theme });
55
+ return (_jsxs("div", { className: `markgraf-doc-player ${className ?? ""}`, "data-markgraf-theme": theme, children: [_jsx("svg", { className: "markgraf-player markgraf-doc-stage", ref: api.elementRef, onClick: api.toggle, "aria-label": "Animated Markgraf diagram" }), _jsx(Controls, { api: api, controls: controls })] }));
56
+ };
57
+ export const StarlightMarkgrafPlayer = ({ src, renderer = "canvas", className, controls = true, }) => {
58
+ const theme = useDocumentTheme();
59
+ const props = { src, renderer, className, controls, theme };
60
+ return renderer === "svg" ? (_jsx(SvgPlayer, { ...props })) : (_jsx(CanvasPlayer, { ...props }));
61
+ };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AstroIntegration } from "astro";
2
2
  import remarkMarkgraf from "./remark-markgraf.mjs";
3
3
  export { remarkMarkgraf };
4
+ export { StarlightMarkgrafPlayer, type MarkgrafControls, type MarkgrafPlayerProps, } from "./MarkgrafPlayer.js";
4
5
  export default function markgrafIntegration(): AstroIntegration;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import remarkMarkgraf from "./remark-markgraf.mjs";
2
2
  export { remarkMarkgraf };
3
+ export { StarlightMarkgrafPlayer, } from "./MarkgrafPlayer.js";
3
4
  export default function markgrafIntegration() {
4
5
  return {
5
6
  name: "markgraf-starlight",
@@ -1,292 +1,154 @@
1
- @font-face {
2
- font-family: 'CommitMono';
3
- src: url('./CommitMono-Regular.woff2') format('woff2');
4
- font-weight: 400;
5
- font-display: swap;
6
- }
1
+ @import "@markgrafhq/markgraf-react/css";
7
2
 
8
- .markgraf-embed {
9
- --mg-bg: #ffffff;
10
- --mg-fg: #111111;
11
- --mg-bar-bg: #111111;
12
- --mg-bar-fg: #ffffff;
13
- --mg-muted: #9a9a9a;
14
- --mg-accent: #ffffff;
15
- --mg-track: #111111;
16
- --mg-track-hover: #111111;
3
+ .markgraf-doc-player {
17
4
  position: relative;
18
- border-radius: 10px;
19
- background: transparent;
20
- color: var(--mg-fg);
21
- font: 13px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
22
- overflow: hidden;
23
- }
24
-
25
- .markgraf-embed canvas[data-mg="stage"],
26
- .markgraf-embed svg[data-mg="stage"] {
5
+ display: flex;
6
+ flex-direction: column;
27
7
  width: 100%;
28
- height: auto;
29
- display: block;
30
- max-height: var(--mg-max-height, 90svh);
31
- object-fit: contain;
32
- }
33
-
34
- .markgraf-embed :focus,
35
- .markgraf-embed :focus-visible {
36
- outline: none;
37
- }
38
-
39
- /* Big centered play hint shown while paused. Hidden as soon as a play
40
- button anywhere in the embed flips to data-mg-playing="1". */
41
- .markgraf-embed [data-mg="play-overlay"] {
42
- position: absolute;
43
- inset: 0;
44
- margin: auto;
45
- width: 72px;
46
- height: 72px;
47
- border-radius: 50%;
48
- background: rgba(0, 0, 0, 0.55);
49
- pointer-events: none;
50
- opacity: 0;
51
- transform: scale(0.85);
52
- transition: opacity 0.12s ease, transform 0.12s ease;
8
+ min-width: 0;
9
+ min-height: 11.25rem;
10
+ aspect-ratio: 16 / 9;
11
+ overflow: hidden;
12
+ border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
13
+ border-radius: 0.875rem;
14
+ background: #ffffff;
15
+ box-shadow: 0 1rem 3rem rgb(0 0 0 / 10%);
53
16
  }
54
17
 
55
- .markgraf-embed [data-mg="play-overlay"]::before {
56
- content: "";
57
- position: absolute;
58
- inset: 0;
59
- margin: auto;
60
- width: 32px;
61
- height: 32px;
62
- background: #fff;
63
- -webkit-mask: var(--mg-icon-play) center / contain no-repeat;
64
- mask: var(--mg-icon-play) center / contain no-repeat;
18
+ .markgraf-doc-player[data-markgraf-theme="dark"] {
19
+ background: #101827;
65
20
  }
66
21
 
67
- .markgraf-embed:has([data-mg="play"][data-mg-playing="0"]) [data-mg="play-overlay"] {
68
- opacity: 1;
69
- transform: scale(1);
22
+ .markgraf-doc-stage {
23
+ display: block;
24
+ width: 100%;
25
+ height: 100%;
26
+ min-height: 11.25rem;
27
+ cursor: pointer;
70
28
  }
71
29
 
72
- .markgraf-embed [data-mg="bar"] {
30
+ .markgraf-doc-controls {
73
31
  position: absolute;
74
- left: 0;
75
- right: 0;
76
- bottom: 0;
77
- display: flex;
32
+ z-index: 2;
33
+ right: 50%;
34
+ bottom: 0.75rem;
35
+ display: grid;
36
+ grid-template-columns: 1.75rem minmax(7rem, 1fr) auto auto;
78
37
  align-items: center;
79
- gap: 8px;
80
- padding: 6px 8px;
81
- background: rgba(255, 255, 255, 0.92);
82
- color: var(--mg-fg);
83
- opacity: 0;
84
- transform: translateY(2px);
85
- transition: opacity 0.08s ease, transform 0.08s ease;
86
- pointer-events: none;
87
- }
88
-
89
- .markgraf-embed:hover [data-mg="bar"],
90
- .markgraf-embed:focus-within [data-mg="bar"] {
91
- opacity: 1;
92
- transform: translateY(0);
93
- pointer-events: auto;
94
- transition: opacity 0.16s ease, transform 0.16s ease;
95
- }
96
-
97
- .markgraf-embed [data-mg="play"] {
98
- background: transparent;
99
- color: var(--mg-fg);
100
- border: 0;
38
+ gap: 0.5rem;
39
+ width: min(35rem, calc(100% - 2rem));
40
+ padding: 0.375rem 0.5rem;
41
+ border: 1px solid rgb(255 255 255 / 14%);
101
42
  border-radius: 999px;
102
- width: 22px;
103
- height: 22px;
43
+ color: #ffffff;
44
+ background: rgb(8 13 22 / 58%);
45
+ box-shadow: 0 0.875rem 2.75rem rgb(0 0 0 / 28%);
46
+ opacity: 0.78;
47
+ transform: translateX(50%);
48
+ transition:
49
+ opacity 140ms ease,
50
+ background 140ms ease;
51
+ backdrop-filter: blur(18px);
52
+ }
53
+
54
+ .markgraf-doc-player:hover .markgraf-doc-controls,
55
+ .markgraf-doc-controls:focus-within {
56
+ background: rgb(8 13 22 / 78%);
57
+ opacity: 0.98;
58
+ }
59
+
60
+ .markgraf-doc-play {
61
+ display: grid;
62
+ width: 1.75rem;
63
+ height: 1.75rem;
104
64
  padding: 0;
65
+ border: 0;
66
+ border-radius: 999px;
67
+ place-items: center;
68
+ color: inherit;
69
+ background: rgb(255 255 255 / 10%);
105
70
  cursor: pointer;
106
- display: inline-flex;
107
- align-items: center;
108
- justify-content: center;
109
- transition: background 0.1s ease;
110
71
  }
111
72
 
112
- .markgraf-embed [data-mg="play"]:hover {
113
- background: rgba(0, 0, 0, 0.08);
73
+ .markgraf-doc-play:hover,
74
+ .markgraf-doc-play:focus-visible {
75
+ background: rgb(255 255 255 / 20%);
114
76
  }
115
77
 
116
- /* Heroicons v2 solid play / pause, rendered as a mask so the icon picks up
117
- currentColor (light-mode black, dark-mode white via the bar override). */
118
- .markgraf-embed [data-mg="play"]::before {
119
- content: "";
120
- display: block;
121
- width: 14px;
122
- height: 14px;
123
- background: currentColor;
124
- -webkit-mask: var(--mg-icon-play) center / contain no-repeat;
125
- mask: var(--mg-icon-play) center / contain no-repeat;
126
- }
127
-
128
- .markgraf-embed [data-mg="play"][data-mg-playing="1"]::before {
129
- -webkit-mask: var(--mg-icon-pause) center / contain no-repeat;
130
- mask: var(--mg-icon-pause) center / contain no-repeat;
78
+ .markgraf-doc-play svg {
79
+ width: 0.875rem;
80
+ height: 0.875rem;
131
81
  }
132
82
 
133
- .markgraf-embed {
134
- --mg-icon-play: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'><path d='M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653Z'/></svg>");
135
- --mg-icon-pause: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'><path d='M6.75 5.25a.75.75 0 0 1 .75-.75H9a.75.75 0 0 1 .75.75v13.5a.75.75 0 0 1-.75.75H7.5a.75.75 0 0 1-.75-.75V5.25Zm7.5 0A.75.75 0 0 1 15 4.5h1.5a.75.75 0 0 1 .75.75v13.5a.75.75 0 0 1-.75.75H15a.75.75 0 0 1-.75-.75V5.25Z'/></svg>");
83
+ .markgraf-doc-scrub {
84
+ min-width: 0;
85
+ height: 0.875rem;
86
+ margin: 0;
87
+ accent-color: #ffffff;
88
+ cursor: pointer;
136
89
  }
137
90
 
138
- .markgraf-embed [data-mg="time"] {
91
+ .markgraf-doc-time {
92
+ color: rgb(255 255 255 / 76%);
93
+ font-family: var(
94
+ --sl-font-mono,
95
+ var(--ifm-font-family-monospace, ui-monospace, monospace)
96
+ );
97
+ font-size: 0.625rem;
98
+ line-height: 1;
139
99
  font-variant-numeric: tabular-nums;
140
- font-size: 11px;
141
- color: var(--mg-fg);
142
100
  white-space: nowrap;
143
101
  }
144
102
 
145
- .markgraf-embed [data-mg="speed"] {
146
- background: transparent;
147
- border: 1px solid currentColor;
148
- border-radius: 4px;
149
- padding: 1px 4px;
150
- color: var(--mg-fg);
151
- font: inherit;
152
- font-size: 11px;
153
- line-height: 1;
154
- cursor: pointer;
155
- }
156
-
157
- .markgraf-embed [data-mg="speed"] option {
158
- color: var(--mg-fg);
159
- background: var(--mg-bg);
160
- }
161
-
162
- .markgraf-embed [data-mg="scrub-wrap"] {
103
+ .markgraf-doc-speed-control {
163
104
  position: relative;
164
- flex: 1;
165
105
  display: flex;
166
106
  align-items: center;
167
107
  }
168
108
 
169
- .markgraf-embed [data-mg="ticks"] {
170
- position: absolute;
171
- left: 7px;
172
- right: 7px;
173
- top: 50%;
174
- transform: translateY(-50%);
175
- height: 18px;
176
- pointer-events: none;
177
- }
178
-
179
- .markgraf-embed .mg-tick {
180
- position: absolute;
181
- top: 50%;
182
- width: 4px;
183
- height: 4px;
184
- margin-left: -2px;
185
- margin-top: -2px;
186
- border-radius: 50%;
187
- background: #fff;
188
- opacity: 0.7;
189
- pointer-events: auto;
190
- cursor: pointer;
191
- transition: transform 0.1s ease, opacity 0.1s ease;
192
- }
193
-
194
- .markgraf-embed .mg-tick:hover {
195
- opacity: 1;
196
- transform: scale(1.6);
197
- }
198
-
199
- .markgraf-embed .mg-tick::after {
200
- content: attr(data-label);
201
- position: absolute;
202
- bottom: 100%;
203
- left: 50%;
204
- transform: translate(-50%, -4px);
205
- background: rgba(15, 23, 42, 0.95);
206
- color: #fff;
207
- font-size: 11px;
208
- padding: 2px 6px;
209
- border-radius: 4px;
210
- white-space: nowrap;
211
- opacity: 0;
212
- pointer-events: none;
213
- transition: opacity 0.1s ease;
214
- }
215
-
216
- .markgraf-embed .mg-tick:hover::after {
217
- opacity: 1;
218
- }
219
-
220
- /* Custom-styled scrubber. Cross-browser range inputs are notoriously ugly,
221
- so we strip the native chrome and rebuild from CSS primitives. */
222
- .markgraf-embed [data-mg="scrub"] {
223
- -webkit-appearance: none;
109
+ .markgraf-doc-speed {
224
110
  appearance: none;
225
111
  width: 100%;
226
- height: 18px;
227
- background: transparent;
228
- cursor: pointer;
229
- margin: 0;
230
- position: relative;
231
- z-index: 1;
232
- }
233
-
234
- .markgraf-embed [data-mg="scrub"]::-webkit-slider-runnable-track {
235
- height: 4px;
236
- background: var(--mg-track);
112
+ padding: 0.1875rem 1.375rem 0.1875rem 0.5rem;
113
+ border: 0;
237
114
  border-radius: 999px;
238
- transition: background 0.15s ease;
115
+ color: #ffffff;
116
+ background: rgb(255 255 255 / 10%);
117
+ font-size: 0.625rem;
118
+ cursor: pointer;
239
119
  }
240
120
 
241
- .markgraf-embed [data-mg="scrub"]:hover::-webkit-slider-runnable-track {
242
- background: var(--mg-track-hover);
121
+ .markgraf-doc-speed-icon {
122
+ position: absolute;
123
+ right: 0.4375rem;
124
+ width: 0.75rem;
125
+ height: 0.75rem;
126
+ color: rgb(255 255 255 / 72%);
127
+ pointer-events: none;
243
128
  }
244
129
 
245
- .markgraf-embed [data-mg="scrub"]::-moz-range-track {
246
- height: 4px;
247
- background: var(--mg-track);
248
- border-radius: 999px;
130
+ .markgraf-doc-speed option {
131
+ color: #111827;
132
+ background: #ffffff;
249
133
  }
250
134
 
251
- .markgraf-embed [data-mg="scrub"]::-webkit-slider-thumb {
252
- -webkit-appearance: none;
253
- appearance: none;
254
- width: 14px;
255
- height: 14px;
256
- border-radius: 50%;
257
- background: #111;
258
- border: 2px solid #fff;
259
- margin-top: -5px;
260
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
261
- transition: transform 0.08s ease;
135
+ .markgraf-doc-controls :disabled {
136
+ cursor: wait;
137
+ opacity: 0.5;
262
138
  }
263
139
 
264
- .markgraf-embed [data-mg="scrub"]:hover::-webkit-slider-thumb {
265
- transform: scale(1.15);
266
- }
140
+ @media (max-width: 32rem) {
141
+ .markgraf-doc-controls {
142
+ grid-template-columns: 1.75rem minmax(5rem, 1fr) auto;
143
+ }
267
144
 
268
- .markgraf-embed [data-mg="scrub"]::-moz-range-thumb {
269
- width: 14px;
270
- height: 14px;
271
- border-radius: 50%;
272
- background: #111;
273
- border: 2px solid #fff;
274
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
145
+ .markgraf-doc-time {
146
+ display: none;
147
+ }
275
148
  }
276
149
 
277
- @media (prefers-color-scheme: dark) {
278
- .markgraf-embed {
279
- --mg-bg: #111111;
280
- --mg-fg: #ffffff;
281
- --mg-track: #ffffff;
282
- --mg-track-hover: #ffffff;
283
- }
284
- .markgraf-embed [data-mg="bar"] {
285
- background: rgba(17, 17, 17, 0.92);
286
- }
287
- .markgraf-embed [data-mg="scrub"]::-webkit-slider-thumb,
288
- .markgraf-embed [data-mg="scrub"]::-moz-range-thumb {
289
- background: #fff;
290
- border-color: #111;
150
+ @media (prefers-reduced-motion: reduce) {
151
+ .markgraf-doc-controls {
152
+ transition: none;
291
153
  }
292
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markgrafhq/starlight-markgraf",
3
- "version": "0.0.35",
3
+ "version": "0.0.36",
4
4
  "description": "Starlight/Astro integration for embedding markgraf animations. Transforms ```markgraf fenced code blocks into a live player and exposes a <Markgraf src=\"…\" /> component.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -20,6 +20,8 @@
20
20
  "files": [
21
21
  "dist",
22
22
  "src/Markgraf.astro",
23
+ "src/MarkgrafPlayer.tsx",
24
+ "src/player.css",
23
25
  "README.md",
24
26
  "LICENSE"
25
27
  ],
@@ -27,7 +29,7 @@
27
29
  "./dist/markgraf-react.css"
28
30
  ],
29
31
  "scripts": {
30
- "build": "tsc -p tsconfig.json && cp src/remark-markgraf.mjs dist/remark-markgraf.mjs && cp src/remark-markgraf.mjs dist/remark-markgraf.js && cp node_modules/@markgrafhq/markgraf-react/dist/markgraf-react.css dist/markgraf-react.css"
32
+ "build": "tsc -p tsconfig.json && cp src/remark-markgraf.mjs dist/remark-markgraf.mjs && cp src/remark-markgraf.mjs dist/remark-markgraf.js && cp src/player.css dist/markgraf-react.css"
31
33
  },
32
34
  "peerDependencies": {
33
35
  "astro": ">=4",
@@ -36,10 +38,11 @@
36
38
  "react-dom": ">=18"
37
39
  },
38
40
  "dependencies": {
39
- "@markgrafhq/markgraf-react": "^0.1.23",
41
+ "@markgrafhq/markgraf-react": "^0.1.25",
40
42
  "unist-util-visit": "^5"
41
43
  },
42
44
  "devDependencies": {
45
+ "@types/react": "^19.0.0",
43
46
  "typescript": "^5"
44
47
  },
45
48
  "keywords": [
@@ -1,18 +1,21 @@
1
1
  ---
2
- import { MarkgrafPlayer } from "@markgrafhq/markgraf-react";
2
+ import { StarlightMarkgrafPlayer } from "./MarkgrafPlayer";
3
+ import "./player.css";
3
4
 
4
5
  interface Props {
5
6
  src: string;
6
7
  renderer?: "canvas" | "svg";
7
8
  class?: string;
9
+ controls?: boolean;
8
10
  }
9
11
 
10
- const { src, renderer = "canvas", class: className } = Astro.props;
12
+ const { src, renderer = "canvas", class: className, controls = true } = Astro.props;
11
13
  ---
12
14
 
13
- <MarkgrafPlayer
15
+ <StarlightMarkgrafPlayer
14
16
  client:visible
15
17
  src={src}
16
18
  renderer={renderer}
17
19
  className={className}
20
+ controls={controls}
18
21
  />
@@ -0,0 +1,217 @@
1
+ import { type ReactNode, useEffect, useState } from "react";
2
+ import { useMarkgraf, type MarkgrafApi } from "@markgrafhq/markgraf-react";
3
+
4
+ type Renderer = "canvas" | "svg";
5
+ type Theme = "light" | "dark";
6
+
7
+ export type MarkgrafControls =
8
+ | boolean
9
+ | ((api: MarkgrafApi<Element>) => ReactNode);
10
+ const playIcon = (
11
+ <svg viewBox="0 0 15 15" aria-hidden="true" focusable="false">
12
+ <path d="M3.24 2.05a.5.5 0 0 1 .52.02l8.5 5a.5.5 0 0 1 0 .86l-8.5 5A.5.5 0 0 1 3 12.5v-10a.5.5 0 0 1 .24-.45Z" />
13
+ </svg>
14
+ );
15
+
16
+ const pauseIcon = (
17
+ <svg viewBox="0 0 15 15" aria-hidden="true" focusable="false">
18
+ <path d="M3.5 2.5a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v10a.5.5 0 0 1-.5.5H4a.5.5 0 0 1-.5-.5v-10Zm5 0A.5.5 0 0 1 9 2h2a.5.5 0 0 1 .5.5v10a.5.5 0 0 1-.5.5H9a.5.5 0 0 1-.5-.5v-10Z" />
19
+ </svg>
20
+ );
21
+
22
+ const speedIcon = (
23
+ <svg
24
+ className="markgraf-doc-speed-icon"
25
+ viewBox="0 0 15 15"
26
+ aria-hidden="true"
27
+ focusable="false"
28
+ >
29
+ <path d="M3.14 5.64a.5.5 0 0 1 .71 0L7.5 9.29l3.65-3.65a.5.5 0 0 1 .7.71l-4 4a.5.5 0 0 1-.7 0l-4-4a.5.5 0 0 1 0-.71Z" />
30
+ </svg>
31
+ );
32
+
33
+ export interface MarkgrafPlayerProps {
34
+ src: string;
35
+ renderer?: Renderer;
36
+ className?: string;
37
+ /**
38
+ * Shows the transport bar by default. Pass `false` to hide it, or a render
39
+ * function to replace it with controls driven by the same player API.
40
+ */
41
+ controls?: MarkgrafControls;
42
+ }
43
+
44
+ const useDocumentTheme = (): Theme => {
45
+ const [theme, setTheme] = useState<Theme>("light");
46
+
47
+ useEffect(() => {
48
+ const root = document.documentElement;
49
+ const update = () =>
50
+ setTheme(root.dataset.theme === "dark" ? "dark" : "light");
51
+ update();
52
+
53
+ const observer = new MutationObserver(update);
54
+ observer.observe(root, {
55
+ attributes: true,
56
+ attributeFilter: ["data-theme"],
57
+ });
58
+ return () => observer.disconnect();
59
+ }, []);
60
+
61
+ return theme;
62
+ };
63
+
64
+ const formatTime = (seconds: number): string => {
65
+ const safe = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
66
+ const minutes = Math.floor(safe / 60);
67
+ const remainder = safe - minutes * 60;
68
+ return `${minutes}:${remainder.toFixed(1).padStart(4, "0")}`;
69
+ };
70
+
71
+ const DefaultControls = ({
72
+ api,
73
+ speed,
74
+ setSpeed,
75
+ }: {
76
+ api: MarkgrafApi<Element>;
77
+ speed: number;
78
+ setSpeed: (speed: number) => void;
79
+ }) => {
80
+ const duration = Number.isFinite(api.duration)
81
+ ? Math.max(0, api.duration)
82
+ : 0;
83
+ const time = Number.isFinite(api.time)
84
+ ? Math.max(0, Math.min(api.time, duration))
85
+ : 0;
86
+
87
+ return (
88
+ <div
89
+ className="markgraf-doc-controls"
90
+ aria-label="Diagram playback controls"
91
+ >
92
+ <button
93
+ className="markgraf-doc-play"
94
+ type="button"
95
+ aria-label={api.playing ? "Pause diagram" : "Play diagram"}
96
+ aria-pressed={api.playing}
97
+ disabled={!api.ready}
98
+ onClick={api.toggle}
99
+ >
100
+ {api.playing ? pauseIcon : playIcon}
101
+ </button>
102
+
103
+ <input
104
+ className="markgraf-doc-scrub"
105
+ type="range"
106
+ aria-label="Diagram time"
107
+ min="0"
108
+ max={Math.max(duration, 0.001)}
109
+ step="0.01"
110
+ value={time}
111
+ disabled={!api.ready}
112
+ onChange={(event) => api.seek(event.currentTarget.valueAsNumber)}
113
+ />
114
+
115
+ <output className="markgraf-doc-time" aria-live="off">
116
+ {formatTime(time)} / {formatTime(duration)}
117
+ </output>
118
+
119
+ <div className="markgraf-doc-speed-control">
120
+ <select
121
+ className="markgraf-doc-speed"
122
+ aria-label="Playback speed"
123
+ value={speed}
124
+ disabled={!api.ready}
125
+ onChange={(event) => setSpeed(Number(event.currentTarget.value))}
126
+ >
127
+ <option value="0.5">0.5×</option>
128
+ <option value="1">1×</option>
129
+ <option value="1.5">1.5×</option>
130
+ <option value="2">2×</option>
131
+ </select>
132
+ {speedIcon}
133
+ </div>
134
+ </div>
135
+ );
136
+ };
137
+
138
+ const Controls = ({
139
+ api,
140
+ controls,
141
+ }: {
142
+ api: MarkgrafApi<Element>;
143
+ controls: MarkgrafControls;
144
+ }) => {
145
+ const [speed, setSpeed] = useState(1);
146
+
147
+ useEffect(() => {
148
+ if (controls === true && api.ready) api.setSpeed(speed);
149
+ }, [api.ready, controls, speed]);
150
+
151
+ if (typeof controls === "function") return controls(api);
152
+ if (!controls) return null;
153
+ return <DefaultControls api={api} speed={speed} setSpeed={setSpeed} />;
154
+ };
155
+
156
+ const CanvasPlayer = ({
157
+ src,
158
+ theme,
159
+ className,
160
+ controls,
161
+ }: MarkgrafPlayerProps & { theme: Theme; controls: MarkgrafControls }) => {
162
+ const api = useMarkgraf(src, { renderer: "canvas", theme });
163
+
164
+ return (
165
+ <div
166
+ className={`markgraf-doc-player ${className ?? ""}`}
167
+ data-markgraf-theme={theme}
168
+ >
169
+ <canvas
170
+ className="markgraf-player markgraf-doc-stage"
171
+ ref={api.elementRef}
172
+ onClick={api.toggle}
173
+ aria-label="Animated Markgraf diagram"
174
+ />
175
+ <Controls api={api} controls={controls} />
176
+ </div>
177
+ );
178
+ };
179
+
180
+ const SvgPlayer = ({
181
+ src,
182
+ theme,
183
+ className,
184
+ controls,
185
+ }: MarkgrafPlayerProps & { theme: Theme; controls: MarkgrafControls }) => {
186
+ const api = useMarkgraf(src, { renderer: "svg", theme });
187
+
188
+ return (
189
+ <div
190
+ className={`markgraf-doc-player ${className ?? ""}`}
191
+ data-markgraf-theme={theme}
192
+ >
193
+ <svg
194
+ className="markgraf-player markgraf-doc-stage"
195
+ ref={api.elementRef}
196
+ onClick={api.toggle}
197
+ aria-label="Animated Markgraf diagram"
198
+ />
199
+ <Controls api={api} controls={controls} />
200
+ </div>
201
+ );
202
+ };
203
+
204
+ export const StarlightMarkgrafPlayer = ({
205
+ src,
206
+ renderer = "canvas",
207
+ className,
208
+ controls = true,
209
+ }: MarkgrafPlayerProps) => {
210
+ const theme = useDocumentTheme();
211
+ const props = { src, renderer, className, controls, theme };
212
+ return renderer === "svg" ? (
213
+ <SvgPlayer {...props} />
214
+ ) : (
215
+ <CanvasPlayer {...props} />
216
+ );
217
+ };
package/src/player.css ADDED
@@ -0,0 +1,154 @@
1
+ @import "@markgrafhq/markgraf-react/css";
2
+
3
+ .markgraf-doc-player {
4
+ position: relative;
5
+ display: flex;
6
+ flex-direction: column;
7
+ width: 100%;
8
+ min-width: 0;
9
+ min-height: 11.25rem;
10
+ aspect-ratio: 16 / 9;
11
+ overflow: hidden;
12
+ border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
13
+ border-radius: 0.875rem;
14
+ background: #ffffff;
15
+ box-shadow: 0 1rem 3rem rgb(0 0 0 / 10%);
16
+ }
17
+
18
+ .markgraf-doc-player[data-markgraf-theme="dark"] {
19
+ background: #101827;
20
+ }
21
+
22
+ .markgraf-doc-stage {
23
+ display: block;
24
+ width: 100%;
25
+ height: 100%;
26
+ min-height: 11.25rem;
27
+ cursor: pointer;
28
+ }
29
+
30
+ .markgraf-doc-controls {
31
+ position: absolute;
32
+ z-index: 2;
33
+ right: 50%;
34
+ bottom: 0.75rem;
35
+ display: grid;
36
+ grid-template-columns: 1.75rem minmax(7rem, 1fr) auto auto;
37
+ align-items: center;
38
+ gap: 0.5rem;
39
+ width: min(35rem, calc(100% - 2rem));
40
+ padding: 0.375rem 0.5rem;
41
+ border: 1px solid rgb(255 255 255 / 14%);
42
+ border-radius: 999px;
43
+ color: #ffffff;
44
+ background: rgb(8 13 22 / 58%);
45
+ box-shadow: 0 0.875rem 2.75rem rgb(0 0 0 / 28%);
46
+ opacity: 0.78;
47
+ transform: translateX(50%);
48
+ transition:
49
+ opacity 140ms ease,
50
+ background 140ms ease;
51
+ backdrop-filter: blur(18px);
52
+ }
53
+
54
+ .markgraf-doc-player:hover .markgraf-doc-controls,
55
+ .markgraf-doc-controls:focus-within {
56
+ background: rgb(8 13 22 / 78%);
57
+ opacity: 0.98;
58
+ }
59
+
60
+ .markgraf-doc-play {
61
+ display: grid;
62
+ width: 1.75rem;
63
+ height: 1.75rem;
64
+ padding: 0;
65
+ border: 0;
66
+ border-radius: 999px;
67
+ place-items: center;
68
+ color: inherit;
69
+ background: rgb(255 255 255 / 10%);
70
+ cursor: pointer;
71
+ }
72
+
73
+ .markgraf-doc-play:hover,
74
+ .markgraf-doc-play:focus-visible {
75
+ background: rgb(255 255 255 / 20%);
76
+ }
77
+
78
+ .markgraf-doc-play svg {
79
+ width: 0.875rem;
80
+ height: 0.875rem;
81
+ }
82
+
83
+ .markgraf-doc-scrub {
84
+ min-width: 0;
85
+ height: 0.875rem;
86
+ margin: 0;
87
+ accent-color: #ffffff;
88
+ cursor: pointer;
89
+ }
90
+
91
+ .markgraf-doc-time {
92
+ color: rgb(255 255 255 / 76%);
93
+ font-family: var(
94
+ --sl-font-mono,
95
+ var(--ifm-font-family-monospace, ui-monospace, monospace)
96
+ );
97
+ font-size: 0.625rem;
98
+ line-height: 1;
99
+ font-variant-numeric: tabular-nums;
100
+ white-space: nowrap;
101
+ }
102
+
103
+ .markgraf-doc-speed-control {
104
+ position: relative;
105
+ display: flex;
106
+ align-items: center;
107
+ }
108
+
109
+ .markgraf-doc-speed {
110
+ appearance: none;
111
+ width: 100%;
112
+ padding: 0.1875rem 1.375rem 0.1875rem 0.5rem;
113
+ border: 0;
114
+ border-radius: 999px;
115
+ color: #ffffff;
116
+ background: rgb(255 255 255 / 10%);
117
+ font-size: 0.625rem;
118
+ cursor: pointer;
119
+ }
120
+
121
+ .markgraf-doc-speed-icon {
122
+ position: absolute;
123
+ right: 0.4375rem;
124
+ width: 0.75rem;
125
+ height: 0.75rem;
126
+ color: rgb(255 255 255 / 72%);
127
+ pointer-events: none;
128
+ }
129
+
130
+ .markgraf-doc-speed option {
131
+ color: #111827;
132
+ background: #ffffff;
133
+ }
134
+
135
+ .markgraf-doc-controls :disabled {
136
+ cursor: wait;
137
+ opacity: 0.5;
138
+ }
139
+
140
+ @media (max-width: 32rem) {
141
+ .markgraf-doc-controls {
142
+ grid-template-columns: 1.75rem minmax(5rem, 1fr) auto;
143
+ }
144
+
145
+ .markgraf-doc-time {
146
+ display: none;
147
+ }
148
+ }
149
+
150
+ @media (prefers-reduced-motion: reduce) {
151
+ .markgraf-doc-controls {
152
+ transition: none;
153
+ }
154
+ }