svelte-streamdown 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,483 @@
1
+ import { onDestroy, untrack } from 'svelte';
2
+ export const usePanzoom = (opts = {}) => {
3
+ // state
4
+ let x = opts.initialX ?? 0;
5
+ let y = opts.initialY ?? 0;
6
+ let scale = opts.initialScale ?? 1;
7
+ // options
8
+ const minZoom = opts.minZoom ?? 0.1;
9
+ const maxZoom = opts.maxZoom ?? Number.POSITIVE_INFINITY;
10
+ const zoomSpeed = opts.zoomSpeed ?? 1;
11
+ const doubleClickScale = opts.doubleClickScale ?? 1.75;
12
+ let node = null; // element we transform
13
+ let eventTarget = null; // element we listen on (parent container if available)
14
+ let createdWrapper = null; // wrapper we created if no parent existed
15
+ const listeners = new Set();
16
+ // drag state
17
+ let dragging = false;
18
+ let lastClientX = 0;
19
+ let lastClientY = 0;
20
+ let dragOffMove = null;
21
+ let dragOffUp = null;
22
+ let previousStyle = null;
23
+ // touch state
24
+ let touchMode = 'none';
25
+ let pinchDistance = 0;
26
+ let pinchCenter = null;
27
+ // expand/collapse state
28
+ let isExpanded = $state(false);
29
+ let animating = false;
30
+ let placeholderEl = null;
31
+ function onKeyDown(e) {
32
+ if (e.key === 'Escape' || e.keyCode === 27) {
33
+ if (isExpanded && !animating) {
34
+ // fire and forget
35
+ void collapse();
36
+ }
37
+ }
38
+ }
39
+ const destroy = () => {
40
+ listeners.forEach((off) => off());
41
+ listeners.clear();
42
+ if (placeholderEl) {
43
+ placeholderEl.remove();
44
+ placeholderEl = null;
45
+ }
46
+ if (createdWrapper) {
47
+ // Move node back out of wrapper before removing wrapper
48
+ if (node && createdWrapper.parentElement) {
49
+ createdWrapper.parentElement.insertBefore(node, createdWrapper);
50
+ }
51
+ createdWrapper.remove();
52
+ createdWrapper = null;
53
+ }
54
+ if (dragOffMove)
55
+ dragOffMove();
56
+ if (dragOffUp)
57
+ dragOffUp();
58
+ };
59
+ onDestroy(() => {
60
+ destroy();
61
+ });
62
+ function normalize() {
63
+ // clamp and round to reduce subpixel jitter
64
+ scale = clampScale(scale);
65
+ const r = (v) => Math.round(v * 1000) / 1000;
66
+ x = r(x);
67
+ y = r(y);
68
+ scale = r(scale);
69
+ }
70
+ function apply() {
71
+ if (!node)
72
+ return;
73
+ normalize();
74
+ // Use CSS transform regardless of DOM/SVG root; modern browsers support this.
75
+ const el = node;
76
+ el.style.transformOrigin = '0 0';
77
+ el.style.willChange = 'transform';
78
+ // transform order is right-to-left. We want screen = translate + scale * local
79
+ // so use translate() first, then scale().
80
+ el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
81
+ }
82
+ function clampScale(s) {
83
+ if (s < minZoom)
84
+ return minZoom;
85
+ if (s > maxZoom)
86
+ return maxZoom;
87
+ return s;
88
+ }
89
+ function zoomAt(clientX, clientY, factor) {
90
+ if (!node)
91
+ return;
92
+ if (!Number.isFinite(factor) || factor === 1)
93
+ return;
94
+ const nextScale = clampScale(scale * factor);
95
+ const ratio = nextScale / scale;
96
+ if (ratio === 1)
97
+ return;
98
+ const owner = (eventTarget ?? node.parentElement ?? node);
99
+ const ownerRect = owner.getBoundingClientRect();
100
+ const ox = clientX - ownerRect.left;
101
+ const oy = clientY - ownerRect.top;
102
+ // Keep (ox, oy) stationary in owner's coordinate space
103
+ x = ratio * x + (1 - ratio) * ox;
104
+ y = ratio * y + (1 - ratio) * oy;
105
+ scale = nextScale;
106
+ apply();
107
+ }
108
+ function kineticWheel(deltaY) {
109
+ // deltaY > 0 => zoom out; use smooth exponential scaling
110
+ const sign = Math.sign(deltaY);
111
+ const step = Math.min(0.25, Math.abs((zoomSpeed * deltaY) / 128));
112
+ return 1 - sign * step;
113
+ }
114
+ function onWheel(e) {
115
+ if (!opts.activateMouseWheel)
116
+ return;
117
+ if (!node)
118
+ return;
119
+ if (animating) {
120
+ e.preventDefault();
121
+ e.stopPropagation();
122
+ return;
123
+ }
124
+ // Always prevent default to stop page scroll
125
+ e.preventDefault();
126
+ e.stopPropagation();
127
+ const factor = kineticWheel(e.deltaY * (e.deltaMode ? 100 : 1));
128
+ zoomAt(e.clientX, e.clientY, factor);
129
+ // re-apply using latest rect
130
+ apply();
131
+ }
132
+ function onDblClick(e) {
133
+ // Ignore dblclicks that originate outside of the pan/zoom content (the node)
134
+ const t = e.target;
135
+ if (node && t && !(t === node || node.contains(t))) {
136
+ return; // likely UI control inside container; don't zoom
137
+ }
138
+ // Ignore dblclicks on interactive elements or those explicitly marked to ignore
139
+ const isInteractive = (el) => {
140
+ const tag = el.tagName.toLowerCase();
141
+ return (el.closest('[data-panzoom-ignore]') !== null ||
142
+ ['button', 'a', 'input', 'textarea', 'select', 'label', 'summary', 'details'].includes(tag) ||
143
+ el.isContentEditable);
144
+ };
145
+ if (t && isInteractive(t))
146
+ return;
147
+ e.preventDefault();
148
+ // Anchor double-click zoom at parent center to avoid shifts on rapid clicks
149
+ const baseEl = (eventTarget ?? node?.parentElement ?? node);
150
+ const base = baseEl.getBoundingClientRect();
151
+ const cx = base.left + base.width / 2;
152
+ const cy = base.top + base.height / 2;
153
+ zoomAt(cx, cy, doubleClickScale);
154
+ }
155
+ function startDrag(e) {
156
+ if (animating) {
157
+ e.preventDefault();
158
+ return;
159
+ }
160
+ if (e.button !== 0)
161
+ return; // left only
162
+ dragging = true;
163
+ lastClientX = e.clientX;
164
+ lastClientY = e.clientY;
165
+ e.preventDefault();
166
+ if (node)
167
+ node.style.cursor = 'grabbing';
168
+ dragOffMove = () => window.removeEventListener('mousemove', onDragMove);
169
+ dragOffUp = () => window.removeEventListener('mouseup', endDrag);
170
+ window.addEventListener('mousemove', onDragMove, { passive: false });
171
+ window.addEventListener('mouseup', endDrag, { passive: true });
172
+ listeners.add(dragOffMove);
173
+ listeners.add(dragOffUp);
174
+ }
175
+ function onDragMove(e) {
176
+ if (!dragging)
177
+ return;
178
+ const dx = e.clientX - lastClientX;
179
+ const dy = e.clientY - lastClientY;
180
+ lastClientX = e.clientX;
181
+ lastClientY = e.clientY;
182
+ x += dx;
183
+ y += dy;
184
+ apply();
185
+ }
186
+ function endDrag() {
187
+ dragging = false;
188
+ if (node)
189
+ node.style.cursor = 'grab';
190
+ if (dragOffMove) {
191
+ dragOffMove();
192
+ listeners.delete(dragOffMove);
193
+ }
194
+ if (dragOffUp) {
195
+ dragOffUp();
196
+ listeners.delete(dragOffUp);
197
+ }
198
+ dragOffMove = dragOffUp = null;
199
+ }
200
+ function onTouchStart(e) {
201
+ if (!node)
202
+ return;
203
+ if (animating) {
204
+ e.preventDefault();
205
+ return;
206
+ }
207
+ if (e.touches.length === 1) {
208
+ touchMode = 'pan';
209
+ lastClientX = e.touches[0].clientX;
210
+ lastClientY = e.touches[0].clientY;
211
+ }
212
+ else if (e.touches.length >= 2) {
213
+ touchMode = 'pinch';
214
+ const [t1, t2] = [e.touches[0], e.touches[1]];
215
+ pinchDistance = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
216
+ pinchCenter = {
217
+ x: (t1.clientX + t2.clientX) / 2,
218
+ y: (t1.clientY + t2.clientY) / 2
219
+ };
220
+ }
221
+ // prevent default scrolling/zooming
222
+ e.preventDefault();
223
+ const offMove = () => window.removeEventListener('touchmove', onTouchMove);
224
+ const offEnd = () => window.removeEventListener('touchend', onTouchEnd);
225
+ const offCancel = () => window.removeEventListener('touchcancel', onTouchEnd);
226
+ window.addEventListener('touchmove', onTouchMove, { passive: false });
227
+ window.addEventListener('touchend', onTouchEnd, { passive: true });
228
+ window.addEventListener('touchcancel', onTouchEnd, { passive: true });
229
+ listeners.add(offMove);
230
+ listeners.add(offEnd);
231
+ listeners.add(offCancel);
232
+ }
233
+ function onTouchMove(e) {
234
+ if (!node)
235
+ return;
236
+ if (touchMode === 'pan' && e.touches.length === 1) {
237
+ const t = e.touches[0];
238
+ const dx = t.clientX - lastClientX;
239
+ const dy = t.clientY - lastClientY;
240
+ lastClientX = t.clientX;
241
+ lastClientY = t.clientY;
242
+ x += dx;
243
+ y += dy;
244
+ apply();
245
+ e.preventDefault();
246
+ }
247
+ else if (touchMode === 'pinch' && e.touches.length >= 2) {
248
+ const [t1, t2] = [e.touches[0], e.touches[1]];
249
+ const dist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
250
+ const factor = dist / (pinchDistance || dist);
251
+ const center = {
252
+ x: (t1.clientX + t2.clientX) / 2,
253
+ y: (t1.clientY + t2.clientY) / 2
254
+ };
255
+ zoomAt(center.x, center.y, factor);
256
+ pinchDistance = dist;
257
+ pinchCenter = center;
258
+ e.preventDefault();
259
+ }
260
+ }
261
+ function onTouchEnd() {
262
+ if (!node)
263
+ return;
264
+ if (touchMode === 'pinch') {
265
+ // reset pinch state, keep resulting transform
266
+ touchMode = 'none';
267
+ pinchDistance = 0;
268
+ pinchCenter = null;
269
+ }
270
+ else if (touchMode === 'pan') {
271
+ touchMode = 'none';
272
+ }
273
+ }
274
+ function internalAttach(target) {
275
+ return untrack(() => {
276
+ node = target;
277
+ // Ensure eventTarget and node are different elements to avoid FLIP conflicts
278
+ const isSVG = typeof SVGSVGElement !== 'undefined' && target instanceof SVGSVGElement;
279
+ if (isSVG) {
280
+ // For SVG, prefer parent element
281
+ eventTarget = target.parentElement;
282
+ }
283
+ else {
284
+ // For non-SVG, try to use parent element when available
285
+ const parent = target.parentElement;
286
+ if (parent && parent instanceof HTMLElement) {
287
+ eventTarget = parent;
288
+ }
289
+ else {
290
+ // No suitable parent - create wrapper to ensure separation
291
+ const wrapper = document.createElement('div');
292
+ wrapper.style.cssText = `
293
+ display: contents;
294
+ contain: layout style;
295
+ `
296
+ .replace(/\s+/g, ' ')
297
+ .trim();
298
+ // Insert wrapper and move target into it
299
+ if (target.parentElement) {
300
+ target.parentElement.insertBefore(wrapper, target);
301
+ }
302
+ wrapper.appendChild(target);
303
+ eventTarget = wrapper;
304
+ createdWrapper = wrapper;
305
+ }
306
+ }
307
+ // Final fallback if no eventTarget was established
308
+ if (!eventTarget) {
309
+ eventTarget = target;
310
+ }
311
+ apply();
312
+ // helper to add and track listeners with options
313
+ const add = (type, handler, options) => {
314
+ const n = (eventTarget ?? node);
315
+ n.addEventListener(type, handler, options);
316
+ const off = () => n.removeEventListener(type, handler, options);
317
+ listeners.add(off);
318
+ return off;
319
+ };
320
+ const addWindow = (type, handler, options) => {
321
+ window.addEventListener(type, handler, options);
322
+ const off = () => window.removeEventListener(type, handler, options);
323
+ listeners.add(off);
324
+ return off;
325
+ };
326
+ // core events with passive: false where needed
327
+ add('mousedown', startDrag, { passive: false });
328
+ add('wheel', onWheel, { passive: false, capture: true });
329
+ add('dblclick', onDblClick, { passive: false });
330
+ add('touchstart', onTouchStart, { passive: false });
331
+ addWindow('keydown', onKeyDown, { passive: true });
332
+ // prevent text selection/scrolling while interacting
333
+ const t = (eventTarget ?? node);
334
+ t.style.userSelect = 'none';
335
+ t.style.touchAction = 'none';
336
+ t.style.cursor = 'grab';
337
+ t.style.overscrollBehavior = 'contain';
338
+ // For SVG root, use fill-box so translation math stays in CSS px space
339
+ const n = node;
340
+ if (typeof SVGSVGElement !== 'undefined' && target instanceof SVGSVGElement) {
341
+ n.style.transformBox = 'fill-box';
342
+ }
343
+ return () => {
344
+ destroy();
345
+ };
346
+ });
347
+ }
348
+ const collapse = () => {
349
+ if (!eventTarget)
350
+ return;
351
+ // Add view transition name for CSS targeting
352
+ eventTarget.style.viewTransitionName = 'panzoom-element';
353
+ isExpanded = false;
354
+ const api = document.startViewTransition;
355
+ const run = () => {
356
+ if (!eventTarget)
357
+ return;
358
+ eventTarget.dataset.expanded = 'false';
359
+ zoomToFit();
360
+ };
361
+ if (typeof api === 'function') {
362
+ api(run).finished.finally(() => {
363
+ if (eventTarget)
364
+ eventTarget.style.viewTransitionName = '';
365
+ });
366
+ }
367
+ else {
368
+ run();
369
+ if (eventTarget)
370
+ eventTarget.style.viewTransitionName = '';
371
+ }
372
+ };
373
+ const expand = () => {
374
+ if (!eventTarget)
375
+ return;
376
+ // Add view transition name for CSS targeting
377
+ eventTarget.style.viewTransitionName = 'panzoom-element';
378
+ isExpanded = true;
379
+ const api = document.startViewTransition;
380
+ const run = () => {
381
+ if (!eventTarget)
382
+ return;
383
+ eventTarget.dataset.expanded = 'true';
384
+ zoomToFit();
385
+ };
386
+ if (typeof api === 'function') {
387
+ api(run).finished.finally(() => {
388
+ if (eventTarget)
389
+ eventTarget.style.viewTransitionName = '';
390
+ });
391
+ }
392
+ else {
393
+ run();
394
+ if (eventTarget)
395
+ eventTarget.style.viewTransitionName = '';
396
+ }
397
+ };
398
+ async function toggleExpand() {
399
+ zoomToFit();
400
+ if (isExpanded)
401
+ return collapse();
402
+ return expand();
403
+ }
404
+ function zoomToFit(padding = 0.05) {
405
+ if (!node)
406
+ return;
407
+ const parent = node.parentElement;
408
+ if (!parent)
409
+ return;
410
+ const parentRect = parent.getBoundingClientRect();
411
+ const rect = node.getBoundingClientRect();
412
+ // infer unscaled natural size by dividing by current scale
413
+ const naturalWidth = rect.width / scale || 0;
414
+ const naturalHeight = rect.height / scale || 0;
415
+ const targetWidth = parentRect.width * (1 - 2 * padding);
416
+ const targetHeight = parentRect.height * (1 - 2 * padding);
417
+ if (naturalWidth <= 0 || naturalHeight <= 0 || targetWidth <= 0 || targetHeight <= 0)
418
+ return;
419
+ const s = clampScale(Math.min(targetWidth / naturalWidth, targetHeight / naturalHeight));
420
+ // Temporarily apply scale with zero translate to measure new size
421
+ scale = s;
422
+ x = 0;
423
+ y = 0;
424
+ apply();
425
+ const newRect = node.getBoundingClientRect();
426
+ // compute centered position inside parent
427
+ const targetLeft = parentRect.left + (parentRect.width - newRect.width) / 2;
428
+ const targetTop = parentRect.top + (parentRect.height - newRect.height) / 2;
429
+ x = targetLeft - newRect.left;
430
+ y = targetTop - newRect.top;
431
+ apply();
432
+ }
433
+ function zoomBy(factor) {
434
+ console.log('zoomBy', factor);
435
+ if (!node)
436
+ return;
437
+ // Use zoomAt with the center of the container for consistent behavior
438
+ const owner = (eventTarget ?? node.parentElement ?? node);
439
+ const ownerRect = owner.getBoundingClientRect();
440
+ const cx = ownerRect.left + ownerRect.width / 2;
441
+ const cy = ownerRect.top + ownerRect.height / 2;
442
+ zoomAt(cx, cy, factor);
443
+ }
444
+ function moveBy(dx, dy) {
445
+ x += dx;
446
+ y += dy;
447
+ apply();
448
+ }
449
+ function zoomIn(factor = 1.25) {
450
+ zoomBy(factor);
451
+ }
452
+ function zoomOut(factor = 1.25) {
453
+ console.log('zoomOut', factor);
454
+ // zoom out by inverse multiplier
455
+ if (factor <= 0)
456
+ return;
457
+ zoomBy(1 / factor);
458
+ }
459
+ function setTransform(nx, ny, ns) {
460
+ scale = clampScale(ns);
461
+ x = nx;
462
+ y = ny;
463
+ apply();
464
+ }
465
+ return {
466
+ attach: internalAttach,
467
+ zoomToFit,
468
+ zoomBy,
469
+ zoomIn,
470
+ zoomOut,
471
+ moveBy,
472
+ setTransform,
473
+ expand,
474
+ collapse,
475
+ toggleExpand,
476
+ get transform() {
477
+ return { x, y, scale };
478
+ },
479
+ get expanded() {
480
+ return isExpanded;
481
+ }
482
+ };
483
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelte-streamdown",
3
- "version": "0.0.1",
3
+ "version": "1.0.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",
@@ -49,6 +49,7 @@
49
49
  "publint": "^0.3.2",
50
50
  "svelte": "^5.0.0",
51
51
  "svelte-check": "^4.0.0",
52
+ "svelte-themes": "^2.0.8",
52
53
  "tailwindcss": "^4.0.0",
53
54
  "typescript": "^5.0.0",
54
55
  "vite": "^7.0.4",
@@ -57,6 +58,7 @@
57
58
  "vitest-browser-svelte": "^0.1.0",
58
59
  "wrangler": "^4.34.0"
59
60
  },
61
+ "license": "MIT",
60
62
  "keywords": [
61
63
  "svelte"
62
64
  ],
@@ -76,7 +78,6 @@
76
78
  "mdast": "^3.0.0",
77
79
  "mdast-squeeze-paragraphs": "^6.0.0",
78
80
  "mermaid": "^11.11.0",
79
- "react-markdown": "^10.1.0",
80
81
  "rehype-github-alerts": "^4.1.1",
81
82
  "rehype-katex": "^7.0.1",
82
83
  "remark-gfm": "^4.0.1",