@markdy/renderer-dom 1.0.9 → 1.0.11

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.js CHANGED
@@ -1,5 +1,15 @@
1
+ import {
2
+ exportDiagramAsVectorSvg,
3
+ getDiagramSceneElement,
4
+ prepareHtmlSceneForExport
5
+ } from "./chunk-VIZHA7GI.js";
6
+
1
7
  // src/diagram.ts
2
- import { parseAndCompile } from "@markdy/core";
8
+ import {
9
+ compressMarkdyToUrlHash,
10
+ parseAndCompile,
11
+ resolvePlayer
12
+ } from "@markdy/core";
3
13
 
4
14
  // src/geometry/rect.ts
5
15
  function boxRect(box) {
@@ -35,6 +45,9 @@ function segmentIntersectsRect(a, b, rect) {
35
45
  }
36
46
  return false;
37
47
  }
48
+ function rectsOverlap(a, b) {
49
+ return a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;
50
+ }
38
51
  function countPathIntersections(points, obstacles) {
39
52
  let hits = 0;
40
53
  for (let i = 0; i < points.length - 1; i++) {
@@ -49,22 +62,108 @@ function countPathIntersections(points, obstacles) {
49
62
  function round1(n) {
50
63
  return Math.round(n * 10) / 10;
51
64
  }
52
- function toPathD(points, cornerRadius = 14) {
65
+ function findSegmentHops(p1, p2, existingPaths = [], hopRadius = 5, minDistFromVertex = 10) {
66
+ const isHoriz = Math.abs(p1.y - p2.y) < 0.01;
67
+ const isVert = Math.abs(p1.x - p2.x) < 0.01;
68
+ if (!isHoriz && !isVert || existingPaths.length === 0) return [];
69
+ const crossings = [];
70
+ const minX = Math.min(p1.x, p2.x);
71
+ const maxX = Math.max(p1.x, p2.x);
72
+ const minY = Math.min(p1.y, p2.y);
73
+ const maxY = Math.max(p1.y, p2.y);
74
+ for (const path of existingPaths) {
75
+ for (let j = 0; j < path.length - 1; j++) {
76
+ const q1 = path[j];
77
+ const q2 = path[j + 1];
78
+ const qHoriz = Math.abs(q1.y - q2.y) < 0.01;
79
+ const qVert = Math.abs(q1.x - q2.x) < 0.01;
80
+ if (isHoriz && qVert) {
81
+ const crossX = q1.x;
82
+ const crossY = p1.y;
83
+ const qMinY = Math.min(q1.y, q2.y);
84
+ const qMaxY = Math.max(q1.y, q2.y);
85
+ if (crossX > minX + minDistFromVertex && crossX < maxX - minDistFromVertex && crossY > qMinY + 6 && crossY < qMaxY - 6) {
86
+ crossings.push({ x: crossX, y: crossY });
87
+ }
88
+ } else if (isVert && qHoriz) {
89
+ const crossX = p1.x;
90
+ const crossY = q1.y;
91
+ const qMinX = Math.min(q1.x, q2.x);
92
+ const qMaxX = Math.max(q1.x, q2.x);
93
+ if (crossX > qMinX + 6 && crossX < qMaxX - 6 && crossY > minY + minDistFromVertex && crossY < maxY - minDistFromVertex) {
94
+ crossings.push({ x: crossX, y: crossY });
95
+ }
96
+ }
97
+ }
98
+ }
99
+ crossings.sort((a, b) => {
100
+ const da = Math.hypot(a.x - p1.x, a.y - p1.y);
101
+ const db = Math.hypot(b.x - p1.x, b.y - p1.y);
102
+ return da - db;
103
+ });
104
+ return crossings;
105
+ }
106
+ function appendSegmentWithHops(parts, start, end, existingPaths = [], hopRadius = 5) {
107
+ const isHoriz = Math.abs(start.y - end.y) < 0.01;
108
+ const isVert = Math.abs(start.x - end.x) < 0.01;
109
+ if (!isHoriz && !isVert || existingPaths.length === 0) {
110
+ parts.push(`L ${round1(end.x)} ${round1(end.y)}`);
111
+ return;
112
+ }
113
+ const hops = findSegmentHops(start, end, existingPaths, hopRadius);
114
+ if (hops.length === 0) {
115
+ parts.push(`L ${round1(end.x)} ${round1(end.y)}`);
116
+ return;
117
+ }
118
+ const R = hopRadius;
119
+ if (isHoriz) {
120
+ const dx = end.x - start.x;
121
+ for (const h of hops) {
122
+ if (dx > 0) {
123
+ parts.push(`L ${round1(h.x - R)} ${round1(start.y)}`);
124
+ parts.push(`A ${R} ${R} 0 0 0 ${round1(h.x + R)} ${round1(start.y)}`);
125
+ } else {
126
+ parts.push(`L ${round1(h.x + R)} ${round1(start.y)}`);
127
+ parts.push(`A ${R} ${R} 0 0 1 ${round1(h.x - R)} ${round1(start.y)}`);
128
+ }
129
+ }
130
+ parts.push(`L ${round1(end.x)} ${round1(end.y)}`);
131
+ } else if (isVert) {
132
+ const dy = end.y - start.y;
133
+ for (const h of hops) {
134
+ if (dy > 0) {
135
+ parts.push(`L ${round1(start.x)} ${round1(h.y - R)}`);
136
+ parts.push(`A ${R} ${R} 0 0 1 ${round1(start.x)} ${round1(h.y + R)}`);
137
+ } else {
138
+ parts.push(`L ${round1(start.x)} ${round1(h.y + R)}`);
139
+ parts.push(`A ${R} ${R} 0 0 0 ${round1(start.x)} ${round1(h.y - R)}`);
140
+ }
141
+ }
142
+ parts.push(`L ${round1(end.x)} ${round1(end.y)}`);
143
+ }
144
+ }
145
+ function toPathD(points, cornerRadius = 14, existingPaths = []) {
53
146
  if (points.length < 2) return "";
54
147
  if (points.length === 2) {
55
- const [a, b] = points;
56
- return `M ${round1(a.x)} ${round1(a.y)} L ${round1(b.x)} ${round1(b.y)}`;
148
+ const parts2 = [`M ${round1(points[0].x)} ${round1(points[0].y)}`];
149
+ appendSegmentWithHops(parts2, points[0], points[1], existingPaths);
150
+ return parts2.join(" ");
57
151
  }
58
152
  if (cornerRadius <= 0) {
59
- return points.map((p, i) => i === 0 ? `M ${round1(p.x)} ${round1(p.y)}` : `L ${round1(p.x)} ${round1(p.y)}`).join(" ");
153
+ const parts2 = [`M ${round1(points[0].x)} ${round1(points[0].y)}`];
154
+ for (let i = 1; i < points.length; i++) {
155
+ appendSegmentWithHops(parts2, points[i - 1], points[i], existingPaths);
156
+ }
157
+ return parts2.join(" ");
60
158
  }
61
159
  const parts = [`M ${round1(points[0].x)} ${round1(points[0].y)}`];
160
+ let prevCornerEnd = points[0];
62
161
  for (let i = 1; i < points.length; i++) {
63
162
  const prev = points[i - 1];
64
163
  const cur = points[i];
65
164
  const next = points[i + 1];
66
165
  if (!next) {
67
- parts.push(`L ${round1(cur.x)} ${round1(cur.y)}`);
166
+ appendSegmentWithHops(parts, prevCornerEnd, cur, existingPaths);
68
167
  continue;
69
168
  }
70
169
  const dx1 = cur.x - prev.x;
@@ -74,7 +173,8 @@ function toPathD(points, cornerRadius = 14) {
74
173
  const len1 = Math.hypot(dx1, dy1);
75
174
  const len2 = Math.hypot(dx2, dy2);
76
175
  if (len1 < 0.5 || len2 < 0.5) {
77
- parts.push(`L ${round1(cur.x)} ${round1(cur.y)}`);
176
+ appendSegmentWithHops(parts, prevCornerEnd, cur, existingPaths);
177
+ prevCornerEnd = cur;
78
178
  continue;
79
179
  }
80
180
  const r = Math.min(cornerRadius, len1 / 2, len2 / 2);
@@ -82,8 +182,9 @@ function toPathD(points, cornerRadius = 14) {
82
182
  const by = cur.y - dy1 / len1 * r;
83
183
  const ax = cur.x + dx2 / len2 * r;
84
184
  const ay = cur.y + dy2 / len2 * r;
85
- parts.push(`L ${round1(bx)} ${round1(by)}`);
185
+ appendSegmentWithHops(parts, prevCornerEnd, { x: bx, y: by }, existingPaths);
86
186
  parts.push(`Q ${round1(cur.x)} ${round1(cur.y)} ${round1(ax)} ${round1(ay)}`);
187
+ prevCornerEnd = { x: ax, y: ay };
87
188
  }
88
189
  return parts.join(" ");
89
190
  }
@@ -109,56 +210,49 @@ function polylineLength(points) {
109
210
  function segmentLength(a, b) {
110
211
  return Math.hypot(b.x - a.x, b.y - a.y);
111
212
  }
112
- var LABEL_BOX_HEIGHT = 20;
113
- function rectsOverlap(a, b) {
114
- return a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1;
115
- }
213
+ var LABEL_BOX_HEIGHT = 18;
116
214
  function overlapCount(rect, obstacles) {
117
215
  let hits = 0;
118
216
  for (const o of obstacles) if (rectsOverlap(rect, o)) hits++;
119
217
  return hits;
120
218
  }
121
219
  function placeFlowLabel(points, textWidth, obstacles, bounds) {
122
- let bestIndex = 0;
123
- let bestLength = -1;
220
+ const halfW = textWidth / 2 + 5;
221
+ const halfH = LABEL_BOX_HEIGHT / 2 + 1;
222
+ const pad = 12;
223
+ let bestPlacement = null;
224
+ let bestScore = Number.POSITIVE_INFINITY;
225
+ const stepX = Math.max(18, halfW + 6);
226
+ const stepY = Math.max(18, halfH + 6);
124
227
  for (let i = 0; i < points.length - 1; i++) {
125
- const len = segmentLength(points[i], points[i + 1]);
126
- if (len > bestLength) {
127
- bestLength = len;
128
- bestIndex = i;
129
- }
130
- }
131
- const a = points[bestIndex] ?? { x: 0, y: 0 };
132
- const b = points[bestIndex + 1] ?? a;
133
- const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
134
- const horizontal = Math.abs(a.x - b.x) >= Math.abs(a.y - b.y);
135
- const half = textWidth / 2;
136
- const halfH = LABEL_BOX_HEIGHT / 2;
137
- const pad = 10;
138
- const base = horizontal ? 16 : half + 14;
139
- const step = horizontal ? LABEL_BOX_HEIGHT + 6 : textWidth + 14;
140
- const order = horizontal ? [-1, 1] : [1, -1];
141
- const offsets = [];
142
- for (let k = 0; k < 8; k++) {
143
- for (const sign of order) offsets.push(sign * (base + k * step));
144
- }
145
- let fallback = null;
146
- let fallbackHits = Number.POSITIVE_INFINITY;
147
- for (const off of offsets) {
148
- const cx = clamp(horizontal ? mid.x : mid.x + off, pad + half, bounds.width - pad - half);
149
- const cy = clamp(horizontal ? mid.y + off : mid.y, pad + halfH, bounds.height - pad - halfH);
150
- const rect = { x1: cx - half, y1: cy - halfH, x2: cx + half, y2: cy + halfH };
151
- const hits = overlapCount(rect, obstacles);
152
- if (hits === 0) return { x: round1(cx), y: round1(cy), rect };
153
- if (hits < fallbackHits) {
154
- fallbackHits = hits;
155
- fallback = { x: round1(cx), y: round1(cy), rect };
156
- }
157
- }
158
- return fallback ?? {
159
- x: round1(mid.x),
160
- y: round1(mid.y),
161
- rect: { x1: mid.x - half, y1: mid.y - halfH, x2: mid.x + half, y2: mid.y + halfH }
228
+ const a = points[i];
229
+ const b = points[i + 1];
230
+ const segLen = segmentLength(a, b);
231
+ if (segLen < 1) continue;
232
+ const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
233
+ const horizontal = Math.abs(a.x - b.x) >= Math.abs(a.y - b.y);
234
+ const offsets = [0];
235
+ for (let k = 1; k <= 8; k++) {
236
+ offsets.push(-k * (horizontal ? stepX : stepY), k * (horizontal ? stepX : stepY));
237
+ }
238
+ for (const off of offsets) {
239
+ const cx = clamp(horizontal ? mid.x + off : mid.x, pad + halfW, bounds.width - pad - halfW);
240
+ const cy = clamp(horizontal ? mid.y : mid.y + off, pad + halfH, bounds.height - pad - halfH);
241
+ const rect = { x1: cx - halfW, y1: cy - halfH, x2: cx + halfW, y2: cy + halfH };
242
+ const hits = overlapCount(rect, obstacles);
243
+ const score = hits * 1e5 + (horizontal ? 0 : 40) + Math.abs(off) * 2 - Math.min(100, segLen) * 0.1;
244
+ if (score < bestScore) {
245
+ bestScore = score;
246
+ bestPlacement = { x: round1(cx), y: round1(cy), rect };
247
+ }
248
+ }
249
+ }
250
+ if (bestPlacement) return bestPlacement;
251
+ const firstMid = points.length >= 2 ? { x: (points[0].x + points[1].x) / 2, y: (points[0].y + points[1].y) / 2 } : { x: 0, y: 0 };
252
+ return {
253
+ x: round1(firstMid.x),
254
+ y: round1(firstMid.y),
255
+ rect: { x1: firstMid.x - halfW, y1: firstMid.y - halfH, x2: firstMid.x + halfW, y2: firstMid.y + halfH }
162
256
  };
163
257
  }
164
258
  function clamp(n, min, max) {
@@ -173,9 +267,12 @@ function clampPointToScene(point, bounds) {
173
267
  };
174
268
  }
175
269
  function laneOffset(lane) {
176
- if (lane <= 0) return 0;
177
- const step = Math.ceil(lane / 2);
178
- return (lane % 2 === 1 ? 1 : -1) * step * 16;
270
+ if (lane === 0) return -8;
271
+ if (lane === 1) return 8;
272
+ if (lane === 2) return -16;
273
+ if (lane === 3) return 16;
274
+ const step = Math.ceil((lane + 1) / 2);
275
+ return (lane % 2 === 1 ? 1 : -1) * step * 8;
179
276
  }
180
277
  function routeLength(points) {
181
278
  let total = 0;
@@ -217,69 +314,149 @@ function routeOrthogonal(sourceRect, targetRect, obstacles, bounds, lane = 0) {
217
314
  const laneShift = laneOffset(lane);
218
315
  const sourceCenter = rectCenter(sourceRect);
219
316
  const targetCenter = rectCenter(targetRect);
220
- const horizontalPrimary = Math.abs(targetCenter.x - sourceCenter.x) >= Math.abs(targetCenter.y - sourceCenter.y);
221
- const sourceRight = targetCenter.x >= sourceCenter.x;
222
- const targetLeft = targetCenter.x >= sourceCenter.x;
223
- const sourceDown = targetCenter.y >= sourceCenter.y;
224
- const targetUp = targetCenter.y >= sourceCenter.y;
225
- const source = horizontalPrimary ? {
226
- x: sourceRight ? sourceRect.x2 : sourceRect.x1,
227
- y: clamp(sourceCenter.y + laneShift, sourceRect.y1 + 16, sourceRect.y2 - 16)
228
- } : {
229
- x: clamp(sourceCenter.x + laneShift, sourceRect.x1 + 20, sourceRect.x2 - 20),
230
- y: sourceDown ? sourceRect.y2 : sourceRect.y1
231
- };
232
- const target = horizontalPrimary ? {
233
- x: targetLeft ? targetRect.x1 : targetRect.x2,
234
- y: clamp(targetCenter.y + laneShift, targetRect.y1 + 16, targetRect.y2 - 16)
235
- } : {
236
- x: clamp(targetCenter.x + laneShift, targetRect.x1 + 20, targetRect.x2 - 20),
237
- y: targetUp ? targetRect.y1 : targetRect.y2
238
- };
317
+ const dx = targetCenter.x - sourceCenter.x;
318
+ const dy = targetCenter.y - sourceCenter.y;
239
319
  const stubLen = 18;
240
- const sStub = horizontalPrimary ? { x: source.x + (sourceRight ? stubLen : -stubLen), y: source.y } : { x: source.x, y: source.y + (sourceDown ? stubLen : -stubLen) };
241
- const tStub = horizontalPrimary ? { x: target.x + (targetLeft ? -stubLen : stubLen), y: target.y } : { x: target.x, y: target.y + (targetUp ? -stubLen : stubLen) };
242
- const infl = obstacles.map((o) => inflateRect(o, 10));
320
+ const allObstacles = obstacles;
243
321
  const candidates = [];
244
- if (Math.abs(source.y - target.y) < 1e-3 || Math.abs(source.x - target.x) < 1e-3) {
245
- candidates.push([source, target]);
246
- }
247
- const midX = round1((source.x + target.x) / 2 + laneShift);
248
- const midY = round1((source.y + target.y) / 2 + laneShift);
249
- candidates.push(
250
- [source, sStub, { x: midX, y: source.y }, { x: midX, y: target.y }, tStub, target],
251
- [source, sStub, { x: source.x, y: midY }, { x: target.x, y: midY }, tStub, target],
252
- [source, { x: midX, y: source.y }, { x: midX, y: target.y }, target],
253
- [source, { x: source.x, y: midY }, { x: target.x, y: midY }, target]
254
- );
255
- const minObstacleY = infl.length ? Math.min(...infl.map((o) => o.y1)) : Math.min(source.y, target.y);
256
- const maxObstacleY = infl.length ? Math.max(...infl.map((o) => o.y2)) : Math.max(source.y, target.y);
257
- const topLane = Math.max(20, minObstacleY - 28 - lane * 14);
258
- const bottomLane = Math.min(bounds.height - 20, maxObstacleY + 28 + lane * 14);
259
- candidates.push(
260
- [source, sStub, { x: sStub.x, y: topLane }, { x: tStub.x, y: topLane }, tStub, target],
261
- [source, sStub, { x: sStub.x, y: bottomLane }, { x: tStub.x, y: bottomLane }, tStub, target]
262
- );
263
- const minObstacleX = infl.length ? Math.min(...infl.map((o) => o.x1)) : Math.min(source.x, target.x);
264
- const maxObstacleX = infl.length ? Math.max(...infl.map((o) => o.x2)) : Math.max(source.x, target.x);
265
- const leftLane = Math.max(20, minObstacleX - 28 - lane * 14);
266
- const rightLane = Math.min(bounds.width - 20, maxObstacleX + 28 + lane * 14);
267
- candidates.push(
268
- [source, sStub, { x: leftLane, y: sStub.y }, { x: leftLane, y: tStub.y }, tStub, target],
269
- [source, sStub, { x: rightLane, y: sStub.y }, { x: rightLane, y: tStub.y }, tStub, target]
270
- );
322
+ if (dx >= 30) {
323
+ let sY = sourceCenter.y;
324
+ let tY = targetCenter.y;
325
+ if (Math.abs(dy) > 16) {
326
+ const dirSign = Math.sign(dy);
327
+ sY = clamp(sourceCenter.y + dirSign * 12, sourceRect.y1 + 10, sourceRect.y2 - 10);
328
+ tY = clamp(targetCenter.y - dirSign * 12, targetRect.y1 + 10, targetRect.y2 - 10);
329
+ } else {
330
+ sY = clamp(sourceCenter.y + (lane > 0 ? lane % 2 === 1 ? 4 : -4 : 0), sourceRect.y1 + 10, sourceRect.y2 - 10);
331
+ tY = clamp(targetCenter.y + (lane > 0 ? lane % 2 === 1 ? 4 : -4 : 0), targetRect.y1 + 10, targetRect.y2 - 10);
332
+ }
333
+ const sPort = { x: sourceRect.x2, y: sY };
334
+ const tPort = { x: targetRect.x1, y: tY };
335
+ if (Math.abs(sY - tY) < 1) {
336
+ candidates.push([sPort, tPort]);
337
+ }
338
+ const rawMidX = (sPort.x + tPort.x) / 2 + laneShift;
339
+ const midX = round1(clamp(rawMidX, sourceRect.x2 + 8, targetRect.x1 - 8));
340
+ candidates.push([
341
+ sPort,
342
+ { x: midX, y: sY },
343
+ { x: midX, y: tY },
344
+ tPort
345
+ ]);
346
+ let minObstacleY = Math.min(sourceRect.y1, targetRect.y1);
347
+ for (const o of allObstacles) {
348
+ if (o.x2 > sourceRect.x2 && o.x1 < targetRect.x1) {
349
+ minObstacleY = Math.min(minObstacleY, o.y1);
350
+ }
351
+ }
352
+ const bypassYTop = Math.max(16, minObstacleY - 24 - Math.abs(laneShift));
353
+ candidates.push([
354
+ { x: sourceCenter.x, y: sourceRect.y1 },
355
+ { x: sourceCenter.x, y: bypassYTop },
356
+ { x: targetCenter.x, y: bypassYTop },
357
+ { x: targetCenter.x, y: targetRect.y1 }
358
+ ]);
359
+ } else if (dx < -30) {
360
+ const sX = clamp(sourceCenter.x + (lane > 0 ? lane % 2 === 1 ? 8 : -8 : 0), sourceRect.x1 + 16, sourceRect.x2 - 16);
361
+ const tX = clamp(targetCenter.x + (lane > 0 ? lane % 2 === 1 ? 8 : -8 : 0), targetRect.x1 + 16, targetRect.x2 - 16);
362
+ let minObstacleY = Math.min(sourceRect.y1, targetRect.y1);
363
+ for (const o of allObstacles) {
364
+ if (o.x2 > targetRect.x1 && o.x1 < sourceRect.x2) {
365
+ minObstacleY = Math.min(minObstacleY, o.y1);
366
+ }
367
+ }
368
+ const highwayYTop = Math.max(16, minObstacleY - 28 - Math.abs(laneShift));
369
+ candidates.push([
370
+ { x: sX, y: sourceRect.y1 },
371
+ { x: sX, y: highwayYTop },
372
+ { x: tX, y: highwayYTop },
373
+ { x: tX, y: targetRect.y1 }
374
+ ]);
375
+ let maxObstacleY = Math.max(sourceRect.y2, targetRect.y2);
376
+ for (const o of allObstacles) {
377
+ if (o.x2 > targetRect.x1 && o.x1 < sourceRect.x2) {
378
+ maxObstacleY = Math.max(maxObstacleY, o.y2);
379
+ }
380
+ }
381
+ const highwayYBottom = Math.min(bounds.height - 16, maxObstacleY + 28 + Math.abs(laneShift));
382
+ candidates.push([
383
+ { x: sX, y: sourceRect.y2 },
384
+ { x: sX, y: highwayYBottom },
385
+ { x: tX, y: highwayYBottom },
386
+ { x: tX, y: targetRect.y2 }
387
+ ]);
388
+ let sY = sourceCenter.y;
389
+ let tY = targetCenter.y;
390
+ if (Math.abs(dy) > 16) {
391
+ const dirSign = Math.sign(dy);
392
+ sY = clamp(sourceCenter.y + dirSign * 12, sourceRect.y1 + 10, sourceRect.y2 - 10);
393
+ tY = clamp(targetCenter.y - dirSign * 12, targetRect.y1 + 10, targetRect.y2 - 10);
394
+ }
395
+ const sPortL = { x: sourceRect.x1, y: sY };
396
+ const tPortR = { x: targetRect.x2, y: tY };
397
+ const midX = round1((sPortL.x + tPortR.x) / 2 + laneShift);
398
+ candidates.push([
399
+ sPortL,
400
+ { x: midX, y: sY },
401
+ { x: midX, y: tY },
402
+ tPortR
403
+ ]);
404
+ } else {
405
+ const sX = clamp(sourceCenter.x + laneShift, sourceRect.x1 + 16, sourceRect.x2 - 16);
406
+ const tX = clamp(targetCenter.x + laneShift, targetRect.x1 + 16, targetRect.x2 - 16);
407
+ const sourceDown = dy >= 0;
408
+ const sPort = { x: sX, y: sourceDown ? sourceRect.y2 : sourceRect.y1 };
409
+ const tPort = { x: tX, y: sourceDown ? targetRect.y1 : targetRect.y2 };
410
+ if (Math.abs(sX - tX) < 1) {
411
+ candidates.push([sPort, tPort]);
412
+ } else {
413
+ const midY = round1((sPort.y + tPort.y) / 2 + laneShift);
414
+ candidates.push([
415
+ sPort,
416
+ { x: sPort.x, y: midY },
417
+ { x: tPort.x, y: midY },
418
+ tPort
419
+ ]);
420
+ }
421
+ }
422
+ for (const obstacle of allObstacles) {
423
+ const bypassYTop = Math.max(16, obstacle.y1 - 18 - Math.abs(laneShift));
424
+ const bypassYBottom = Math.min(bounds.height - 16, obstacle.y2 + 18 + Math.abs(laneShift));
425
+ const sourceRight = dx >= 0;
426
+ const targetLeft = dx >= 0;
427
+ const sP = { x: sourceRight ? sourceRect.x2 : sourceRect.x1, y: sourceCenter.y };
428
+ const tP = { x: targetLeft ? targetRect.x1 : targetRect.x2, y: targetCenter.y };
429
+ candidates.push([
430
+ sP,
431
+ { x: sP.x + (sourceRight ? stubLen : -stubLen), y: sP.y },
432
+ { x: sP.x + (sourceRight ? stubLen : -stubLen), y: bypassYTop },
433
+ { x: tP.x + (targetLeft ? -stubLen : stubLen), y: bypassYTop },
434
+ { x: tP.x + (targetLeft ? -stubLen : stubLen), y: tP.y },
435
+ tP
436
+ ]);
437
+ candidates.push([
438
+ sP,
439
+ { x: sP.x + (sourceRight ? stubLen : -stubLen), y: sP.y },
440
+ { x: sP.x + (sourceRight ? stubLen : -stubLen), y: bypassYBottom },
441
+ { x: tP.x + (targetLeft ? -stubLen : stubLen), y: bypassYBottom },
442
+ { x: tP.x + (targetLeft ? -stubLen : stubLen), y: tP.y },
443
+ tP
444
+ ]);
445
+ }
271
446
  let best = candidates[0];
272
447
  let bestScore = Number.POSITIVE_INFINITY;
273
448
  for (const candidate of candidates) {
274
449
  const cleaned = cleanCollinearPoints(candidate);
275
- const hits = countPathIntersections(cleaned, infl);
276
- const score = hits * 1e5 + routeBends(cleaned) * 800 + routeLength(cleaned);
450
+ const hits = countPathIntersections(cleaned, allObstacles);
451
+ const bends = routeBends(cleaned);
452
+ const length = routeLength(cleaned);
453
+ const score = hits * 1e6 + bends * 500 + length;
277
454
  if (score < bestScore) {
278
- best = cleaned;
279
455
  bestScore = score;
456
+ best = cleaned;
280
457
  }
281
458
  }
282
- return cleanCollinearPoints(best.map((point) => clampPointToScene(point, bounds)));
459
+ return cleanCollinearPoints(best.map((p) => clampPointToScene(p, bounds)));
283
460
  }
284
461
 
285
462
  // src/edges.ts
@@ -396,16 +573,38 @@ function ensureDefs(svg, theme, id) {
396
573
  defs.appendChild(filter);
397
574
  svg.prepend(defs);
398
575
  }
399
- function createEdgeRuntime(svg, from, to, kind, label, theme, sceneId, routeObstacles, labelObstacles, bounds, lane) {
576
+ function isDarkTheme(theme) {
577
+ if (theme.name === "paper" || theme.name === "editorial" || theme.name === "sketchy") {
578
+ return false;
579
+ }
580
+ const canvas = (theme.canvas || theme.surface || "").trim();
581
+ if (canvas.startsWith("#")) {
582
+ const hex = canvas.slice(1);
583
+ const r = parseInt(hex.length === 3 ? hex[0] + hex[0] : hex.slice(0, 2), 16) || 0;
584
+ const g = parseInt(hex.length === 3 ? hex[1] + hex[1] : hex.slice(2, 4), 16) || 0;
585
+ const b = parseInt(hex.length === 3 ? hex[2] + hex[2] : hex.slice(4, 6), 16) || 0;
586
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
587
+ return luminance < 0.5;
588
+ }
589
+ return true;
590
+ }
591
+ function computeEdgeLabelColor(edgeColor, isDark) {
592
+ if (isDark) {
593
+ return `color-mix(in srgb, ${edgeColor} 65%, #f8fafc)`;
594
+ }
595
+ return `color-mix(in srgb, ${edgeColor} 75%, #090d16)`;
596
+ }
597
+ function createEdgeRuntime(svg, from, to, kind, label, theme, sceneId, routeObstacles, labelObstacles, bounds, lane, existingPaths = []) {
400
598
  ensureDefs(svg, theme, sceneId);
401
599
  const color = theme.edges[kind];
402
600
  const style = EDGE_STYLES[kind];
403
601
  const isSelfLoop = from.id === to.id;
404
602
  const points = isSelfLoop ? dedupePoints(selfLoopPath(from)) : dedupePoints(routeEdgePoints(from, to, routeObstacles, bounds, lane));
405
- const d = toPathD(points);
603
+ const d = toPathD(points, 14, existingPaths);
406
604
  const len = polylineLength(points);
407
605
  const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
408
606
  group.setAttribute("data-edge-kind", kind);
607
+ group.setAttribute("class", `markdy-edge markdy-edge--${kind}`);
409
608
  group.style.opacity = "0";
410
609
  const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
411
610
  path.setAttribute("d", d);
@@ -414,6 +613,7 @@ function createEdgeRuntime(svg, from, to, kind, label, theme, sceneId, routeObst
414
613
  path.setAttribute("stroke-width", kind === "dependency" ? "1.5" : "2");
415
614
  path.setAttribute("stroke-linejoin", "round");
416
615
  path.setAttribute("stroke-linecap", "round");
616
+ path.setAttribute("class", `markdy-edge-path markdy-edge-path--${kind}`);
417
617
  if (style.dash) path.setAttribute("stroke-dasharray", style.dash);
418
618
  if (style.marker !== "none") {
419
619
  path.setAttribute("marker-end", `url(#${sceneId}-arrow-${kind})`);
@@ -424,40 +624,46 @@ function createEdgeRuntime(svg, from, to, kind, label, theme, sceneId, routeObst
424
624
  const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
425
625
  dot.setAttribute("r", "4");
426
626
  dot.setAttribute("fill", color);
627
+ dot.setAttribute("class", "markdy-edge-dot");
427
628
  dot.style.opacity = "0";
428
629
  dot.style.filter = `drop-shadow(0 0 6px ${color}) drop-shadow(0 0 12px ${color}88)`;
429
630
  group.append(path, dot);
430
631
  let labelEl;
431
632
  let labelRect;
432
633
  if (label) {
433
- const textWidth = label.length * 6.8 + 14;
634
+ const isDark = isDarkTheme(theme);
635
+ const labelColor = computeEdgeLabelColor(color, isDark);
636
+ const textWidth = Math.max(36, label.length * 6.6 + 8);
434
637
  const placement = placeFlowLabel(points, textWidth, labelObstacles, bounds);
435
638
  labelRect = placement.rect;
436
639
  const plate = document.createElementNS("http://www.w3.org/2000/svg", "rect");
437
- const padX = 8;
640
+ const padX = 6;
438
641
  const halfW = textWidth / 2;
642
+ plate.setAttribute("class", "markdy-edge-plate");
439
643
  plate.setAttribute("x", String(placement.x - halfW - padX));
440
- plate.setAttribute("y", String(placement.y - 10));
644
+ plate.setAttribute("y", String(placement.y - 9));
441
645
  plate.setAttribute("width", String(textWidth + padX * 2));
442
- plate.setAttribute("height", "20");
443
- plate.setAttribute("rx", "6");
444
- plate.setAttribute("fill", theme.labelPlate ?? theme.surface);
445
- plate.setAttribute("fill-opacity", "0.96");
446
- plate.setAttribute("stroke", theme.hairline ?? `color-mix(in srgb, ${theme.border} 70%, transparent)`);
447
- plate.setAttribute("stroke-width", "1");
646
+ plate.setAttribute("height", "18");
647
+ plate.setAttribute("rx", "4");
648
+ plate.setAttribute("ry", "4");
649
+ plate.setAttribute("fill", theme.canvas ?? theme.surface ?? "#ffffff");
650
+ plate.setAttribute("fill-opacity", "0.85");
651
+ plate.setAttribute("stroke", translucentColor(color, "28"));
652
+ plate.setAttribute("stroke-width", "0.85");
448
653
  plate.style.opacity = "0";
449
- plate.style.filter = "drop-shadow(0 1px 3px rgba(0,0,0,0.12))";
654
+ plate.style.filter = "none";
450
655
  group.appendChild(plate);
451
656
  labelEl = document.createElementNS("http://www.w3.org/2000/svg", "text");
657
+ labelEl.setAttribute("class", "markdy-edge-label");
452
658
  labelEl.setAttribute("x", String(placement.x));
453
659
  labelEl.setAttribute("y", String(placement.y + 0.5));
454
660
  labelEl.setAttribute("text-anchor", "middle");
455
661
  labelEl.setAttribute("dominant-baseline", "middle");
456
- labelEl.setAttribute("font-size", "11");
662
+ labelEl.setAttribute("font-size", "10.5");
457
663
  labelEl.setAttribute("font-weight", "500");
458
664
  labelEl.setAttribute("letter-spacing", "0.02em");
459
- labelEl.setAttribute("font-family", "ui-monospace, SFMono-Regular, Menlo, monospace");
460
- labelEl.setAttribute("fill", theme.text);
665
+ labelEl.setAttribute("font-family", "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace");
666
+ labelEl.setAttribute("fill", labelColor);
461
667
  labelEl.textContent = label;
462
668
  labelEl.style.opacity = "0";
463
669
  group.appendChild(labelEl);
@@ -482,6 +688,11 @@ function setEdgeVisible(runtime, visible) {
482
688
  runtime.group.style.opacity = visible ? "1" : "0";
483
689
  if (runtime.label) runtime.label.style.opacity = visible ? "1" : "0";
484
690
  if (runtime.labelPlate) runtime.labelPlate.style.opacity = visible ? "1" : "0";
691
+ if (visible) {
692
+ runtime.path.classList.add("markdy-edge-path--flowing");
693
+ } else {
694
+ runtime.path.classList.remove("markdy-edge-path--flowing");
695
+ }
485
696
  }
486
697
  function translucentColor(color, alpha = "aa") {
487
698
  const value = color.trim();
@@ -493,8 +704,16 @@ function translucentColor(color, alpha = "aa") {
493
704
  return `color-mix(in srgb, ${value} 67%, transparent)`;
494
705
  }
495
706
  function nextEdgeLane(lanes, from, to) {
496
- const pair = [from, to].sort().join("|");
497
- const keys = [`pair:${pair}`, `out:${from}`, `in:${to}`];
707
+ const fromId = typeof from === "string" ? from : from.id;
708
+ const toId = typeof to === "string" ? to : to.id;
709
+ const pair = [fromId, toId].sort().join("|");
710
+ const keys = [`pair:${pair}`, `out:${fromId}`, `in:${toId}`];
711
+ if (typeof from !== "string" && typeof to !== "string") {
712
+ const colFrom = Math.round(from.x / 80);
713
+ const colTo = Math.round(to.x / 80);
714
+ const corridorKey = colFrom !== colTo ? `corridor-x:${Math.min(colFrom, colTo)}-${Math.max(colFrom, colTo)}` : `corridor-y:${Math.round(from.y / 60)}-${Math.round(to.y / 60)}`;
715
+ keys.push(corridorKey);
716
+ }
498
717
  const lane = Math.max(...keys.map((key) => lanes.get(key) ?? 0));
499
718
  for (const key of keys) lanes.set(key, (lanes.get(key) ?? 0) + 1);
500
719
  return lane;
@@ -568,14 +787,42 @@ function animateEdgeReveal(runtime, startMs, durMs) {
568
787
  return anims;
569
788
  }
570
789
  function animateEdgeEmphasis(runtime, startMs, durMs, strength, color) {
790
+ const anims = [];
571
791
  const baseFilter = runtime.kind === "dependency" ? "none" : `drop-shadow(0 0 3px ${translucentColor(runtime.color, "33")})`;
572
792
  const glowColor = color ?? runtime.color;
573
793
  const radius = Math.max(4, Math.min(16, 5 + strength * 4));
574
794
  const peakFilter = `drop-shadow(0 0 ${radius}px ${translucentColor(glowColor)}) brightness(${1 + Math.min(strength, 2) * 0.08})`;
575
- return runtime.path.animate(
576
- [{ filter: baseFilter }, { filter: peakFilter }, { filter: baseFilter }],
577
- { duration: durMs, delay: startMs, fill: "none", easing: "ease-in-out" }
795
+ anims.push(
796
+ runtime.path.animate(
797
+ [{ filter: baseFilter }, { filter: peakFilter }, { filter: baseFilter }],
798
+ { duration: durMs, delay: startMs, fill: "none", easing: "ease-in-out" }
799
+ )
578
800
  );
801
+ if (runtime.label) {
802
+ anims.push(
803
+ runtime.label.animate(
804
+ [
805
+ { filter: "none", opacity: 1 },
806
+ { filter: `drop-shadow(0 0 4px ${translucentColor(glowColor, "88")}) brightness(1.25)`, opacity: 1 },
807
+ { filter: "none", opacity: 1 }
808
+ ],
809
+ { duration: durMs, delay: startMs, fill: "none", easing: "ease-in-out" }
810
+ )
811
+ );
812
+ }
813
+ if (runtime.labelPlate) {
814
+ anims.push(
815
+ runtime.labelPlate.animate(
816
+ [
817
+ { stroke: translucentColor(runtime.color, "22") },
818
+ { stroke: translucentColor(glowColor, "88") },
819
+ { stroke: translucentColor(runtime.color, "22") }
820
+ ],
821
+ { duration: durMs, delay: startMs, fill: "none", easing: "ease-in-out" }
822
+ )
823
+ );
824
+ }
825
+ return anims;
579
826
  }
580
827
  function computeFrameTransform(targetIds, nodes, bounds, requestedZoom = DEFAULT_FRAME_ZOOM) {
581
828
  const targets = new Set(targetIds);
@@ -614,13 +861,14 @@ function buildCueAnimations(cues, nodeEls, nodes, theme, scene, titleEl, bounds,
614
861
  const edgeRectById = new Map(nodes.map((node) => [node.id, boxRect(node)]));
615
862
  const edgeLabels = [];
616
863
  const edgeLanes = /* @__PURE__ */ new Map();
864
+ const placedPaths = [];
617
865
  for (const edge of diagramType === "sequence" ? [] : edges) {
618
866
  if (edge.structural || edgeRuntimes.has(edge.id)) continue;
619
867
  const from = nodeById.get(edge.from);
620
868
  const to = nodeById.get(edge.to);
621
869
  if (!from || !to) continue;
622
- const routeObstacles = [...edgeRectById.entries()].filter(([id]) => id !== from.id && id !== to.id).map(([, rect]) => rect);
623
- const lane = nextEdgeLane(edgeLanes, edge.from, edge.to);
870
+ const routeObstacles = [...allNodeRects, ...edgeLabels.map((l) => inflateRect(l, 4))];
871
+ const lane = nextEdgeLane(edgeLanes, from, to);
624
872
  const runtime = createEdgeRuntime(
625
873
  svg,
626
874
  from,
@@ -630,10 +878,12 @@ function buildCueAnimations(cues, nodeEls, nodes, theme, scene, titleEl, bounds,
630
878
  theme,
631
879
  sceneId,
632
880
  routeObstacles,
633
- [...edgeRects, ...edgeLabels],
881
+ [...allNodeRects, ...edgeLabels],
634
882
  bounds,
635
- lane
883
+ lane,
884
+ placedPaths
636
885
  );
886
+ placedPaths.push(runtime.points);
637
887
  if (runtime.labelRect) edgeLabels.push(runtime.labelRect);
638
888
  edgeRuntimes.set(edge.id, runtime);
639
889
  }
@@ -697,7 +947,7 @@ function buildCueAnimations(cues, nodeEls, nodes, theme, scene, titleEl, bounds,
697
947
  continue;
698
948
  }
699
949
  const runtime = edgeRuntimes.get(id);
700
- if (runtime) anims.push(animateEdgeEmphasis(runtime, startMs, durMs, strength, typeof cue.params.color === "string" ? cue.params.color : void 0));
950
+ if (runtime) anims.push(...animateEdgeEmphasis(runtime, startMs, durMs, strength, typeof cue.params.color === "string" ? cue.params.color : void 0));
701
951
  }
702
952
  continue;
703
953
  }
@@ -719,7 +969,7 @@ function buildCueAnimations(cues, nodeEls, nodes, theme, scene, titleEl, bounds,
719
969
  continue;
720
970
  }
721
971
  const runtime = edgeRuntimes.get(id);
722
- if (runtime) anims.push(animateEdgeEmphasis(runtime, startMs, durMs, zoom, void 0));
972
+ if (runtime) anims.push(...animateEdgeEmphasis(runtime, startMs, durMs, zoom, void 0));
723
973
  }
724
974
  continue;
725
975
  }
@@ -749,18 +999,29 @@ function buildCueAnimations(cues, nodeEls, nodes, theme, scene, titleEl, bounds,
749
999
  const from = nodeById.get(seg.from);
750
1000
  const to = nodeById.get(seg.to);
751
1001
  if (!from || !to) continue;
752
- const routeObstacles = [];
753
- for (const [id, rect] of rectById) {
754
- if (id !== seg.from && id !== seg.to) routeObstacles.push(rect);
755
- }
756
- const lane = nextEdgeLane(laneByPair, seg.from, seg.to);
1002
+ const routeObstacles = [...allNodeRects, ...placedLabels.map((l) => inflateRect(l, 4))];
1003
+ const lane = nextEdgeLane(laneByPair, from, to);
757
1004
  const labelObstacles = [...allNodeRects, ...placedLabels];
758
1005
  const edgeId = cue.edgeId ?? edges.find(
759
1006
  (edge) => !edge.structural && edge.from === seg.from && edge.to === seg.to && edge.kind === seg.op && edge.label === seg.label
760
1007
  )?.id;
761
1008
  let runtime = edgeId ? edgeRuntimes.get(edgeId) : void 0;
762
1009
  if (!runtime) {
763
- runtime = createEdgeRuntime(svg, from, to, seg.op, seg.label, theme, sceneId, routeObstacles, labelObstacles, bounds, lane);
1010
+ runtime = createEdgeRuntime(
1011
+ svg,
1012
+ from,
1013
+ to,
1014
+ seg.op,
1015
+ seg.label,
1016
+ theme,
1017
+ sceneId,
1018
+ routeObstacles,
1019
+ labelObstacles,
1020
+ bounds,
1021
+ lane,
1022
+ placedPaths
1023
+ );
1024
+ placedPaths.push(runtime.points);
764
1025
  if (runtime.labelRect) placedLabels.push(runtime.labelRect);
765
1026
  if (edgeId) edgeRuntimes.set(edgeId, runtime);
766
1027
  }
@@ -792,16 +1053,14 @@ function buildStructuralEdgeAnimations(edges, nodes, theme, scene, bounds, edgeR
792
1053
  const rectById = new Map(nodes.map((n) => [n.id, boxRect(n)]));
793
1054
  const allNodeRects = [...rectById.values()];
794
1055
  const placedLabels = [];
1056
+ const placedPaths = [];
795
1057
  const laneByPair = /* @__PURE__ */ new Map();
796
1058
  const svg = ensureEdgeLayer(scene);
797
1059
  for (const edge of structural) {
798
1060
  const from = nodeById.get(edge.from);
799
1061
  const to = nodeById.get(edge.to);
800
1062
  if (!from || !to) continue;
801
- const routeObstacles = [];
802
- for (const [id, rect] of rectById) {
803
- if (id !== edge.from && id !== edge.to) routeObstacles.push(rect);
804
- }
1063
+ const routeObstacles = [...allNodeRects, ...placedLabels.map((l) => inflateRect(l, 4))];
805
1064
  const lane = nextEdgeLane(laneByPair, edge.from, edge.to);
806
1065
  const labelObstacles = [...allNodeRects, ...placedLabels];
807
1066
  const runtime = createEdgeRuntime(
@@ -815,8 +1074,10 @@ function buildStructuralEdgeAnimations(edges, nodes, theme, scene, bounds, edgeR
815
1074
  routeObstacles,
816
1075
  labelObstacles,
817
1076
  bounds,
818
- lane
1077
+ lane,
1078
+ placedPaths
819
1079
  );
1080
+ placedPaths.push(runtime.points);
820
1081
  if (runtime.labelRect) placedLabels.push(runtime.labelRect);
821
1082
  setEdgeVisible(runtime, true);
822
1083
  edgeRuntimes.set(edge.id, runtime);
@@ -1165,9 +1426,14 @@ function ensureGroupStyles(doc) {
1165
1426
  .markdy-group-boundary {
1166
1427
  position: absolute;
1167
1428
  box-sizing: border-box;
1168
- border: 1px dashed var(--md-group-border, color-mix(in srgb, var(--md-border) 60%, transparent));
1169
- border-radius: 14px;
1170
- background: color-mix(in srgb, var(--md-surface-raised) 25%, transparent);
1429
+ border: 1px solid var(--md-group-border, color-mix(in srgb, var(--md-border) 45%, transparent));
1430
+ border-radius: 16px;
1431
+ background: color-mix(in srgb, var(--md-surface-raised) 32%, transparent);
1432
+ box-shadow:
1433
+ 0 4px 20px -4px var(--md-shadow, rgba(0, 0, 0, 0.25)),
1434
+ inset 0 1px 0 rgba(255, 255, 255, 0.06);
1435
+ backdrop-filter: blur(8px);
1436
+ -webkit-backdrop-filter: blur(8px);
1171
1437
  pointer-events: none;
1172
1438
  z-index: 40;
1173
1439
  }
@@ -1175,17 +1441,19 @@ function ensureGroupStyles(doc) {
1175
1441
  position: absolute;
1176
1442
  left: 14px;
1177
1443
  top: 10px;
1178
- padding: 3px 8px;
1179
- font-size: 10px;
1444
+ padding: 4px 10px;
1445
+ font-size: 10.5px;
1180
1446
  font-weight: 600;
1181
1447
  letter-spacing: 0.08em;
1182
1448
  text-transform: uppercase;
1183
- color: var(--md-text-muted);
1184
- background: color-mix(in srgb, var(--md-surface-raised) 80%, transparent);
1449
+ color: var(--md-text);
1450
+ background: color-mix(in srgb, var(--md-surface-raised) 85%, transparent);
1185
1451
  border: 1px solid var(--md-group-border, color-mix(in srgb, var(--md-border) 50%, transparent));
1186
- border-radius: 5px;
1452
+ border-radius: 6px;
1187
1453
  font-family: var(--md-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1188
- backdrop-filter: blur(4px);
1454
+ backdrop-filter: blur(6px);
1455
+ -webkit-backdrop-filter: blur(6px);
1456
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
1189
1457
  }
1190
1458
  `;
1191
1459
  doc.head.appendChild(style);
@@ -1199,10 +1467,11 @@ function createGroupBoundaryEl(boundary, theme, doc = document) {
1199
1467
  el.style.width = `${boundary.width}px`;
1200
1468
  el.style.height = `${boundary.height}px`;
1201
1469
  el.style.setProperty("--md-group-border", theme.hairline ?? theme.border);
1202
- if (boundary.label) {
1203
- const label = document.createElement("div");
1470
+ const displayLabel = boundary.label || boundary.id;
1471
+ if (displayLabel) {
1472
+ const label = doc.createElement("div");
1204
1473
  label.className = "markdy-group-boundary__label";
1205
- label.textContent = boundary.label;
1474
+ label.textContent = displayLabel;
1206
1475
  el.appendChild(label);
1207
1476
  }
1208
1477
  return el;
@@ -1226,6 +1495,8 @@ function ensureNodeStyles(doc) {
1226
1495
  box-sizing: border-box;
1227
1496
  width: var(--md-node-w, 180px);
1228
1497
  height: var(--md-node-h, 76px);
1498
+ min-width: 140px;
1499
+ min-height: 64px;
1229
1500
  border-radius: 12px;
1230
1501
  background:
1231
1502
  linear-gradient(180deg,
@@ -1233,10 +1504,10 @@ function ensureNodeStyles(doc) {
1233
1504
  var(--md-node-surface, var(--md-surface)));
1234
1505
  color: var(--md-text);
1235
1506
  box-shadow:
1236
- 0 1px 2px color-mix(in srgb, var(--md-shadow, rgba(2, 6, 23, 0.4)) 40%, transparent),
1237
- 0 8px 20px -8px var(--md-shadow, rgba(2, 6, 23, 0.45)),
1238
- inset 0 0 0 1px var(--md-hairline, color-mix(in srgb, var(--md-border) 45%, transparent)),
1239
- inset 0 1px 0 rgba(255, 255, 255, 0.08);
1507
+ 0 1px 3px color-mix(in srgb, var(--md-shadow, rgba(2, 6, 23, 0.35)) 35%, transparent),
1508
+ 0 10px 24px -10px var(--md-shadow, rgba(2, 6, 23, 0.45)),
1509
+ inset 0 0 0 1px var(--md-hairline, color-mix(in srgb, var(--md-border) 50%, transparent)),
1510
+ inset 0 1px 0 rgba(255, 255, 255, 0.12);
1240
1511
  font-family: var(--md-font-node, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, sans-serif);
1241
1512
  overflow: hidden;
1242
1513
  opacity: 0;
@@ -1250,16 +1521,16 @@ function ensureNodeStyles(doc) {
1250
1521
  .markdy-node[data-focused="1"] {
1251
1522
  box-shadow:
1252
1523
  0 2px 6px rgba(2, 6, 23, 0.35),
1253
- 0 16px 36px -12px rgba(2, 6, 23, 0.65),
1254
- inset 0 0 0 1.5px color-mix(in srgb, var(--md-accent) 75%, transparent),
1255
- 0 0 0 3px color-mix(in srgb, var(--md-accent) 22%, transparent);
1524
+ 0 18px 38px -10px rgba(2, 6, 23, 0.7),
1525
+ inset 0 0 0 1.5px color-mix(in srgb, var(--md-accent) 80%, transparent),
1526
+ 0 0 0 3px color-mix(in srgb, var(--md-accent) 24%, transparent);
1256
1527
  }
1257
1528
  .markdy-node[data-glow="1"] {
1258
1529
  box-shadow:
1259
1530
  0 2px 6px rgba(2, 6, 23, 0.35),
1260
- 0 0 0 1.5px color-mix(in srgb, var(--md-glow-color, var(--md-accent)) 65%, transparent),
1261
- 0 0 24px -2px color-mix(in srgb, var(--md-glow-color, var(--md-accent)) 48%, transparent),
1262
- inset 0 0 18px -8px color-mix(in srgb, var(--md-glow-color, var(--md-accent)) 45%, transparent);
1531
+ 0 0 0 1.5px color-mix(in srgb, var(--md-glow-color, var(--md-accent)) 70%, transparent),
1532
+ 0 0 28px -2px color-mix(in srgb, var(--md-glow-color, var(--md-accent)) 55%, transparent),
1533
+ inset 0 0 18px -8px color-mix(in srgb, var(--md-glow-color, var(--md-accent)) 50%, transparent);
1263
1534
  }
1264
1535
  .markdy-node__rail { display: none; }
1265
1536
  .markdy-node__type { display: none; }
@@ -1268,13 +1539,14 @@ function ensureNodeStyles(doc) {
1268
1539
  padding: 0 14px;
1269
1540
  display: flex;
1270
1541
  align-items: center;
1271
- gap: 10px;
1542
+ gap: 12px;
1272
1543
  min-width: 0;
1544
+ box-sizing: border-box;
1273
1545
  }
1274
1546
  .markdy-node__icon {
1275
1547
  flex: 0 0 auto;
1276
- width: 32px;
1277
- height: 32px;
1548
+ width: 34px;
1549
+ height: 34px;
1278
1550
  border-radius: 9px;
1279
1551
  display: flex;
1280
1552
  align-items: center;
@@ -1282,15 +1554,15 @@ function ensureNodeStyles(doc) {
1282
1554
  color: var(--md-role-color, var(--md-accent));
1283
1555
  background:
1284
1556
  linear-gradient(180deg,
1285
- color-mix(in srgb, var(--md-role-color, var(--md-accent)) 22%, transparent),
1557
+ color-mix(in srgb, var(--md-role-color, var(--md-accent)) 24%, transparent),
1286
1558
  color-mix(in srgb, var(--md-role-color, var(--md-accent)) 10%, transparent));
1287
1559
  box-shadow:
1288
- inset 0 0 0 1px color-mix(in srgb, var(--md-role-color, var(--md-accent)) 35%, transparent),
1289
- inset 0 1px 0 rgba(255, 255, 255, 0.15);
1560
+ inset 0 0 0 1px color-mix(in srgb, var(--md-role-color, var(--md-accent)) 38%, transparent),
1561
+ inset 0 1px 0 rgba(255, 255, 255, 0.18);
1290
1562
  }
1291
1563
  .markdy-node__icon svg {
1292
- width: 17px;
1293
- height: 17px;
1564
+ width: 18px;
1565
+ height: 18px;
1294
1566
  display: block;
1295
1567
  stroke: currentColor;
1296
1568
  }
@@ -1321,7 +1593,8 @@ function ensureNodeStyles(doc) {
1321
1593
  font-size: 13.5px;
1322
1594
  font-weight: 600;
1323
1595
  letter-spacing: -0.01em;
1324
- line-height: 1.22;
1596
+ line-height: 1.24;
1597
+ color: var(--md-text);
1325
1598
  display: -webkit-box;
1326
1599
  -webkit-box-orient: vertical;
1327
1600
  -webkit-line-clamp: 3;
@@ -1333,13 +1606,13 @@ function ensureNodeStyles(doc) {
1333
1606
  }
1334
1607
  .markdy-node__value {
1335
1608
  flex: 0 0 auto;
1336
- font-size: 17px;
1609
+ font-size: 16px;
1337
1610
  font-weight: 700;
1338
1611
  color: var(--md-ink, var(--md-text));
1339
1612
  font-variant-numeric: tabular-nums;
1340
1613
  }
1341
1614
  .markdy-node[data-role="client"] { border-radius: 14px 14px 10px 10px; }
1342
- .markdy-node[data-role="data"] { border-radius: 12px 12px 18px 18px; }
1615
+ .markdy-node[data-role="data"] { border-radius: 12px 12px 16px 16px; }
1343
1616
  .markdy-scene-title {
1344
1617
  position: absolute;
1345
1618
  left: 48px;
@@ -1413,16 +1686,18 @@ function ensureNodeStyles(doc) {
1413
1686
  }
1414
1687
  .markdy-node[data-is-container="1"] {
1415
1688
  background: color-mix(in srgb, var(--md-surface-raised) 25%, transparent);
1416
- border: 1.5px dashed color-mix(in srgb, var(--md-role-color, var(--md-accent)) 45%, var(--md-border) 55%);
1417
- box-shadow: inset 0 0 0 1px var(--md-hairline);
1689
+ border: 1px solid color-mix(in srgb, var(--md-role-color, var(--md-accent)) 40%, var(--md-border) 60%);
1690
+ box-shadow: inset 0 0 0 1px var(--md-hairline), 0 4px 16px -4px var(--md-shadow, rgba(0,0,0,0.25));
1691
+ backdrop-filter: blur(8px);
1692
+ -webkit-backdrop-filter: blur(8px);
1418
1693
  }
1419
1694
  .markdy-node[data-is-container="1"] .markdy-node__body {
1420
1695
  align-items: flex-start;
1421
1696
  padding: 12px 16px;
1422
1697
  }
1423
1698
  .markdy-node[data-is-container="1"][data-focal="1"] {
1424
- background: color-mix(in srgb, var(--md-accent-tint, var(--md-accent)) 14%, transparent);
1425
- border: 1.5px solid color-mix(in srgb, var(--md-accent) 65%, transparent);
1699
+ background: color-mix(in srgb, var(--md-accent-tint, var(--md-accent)) 16%, transparent);
1700
+ border: 1.5px solid color-mix(in srgb, var(--md-accent) 70%, transparent);
1426
1701
  }
1427
1702
  .markdy-node[data-shape="rounded"] {
1428
1703
  border-radius: 16px;
@@ -1844,6 +2119,9 @@ function createTitleEl(title) {
1844
2119
  const el = document.createElement("div");
1845
2120
  el.className = "markdy-scene-title";
1846
2121
  el.textContent = title;
2122
+ if (!title) {
2123
+ el.style.display = "none";
2124
+ }
1847
2125
  return el;
1848
2126
  }
1849
2127
 
@@ -2147,6 +2425,28 @@ function ensureSceneStyles(doc) {
2147
2425
  from { opacity: 0.24; transform: scale(0.85); }
2148
2426
  to { opacity: 0.9; transform: scale(1.15); }
2149
2427
  }
2428
+ @keyframes markdy-flow-dash {
2429
+ to { stroke-dashoffset: -24; }
2430
+ }
2431
+ .markdy-edge {
2432
+ transition: opacity 0.2s ease;
2433
+ }
2434
+ .markdy-edge-path--flowing {
2435
+ animation: markdy-flow-dash 1.2s linear infinite;
2436
+ }
2437
+ .markdy-edge-path {
2438
+ transition: stroke 0.2s ease, stroke-width 0.2s ease, filter 0.2s ease;
2439
+ }
2440
+ .markdy-edge-plate {
2441
+ transition: opacity 0.2s ease, fill-opacity 0.2s ease, stroke 0.2s ease;
2442
+ pointer-events: none;
2443
+ }
2444
+ .markdy-edge-label {
2445
+ user-select: none;
2446
+ -webkit-user-select: none;
2447
+ pointer-events: none;
2448
+ transition: fill 0.2s ease, opacity 0.2s ease, filter 0.2s ease;
2449
+ }
2150
2450
  .markdy-constellation-star {
2151
2451
  transform-box: fill-box;
2152
2452
  transform-origin: center;
@@ -2223,13 +2523,17 @@ function ensureSceneStyles(doc) {
2223
2523
  @media (prefers-reduced-motion: reduce) {
2224
2524
  .markdy-node,
2225
2525
  .markdy-beat-caption,
2226
- .markdy-constellation-star {
2526
+ .markdy-constellation-star,
2527
+ .markdy-edge-path,
2528
+ .markdy-edge-path--flowing {
2227
2529
  animation-duration: 0.001ms !important;
2228
2530
  animation-iteration-count: 1 !important;
2229
2531
  transition-duration: 0.001ms !important;
2532
+ animation: none !important;
2230
2533
  }
2231
2534
  .markdy-node { opacity: 1 !important; transform: none !important; }
2232
2535
  .markdy-constellation-star { animation: none !important; opacity: 0.7 !important; }
2536
+ .markdy-edge-path--flowing { animation: none !important; }
2233
2537
  }
2234
2538
  @media print {
2235
2539
  .markdy-node { opacity: 1 !important; transform: none !important; }
@@ -2277,11 +2581,11 @@ function applyThemeToScene(scene, theme) {
2277
2581
 
2278
2582
  // src/diagram.ts
2279
2583
  var NORMAL_PLAYBACK_RATE = 4 / 5;
2280
- var DEFAULT_PLAYBACK_RATE = 1;
2281
2584
  var MIN_VIEWPORT_ZOOM = 0.5;
2282
2585
  var MAX_VIEWPORT_ZOOM = 3;
2283
2586
  var VIEWPORT_ZOOM_STEP = 15e-4;
2284
2587
  var DRAG_CLICK_THRESHOLD_PX = 4;
2588
+ var BEAT_NAV_EPSILON_S = 0.05;
2285
2589
  var MARKDY_PLAYGROUND_URL = "https://markdy.com/playground/";
2286
2590
  function encodeCodeForPlaygroundHash(code) {
2287
2591
  return encodeURIComponent(btoa(encodeURIComponent(code)));
@@ -2334,6 +2638,7 @@ function createDiagram(opts) {
2334
2638
  playbackRate: initialPlaybackRate,
2335
2639
  controls: explicitControls,
2336
2640
  interactiveViewport: explicitInteractiveViewport,
2641
+ shareUrl,
2337
2642
  autoplay: explicitAutoplay,
2338
2643
  loop: explicitLoop,
2339
2644
  copyright: explicitCopyright,
@@ -2346,15 +2651,46 @@ function createDiagram(opts) {
2346
2651
  for (const w of ast.diagnostics) {
2347
2652
  if (w.severity === "warning") onWarning(w);
2348
2653
  }
2349
- const controls = explicitControls ?? plan.meta.controls ?? false;
2350
- const interactiveViewport = explicitInteractiveViewport ?? (explicitControls !== void 0 ? explicitControls : plan.meta.interactiveViewport ?? plan.meta.controls ?? false);
2351
- const autoplay = explicitAutoplay ?? plan.meta.autoplay ?? true;
2352
- const loop = explicitLoop ?? plan.meta.loop ?? true;
2353
- const copyright = explicitCopyright ?? plan.meta.copyright ?? true;
2354
- const rawPlaybackRate = initialPlaybackRate ?? plan.meta.playbackRate ?? DEFAULT_PLAYBACK_RATE;
2355
- let playbackRate = Number.isFinite(rawPlaybackRate) && rawPlaybackRate > 0 ? rawPlaybackRate : DEFAULT_PLAYBACK_RATE;
2356
- const showSceneBoundaryProgress = sceneBoundaryProgress === false || sceneBoundaryProgress === void 0 && progressBar === false ? false : plan.meta.progressColor === "none" ? false : true;
2357
- const rawColor = progressColor ?? progressBarColor ?? (typeof sceneBoundaryProgress === "string" && sceneBoundaryProgress !== "true" && sceneBoundaryProgress !== "false" ? sceneBoundaryProgress : typeof progressBar === "string" && progressBar !== "true" && progressBar !== "false" ? progressBar : void 0) ?? (plan.meta.progressColor && plan.meta.progressColor !== "none" ? plan.meta.progressColor : void 0);
2654
+ const hostProgress = sceneBoundaryProgress === false || sceneBoundaryProgress === void 0 && progressBar === false ? "none" : void 0;
2655
+ const hostProgressColor = progressColor ?? progressBarColor ?? (typeof sceneBoundaryProgress === "string" && sceneBoundaryProgress !== "true" && sceneBoundaryProgress !== "false" ? sceneBoundaryProgress : typeof progressBar === "string" && progressBar !== "true" && progressBar !== "false" ? progressBar : void 0);
2656
+ const player = resolvePlayer(plan.meta.player, {
2657
+ autoplay: explicitAutoplay,
2658
+ loop: explicitLoop,
2659
+ playbackRate: initialPlaybackRate,
2660
+ copyright: explicitCopyright,
2661
+ controls: explicitControls,
2662
+ interactiveViewport: explicitInteractiveViewport,
2663
+ progress: hostProgress,
2664
+ progressColor: hostProgressColor
2665
+ });
2666
+ const { autoplay, loop } = player.playback;
2667
+ const {
2668
+ enabled: interactiveViewport,
2669
+ zoom: allowZoom,
2670
+ pan: allowPan,
2671
+ clickToPlay,
2672
+ doubleClickToReset,
2673
+ keyboard: keyboardShortcuts
2674
+ } = player.interaction;
2675
+ const {
2676
+ enabled: showControls,
2677
+ play: playButton,
2678
+ restart: restartButton,
2679
+ prevBeat: prevBeatButton,
2680
+ nextBeat: nextBeatButton,
2681
+ seek: seekBar,
2682
+ speed: speedControls,
2683
+ speeds: speedOptions,
2684
+ fit: fitViewButton,
2685
+ resetView: resetViewButton,
2686
+ svg: svgButton,
2687
+ share: shareButton
2688
+ } = player.controls;
2689
+ const copyright = player.chrome.badge;
2690
+ const progressMode = player.chrome.progress;
2691
+ const showProgress = progressMode !== "none";
2692
+ let playbackRate = player.playback.rate;
2693
+ const rawColor = player.chrome.progressColor;
2358
2694
  const customColor = rawColor && rawColor.trim() !== "rainbow" ? rawColor.trim() : null;
2359
2695
  const DEFAULT_RAINBOW = "hsl(0,90%,60%), hsl(45,90%,55%), hsl(90,80%,50%), hsl(180,80%,50%), hsl(270,80%,55%), hsl(330,90%,60%)";
2360
2696
  const totalDurationMs = plan.duration * 1e3;
@@ -2373,11 +2709,11 @@ function createDiagram(opts) {
2373
2709
  });
2374
2710
  container.appendChild(viewport);
2375
2711
  let progressEl = null;
2376
- if (showSceneBoundaryProgress) {
2712
+ if (showProgress) {
2377
2713
  progressEl = document.createElement("div");
2378
2714
  Object.assign(progressEl.style, {
2379
2715
  position: "absolute",
2380
- inset: "0",
2716
+ ...progressMode === "bar" ? { left: "0", right: "0", bottom: "0", height: "3px" } : { inset: "0" },
2381
2717
  zIndex: "9999",
2382
2718
  pointerEvents: "none",
2383
2719
  borderRadius: "inherit"
@@ -2388,6 +2724,12 @@ function createDiagram(opts) {
2388
2724
  const tlAngleNorm = (tlAngle % 360 + 360) % 360;
2389
2725
  function updateProgressBar(pct) {
2390
2726
  if (!progressEl) return;
2727
+ if (progressMode === "bar") {
2728
+ progressEl.style.background = customColor ?? "#2563eb";
2729
+ progressEl.style.transformOrigin = "left center";
2730
+ progressEl.style.transform = `scaleX(${pct})`;
2731
+ return;
2732
+ }
2391
2733
  const deg = pct * 360;
2392
2734
  const colorStops = customColor ? customColor.includes(",") ? customColor : `${customColor} 0deg, ${customColor}` : DEFAULT_RAINBOW;
2393
2735
  progressEl.style.background = `conic-gradient(from ${tlAngleNorm}deg, ${colorStops} ${deg}deg, transparent ${deg}deg)`;
@@ -2661,15 +3003,40 @@ function createDiagram(opts) {
2661
3003
  let suppressNextClick = false;
2662
3004
  let controlsPlayButton = null;
2663
3005
  let controlsRateButtons = [];
3006
+ let controlsSeekBar = null;
3007
+ let controlsFitButton = null;
3008
+ let fitViewActive = false;
2664
3009
  function applyViewportTransform() {
2665
3010
  viewportTransform.style.transform = `translate(${viewportPanX}px, ${viewportPanY}px) scale(${viewportScale})`;
2666
3011
  }
2667
3012
  function resetViewportTransform() {
3013
+ releaseFitView();
2668
3014
  viewportScale = 1;
2669
3015
  viewportPanX = 0;
2670
3016
  viewportPanY = 0;
2671
3017
  applyViewportTransform();
2672
3018
  }
3019
+ function releaseFitView() {
3020
+ if (!fitViewActive) return;
3021
+ fitViewActive = false;
3022
+ cameraLayer.style.removeProperty("transform");
3023
+ }
3024
+ function toggleFitView() {
3025
+ if (fitViewActive) {
3026
+ resetViewportTransform();
3027
+ syncControls();
3028
+ return;
3029
+ }
3030
+ const bounds = computeContentBounds();
3031
+ const scale = Math.min(plan.meta.width / bounds.width, plan.meta.height / bounds.height);
3032
+ viewportScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
3033
+ viewportPanX = -bounds.minX * viewportScale + (plan.meta.width - bounds.width * viewportScale) / 2;
3034
+ viewportPanY = -bounds.minY * viewportScale + (plan.meta.height - bounds.height * viewportScale) / 2;
3035
+ applyViewportTransform();
3036
+ fitViewActive = true;
3037
+ cameraLayer.style.setProperty("transform", "none", "important");
3038
+ syncControls();
3039
+ }
2673
3040
  function handleViewportWheel(event) {
2674
3041
  event.preventDefault();
2675
3042
  const rect = viewport.getBoundingClientRect();
@@ -2685,6 +3052,7 @@ function createDiagram(opts) {
2685
3052
  applyViewportTransform();
2686
3053
  }
2687
3054
  function handleViewportPointerDown(event) {
3055
+ if (!allowPan) return;
2688
3056
  if (event.button !== 0 || activePointerId !== null) return;
2689
3057
  activePointerId = event.pointerId;
2690
3058
  dragStartX = event.clientX;
@@ -2737,6 +3105,13 @@ function createDiagram(opts) {
2737
3105
  button.style.color = active ? "#ffffff" : "#475569";
2738
3106
  button.style.borderColor = active ? "#0f172a" : "rgba(148, 163, 184, 0.55)";
2739
3107
  }
3108
+ if (controlsSeekBar) controlsSeekBar.value = String(sceneMs / 1e3);
3109
+ if (controlsFitButton) {
3110
+ controlsFitButton.setAttribute("aria-pressed", fitViewActive ? "true" : "false");
3111
+ controlsFitButton.style.background = fitViewActive ? "#1e293b" : "rgba(248, 250, 252, 0.92)";
3112
+ controlsFitButton.style.color = fitViewActive ? "#ffffff" : "#475569";
3113
+ controlsFitButton.style.borderColor = fitViewActive ? "#0f172a" : "rgba(148, 163, 184, 0.55)";
3114
+ }
2740
3115
  }
2741
3116
  function emitPlayStateChange(playing) {
2742
3117
  onPlayStateChange?.(playing);
@@ -2811,8 +3186,23 @@ function createDiagram(opts) {
2811
3186
  const beat = plan.beats.find((b) => b.name === name);
2812
3187
  if (beat) diagram.seek(beat.start);
2813
3188
  },
3189
+ nextBeat() {
3190
+ const next = plan.beats.find((beat) => beat.start > sceneMs / 1e3 + BEAT_NAV_EPSILON_S);
3191
+ if (next) diagram.seek(next.start);
3192
+ },
3193
+ prevBeat() {
3194
+ const current = sceneMs / 1e3;
3195
+ let target = 0;
3196
+ for (const beat of plan.beats) {
3197
+ if (beat.start < current - BEAT_NAV_EPSILON_S) target = beat.start;
3198
+ }
3199
+ diagram.seek(target);
3200
+ },
2814
3201
  destroy() {
2815
3202
  diagram.pause();
3203
+ if (keyboardShortcuts && typeof window !== "undefined") {
3204
+ window.removeEventListener("keydown", handleKeyDown);
3205
+ }
2816
3206
  for (const anim of allAnims) anim.cancel();
2817
3207
  resizeObserver?.disconnect();
2818
3208
  if (progressEl?.parentNode === viewport) viewport.removeChild(progressEl);
@@ -2849,6 +3239,159 @@ function createDiagram(opts) {
2849
3239
  });
2850
3240
  return button;
2851
3241
  }
3242
+ function mountPlayControl(toolbar) {
3243
+ if (!playButton) return;
3244
+ controlsPlayButton = makeControlButton("Play", "Play diagram");
3245
+ controlsPlayButton.className = "markdy-control-play";
3246
+ controlsPlayButton.addEventListener("click", togglePlayback);
3247
+ toolbar.appendChild(controlsPlayButton);
3248
+ }
3249
+ function mountBeatNavControls(toolbar, position) {
3250
+ const wanted = position === "prev" ? prevBeatButton : nextBeatButton;
3251
+ if (!wanted || plan.beats.length < 2) return;
3252
+ const label = position === "prev" ? "Prev" : "Next";
3253
+ const button = makeControlButton(label, `${label === "Prev" ? "Previous" : "Next"} beat`);
3254
+ button.className = `markdy-control-${position}-beat`;
3255
+ button.addEventListener("click", () => position === "prev" ? diagram.prevBeat() : diagram.nextBeat());
3256
+ toolbar.appendChild(button);
3257
+ }
3258
+ function mountRestartControl(toolbar) {
3259
+ if (!restartButton) return;
3260
+ const button = makeControlButton("Restart", "Restart diagram");
3261
+ button.className = "markdy-control-restart";
3262
+ button.addEventListener("click", () => {
3263
+ diagram.seek(0);
3264
+ diagram.play();
3265
+ });
3266
+ toolbar.appendChild(button);
3267
+ }
3268
+ function mountSeekControl(toolbar) {
3269
+ if (!seekBar) return;
3270
+ controlsSeekBar = document.createElement("input");
3271
+ controlsSeekBar.className = "markdy-control-seek";
3272
+ controlsSeekBar.type = "range";
3273
+ controlsSeekBar.min = "0";
3274
+ controlsSeekBar.max = String(durationSeconds);
3275
+ controlsSeekBar.step = "0.01";
3276
+ controlsSeekBar.value = String(sceneMs / 1e3);
3277
+ controlsSeekBar.setAttribute("aria-label", "Seek diagram timeline");
3278
+ controlsSeekBar.addEventListener("input", () => diagram.seek(Number(controlsSeekBar?.value ?? 0)));
3279
+ toolbar.appendChild(controlsSeekBar);
3280
+ }
3281
+ function mountSpeedControls(toolbar) {
3282
+ if (!speedControls) return;
3283
+ controlsRateButtons = speedOptions.map((rate) => {
3284
+ const button = makeControlButton(`${rate}x`, `Set playback speed to ${rate}x`);
3285
+ button.className = "markdy-control-rate";
3286
+ button.dataset.rate = String(rate);
3287
+ button.setAttribute("aria-pressed", "false");
3288
+ button.addEventListener("click", () => diagram.setPlaybackRate(rate));
3289
+ toolbar.appendChild(button);
3290
+ return button;
3291
+ });
3292
+ }
3293
+ function flashControlLabel(button, message) {
3294
+ const original = button.textContent ?? "";
3295
+ button.textContent = message;
3296
+ setTimeout(() => {
3297
+ button.textContent = original;
3298
+ }, 1400);
3299
+ }
3300
+ function downloadFile(filename, contents, type) {
3301
+ const blob = new Blob([contents], { type });
3302
+ const url = URL.createObjectURL(blob);
3303
+ const link = document.createElement("a");
3304
+ link.href = url;
3305
+ link.download = filename;
3306
+ link.style.display = "none";
3307
+ document.body.appendChild(link);
3308
+ link.click();
3309
+ document.body.removeChild(link);
3310
+ URL.revokeObjectURL(url);
3311
+ }
3312
+ function mountSvgControl(toolbar) {
3313
+ if (!svgButton) return;
3314
+ const button = makeControlButton("SVG", "Export diagram as SVG");
3315
+ button.className = "markdy-control-svg";
3316
+ button.addEventListener("click", async () => {
3317
+ const resumeAt = sceneMs;
3318
+ const wasPlaying = isPlaying;
3319
+ try {
3320
+ diagram.pause();
3321
+ diagram.seek(durationSeconds);
3322
+ const { exportDiagramAsVectorSvg: exportDiagramAsVectorSvg2 } = await import("./svg-exporter-7FW6RIP6.js");
3323
+ const svg = exportDiagramAsVectorSvg2(container);
3324
+ const name = (plan.title || "markdy-diagram").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
3325
+ downloadFile(`${name || "markdy-diagram"}.svg`, svg, "image/svg+xml");
3326
+ } catch (error) {
3327
+ onWarning({ severity: "warning", message: `SVG export failed: ${String(error)}`, line: 0 });
3328
+ flashControlLabel(button, "Failed");
3329
+ } finally {
3330
+ diagram.seek(resumeAt / 1e3);
3331
+ if (wasPlaying) diagram.play();
3332
+ }
3333
+ });
3334
+ toolbar.appendChild(button);
3335
+ }
3336
+ function mountShareControl(toolbar) {
3337
+ if (!shareButton) return;
3338
+ const button = makeControlButton("Share", "Copy a share link for this diagram");
3339
+ button.className = "markdy-control-share";
3340
+ button.addEventListener("click", async () => {
3341
+ try {
3342
+ const hash = await compressMarkdyToUrlHash(code);
3343
+ const base = shareUrl ?? MARKDY_PLAYGROUND_URL;
3344
+ await navigator.clipboard.writeText(`${base}#code=${hash}`);
3345
+ flashControlLabel(button, "Copied");
3346
+ } catch (error) {
3347
+ onWarning({ severity: "warning", message: `Share link failed: ${String(error)}`, line: 0 });
3348
+ flashControlLabel(button, "Failed");
3349
+ }
3350
+ });
3351
+ toolbar.appendChild(button);
3352
+ }
3353
+ function mountFitControl(toolbar) {
3354
+ if (!fitViewButton) return;
3355
+ controlsFitButton = makeControlButton("Fit", "Fit all items in view and ignore camera zoom");
3356
+ controlsFitButton.className = "markdy-control-fit";
3357
+ controlsFitButton.setAttribute("aria-pressed", "false");
3358
+ controlsFitButton.addEventListener("click", toggleFitView);
3359
+ toolbar.appendChild(controlsFitButton);
3360
+ }
3361
+ function mountResetViewControl(toolbar) {
3362
+ if (!resetViewButton) return;
3363
+ const button = makeControlButton("Reset", "Reset diagram view");
3364
+ button.className = "markdy-control-reset-view";
3365
+ button.addEventListener("click", resetViewportTransform);
3366
+ toolbar.appendChild(button);
3367
+ }
3368
+ function handleKeyDown(event) {
3369
+ const target = event.target;
3370
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target?.isContentEditable) {
3371
+ return;
3372
+ }
3373
+ switch (event.key) {
3374
+ case "ArrowRight":
3375
+ case "PageDown":
3376
+ event.preventDefault();
3377
+ diagram.nextBeat();
3378
+ break;
3379
+ case "ArrowLeft":
3380
+ case "PageUp":
3381
+ event.preventDefault();
3382
+ diagram.prevBeat();
3383
+ break;
3384
+ case " ":
3385
+ event.preventDefault();
3386
+ togglePlayback();
3387
+ break;
3388
+ case "Home":
3389
+ event.preventDefault();
3390
+ diagram.seek(0);
3391
+ break;
3392
+ default:
3393
+ }
3394
+ }
2852
3395
  function mountControls() {
2853
3396
  const toolbar = document.createElement("div");
2854
3397
  toolbar.className = "markdy-controls";
@@ -2874,53 +3417,44 @@ function createDiagram(opts) {
2874
3417
  for (const eventName of ["click", "dblclick", "pointerdown", "pointermove", "pointerup", "wheel"]) {
2875
3418
  toolbar.addEventListener(eventName, (event) => event.stopPropagation());
2876
3419
  }
2877
- controlsPlayButton = makeControlButton("Play", "Play diagram");
2878
- controlsPlayButton.className = "markdy-control-play";
2879
- controlsPlayButton.addEventListener("click", togglePlayback);
2880
- toolbar.appendChild(controlsPlayButton);
2881
- const restartButton = makeControlButton("Restart", "Restart diagram");
2882
- restartButton.className = "markdy-control-restart";
2883
- restartButton.addEventListener("click", () => {
2884
- diagram.seek(0);
2885
- diagram.play();
2886
- });
2887
- toolbar.appendChild(restartButton);
2888
- controlsRateButtons = [0.5, 1, 2].map((rate) => {
2889
- const button = makeControlButton(`${rate}x`, `Set playback speed to ${rate}x`);
2890
- button.className = "markdy-control-rate";
2891
- button.dataset.rate = String(rate);
2892
- button.setAttribute("aria-pressed", "false");
2893
- button.addEventListener("click", () => diagram.setPlaybackRate(rate));
2894
- toolbar.appendChild(button);
2895
- return button;
2896
- });
2897
- if (interactiveViewport) {
2898
- const resetButton = makeControlButton("Reset", "Reset diagram view");
2899
- resetButton.className = "markdy-control-reset-view";
2900
- resetButton.addEventListener("click", resetViewportTransform);
2901
- toolbar.appendChild(resetButton);
2902
- }
3420
+ mountPlayControl(toolbar);
3421
+ mountBeatNavControls(toolbar, "prev");
3422
+ mountBeatNavControls(toolbar, "next");
3423
+ mountRestartControl(toolbar);
3424
+ mountSeekControl(toolbar);
3425
+ mountSpeedControls(toolbar);
3426
+ mountFitControl(toolbar);
3427
+ mountResetViewControl(toolbar);
3428
+ mountSvgControl(toolbar);
3429
+ mountShareControl(toolbar);
2903
3430
  ensureFooter().insertBefore(toolbar, badge ?? null);
2904
3431
  syncControls();
2905
3432
  }
2906
3433
  viewport.style.cursor = interactiveViewport ? "grab" : "pointer";
2907
3434
  if (interactiveViewport) {
2908
3435
  viewport.style.touchAction = "none";
2909
- viewport.addEventListener("wheel", handleViewportWheel, { passive: false });
2910
- viewport.addEventListener("pointerdown", handleViewportPointerDown);
2911
- viewport.addEventListener("pointermove", handleViewportPointerMove);
2912
- viewport.addEventListener("pointerup", handleViewportPointerEnd);
2913
- viewport.addEventListener("pointercancel", handleViewportPointerEnd);
2914
- viewport.addEventListener("dblclick", handleViewportDoubleClick);
2915
- }
2916
- if (controls) mountControls();
2917
- viewport.addEventListener("click", () => {
2918
- if (suppressNextClick) {
2919
- suppressNextClick = false;
2920
- return;
3436
+ if (allowZoom) viewport.addEventListener("wheel", handleViewportWheel, { passive: false });
3437
+ if (allowPan) {
3438
+ viewport.addEventListener("pointerdown", handleViewportPointerDown);
3439
+ viewport.addEventListener("pointermove", handleViewportPointerMove);
3440
+ viewport.addEventListener("pointerup", handleViewportPointerEnd);
3441
+ viewport.addEventListener("pointercancel", handleViewportPointerEnd);
2921
3442
  }
2922
- togglePlayback();
2923
- });
3443
+ if (doubleClickToReset) viewport.addEventListener("dblclick", handleViewportDoubleClick);
3444
+ }
3445
+ if (showControls) mountControls();
3446
+ if (clickToPlay) {
3447
+ viewport.addEventListener("click", () => {
3448
+ if (suppressNextClick) {
3449
+ suppressNextClick = false;
3450
+ return;
3451
+ }
3452
+ togglePlayback();
3453
+ });
3454
+ }
3455
+ if (keyboardShortcuts && typeof window !== "undefined") {
3456
+ window.addEventListener("keydown", handleKeyDown);
3457
+ }
2924
3458
  if (autoplay) diagram.play();
2925
3459
  return diagram;
2926
3460
  }
@@ -3116,100 +3650,6 @@ function encodeGifSequence(frames, options = {}) {
3116
3650
  return new Uint8Array(buffer);
3117
3651
  }
3118
3652
 
3119
- // src/export/svg-exporter.ts
3120
- function copyRenderedStyles(source, clone) {
3121
- if (typeof window === "undefined" || typeof window.getComputedStyle !== "function") return;
3122
- const sourceElements = [source, ...Array.from(source.querySelectorAll("*"))];
3123
- const cloneElements = [clone, ...Array.from(clone.querySelectorAll("*"))];
3124
- for (let index = 0; index < Math.min(sourceElements.length, cloneElements.length); index++) {
3125
- const computed = window.getComputedStyle(sourceElements[index]);
3126
- const target = cloneElements[index].style;
3127
- for (let propertyIndex = 0; propertyIndex < computed.length; propertyIndex++) {
3128
- const property = computed.item(propertyIndex);
3129
- target.setProperty(property, computed.getPropertyValue(property), computed.getPropertyPriority(property));
3130
- }
3131
- }
3132
- }
3133
- function normalizeExportViewport(scene) {
3134
- scene.querySelectorAll(".markdy-viewport-transform").forEach((viewportTransform) => {
3135
- viewportTransform.style.transform = "translate(0px, 0px) scale(1)";
3136
- viewportTransform.style.transformOrigin = "0 0";
3137
- viewportTransform.style.willChange = "auto";
3138
- });
3139
- }
3140
- function getDiagramSceneElement(containerEl) {
3141
- const sceneEl = containerEl.classList?.contains("markdy-scene-root") ? containerEl : containerEl.querySelector(".markdy-scene-root") || containerEl.querySelector("svg") || (containerEl.tagName?.toLowerCase() === "svg" ? containerEl : null);
3142
- if (!sceneEl) throw new Error("No Markdy scene element found in container");
3143
- return sceneEl;
3144
- }
3145
- function prepareHtmlSceneForExport(sceneEl, options = {}) {
3146
- const clonedScene = sceneEl.cloneNode(true);
3147
- copyRenderedStyles(sceneEl, clonedScene);
3148
- normalizeExportViewport(clonedScene);
3149
- const widthStr = clonedScene.style.width || String(sceneEl.clientWidth || 800);
3150
- const heightStr = clonedScene.style.height || String(sceneEl.clientHeight || 400);
3151
- let width = parseFloat(widthStr);
3152
- let height = parseFloat(heightStr);
3153
- if (isNaN(width)) width = 800;
3154
- if (isNaN(height)) height = 400;
3155
- const scale = options.scale || 1;
3156
- const scaledWidth = width * scale;
3157
- const scaledHeight = height * scale;
3158
- clonedScene.style.transform = `scale(${scale})`;
3159
- clonedScene.style.transformOrigin = "0 0";
3160
- clonedScene.style.position = "relative";
3161
- clonedScene.style.left = "0px";
3162
- clonedScene.style.top = "0px";
3163
- clonedScene.style.margin = "0";
3164
- clonedScene.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
3165
- if (options.transparentBackground) {
3166
- clonedScene.style.background = "transparent";
3167
- }
3168
- return { sceneEl, clonedScene, width, height, scaledWidth, scaledHeight };
3169
- }
3170
- function exportDiagramAsVectorSvg(containerEl, options = {}) {
3171
- const sceneEl = getDiagramSceneElement(containerEl);
3172
- if (sceneEl.tagName?.toLowerCase() === "svg") {
3173
- const clonedSvg = sceneEl.cloneNode(true);
3174
- if (!clonedSvg.getAttribute("xmlns")) {
3175
- clonedSvg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
3176
- }
3177
- const serializer2 = new XMLSerializer();
3178
- return `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
3179
- ${serializer2.serializeToString(clonedSvg)}`;
3180
- }
3181
- const { clonedScene, scaledWidth, scaledHeight } = prepareHtmlSceneForExport(sceneEl, options);
3182
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
3183
- svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
3184
- svg.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
3185
- svg.setAttribute("width", String(scaledWidth));
3186
- svg.setAttribute("height", String(scaledHeight));
3187
- svg.setAttribute("viewBox", `0 0 ${scaledWidth} ${scaledHeight}`);
3188
- if (options.includeThemeStyles !== false) {
3189
- const styleEl = document.createElementNS("http://www.w3.org/2000/svg", "style");
3190
- let combinedStyles = `
3191
- foreignObject { width: 100%; height: 100%; }
3192
- .markdy-node { transition: opacity 0.3s ease; }
3193
- `;
3194
- if (typeof document !== "undefined") {
3195
- const styles = document.querySelectorAll("style[id^='markdy-']");
3196
- for (let i = 0; i < styles.length; i++) {
3197
- combinedStyles += styles[i].textContent + "\n";
3198
- }
3199
- }
3200
- styleEl.textContent = combinedStyles;
3201
- svg.appendChild(styleEl);
3202
- }
3203
- const foreignObject = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
3204
- foreignObject.setAttribute("width", "100%");
3205
- foreignObject.setAttribute("height", "100%");
3206
- foreignObject.appendChild(clonedScene);
3207
- svg.appendChild(foreignObject);
3208
- const serializer = new XMLSerializer();
3209
- return `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
3210
- ` + serializer.serializeToString(svg);
3211
- }
3212
-
3213
3653
  // src/export/inline-resources.ts
3214
3654
  var TRANSPARENT_PIXEL_DATA_URI = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==";
3215
3655
  var RESOURCE_FETCH_TIMEOUT_MS = 3e3;