svelte-streamdown 2.4.5 → 2.5.1

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.
Files changed (46) hide show
  1. package/README.md +98 -4
  2. package/dist/Block.svelte +4 -4
  3. package/dist/Block.svelte.d.ts +0 -1
  4. package/dist/Elements/Alert.svelte +4 -1
  5. package/dist/Elements/Alert.svelte.d.ts +1 -0
  6. package/dist/Elements/Citation.svelte +260 -0
  7. package/dist/Elements/Citation.svelte.d.ts +7 -0
  8. package/dist/Elements/Code.svelte +13 -41
  9. package/dist/Elements/Code.svelte.d.ts +1 -0
  10. package/dist/Elements/Element.svelte +65 -33
  11. package/dist/Elements/FootnoteRef.svelte +41 -89
  12. package/dist/Elements/Image.svelte +5 -1
  13. package/dist/Elements/Image.svelte.d.ts +1 -0
  14. package/dist/Elements/Link.svelte +5 -1
  15. package/dist/Elements/Link.svelte.d.ts +1 -0
  16. package/dist/Elements/Math.svelte +10 -2
  17. package/dist/Elements/Math.svelte.d.ts +1 -0
  18. package/dist/Elements/Mermaid.svelte +87 -175
  19. package/dist/Elements/Mermaid.svelte.d.ts +1 -0
  20. package/dist/Elements/TableDownload.svelte +216 -0
  21. package/dist/Elements/TableDownload.svelte.d.ts +8 -0
  22. package/dist/Elements/icons.d.ts +9 -0
  23. package/dist/Elements/icons.js +175 -0
  24. package/dist/Elements/popover.svelte.d.ts +8 -0
  25. package/dist/Elements/popover.svelte.js +40 -0
  26. package/dist/Elements/stepperState.svelte.d.ts +35 -0
  27. package/dist/Elements/stepperState.svelte.js +98 -0
  28. package/dist/Streamdown.svelte +13 -4
  29. package/dist/Streamdown.svelte.d.ts +23 -2
  30. package/dist/context.svelte.d.ts +28 -11
  31. package/dist/context.svelte.js +14 -6
  32. package/dist/marked/index.d.ts +4 -2
  33. package/dist/marked/index.js +4 -2
  34. package/dist/marked/marked-align.d.ts +10 -0
  35. package/dist/marked/marked-align.js +36 -0
  36. package/dist/marked/marked-citations.d.ts +8 -0
  37. package/dist/marked/marked-citations.js +49 -0
  38. package/dist/marked/marked-table.js +36 -13
  39. package/dist/theme.d.ts +66 -21
  40. package/dist/theme.js +47 -17
  41. package/dist/utils/get.d.ts +1 -0
  42. package/dist/utils/get.js +25 -0
  43. package/dist/utils/panzoom.svelte.d.ts +1 -2
  44. package/dist/utils/panzoom.svelte.js +87 -43
  45. package/dist/utils/parse-incomplete-markdown.js +70 -1
  46. package/package.json +1 -1
@@ -0,0 +1,49 @@
1
+ export const markedCitations = {
2
+ name: 'citations',
3
+ level: 'inline',
4
+ start(src) {
5
+ return src.indexOf('[') === -1 ? -1 : 0;
6
+ },
7
+ tokenizer(src) {
8
+ // Match inline citations like [1], [ref], [1] [2], [ref] [ref2], etc.
9
+ // Requires non-empty bracket contents and spaces between adjacent citation brackets
10
+ const match = src.match(/^\[[^\]]+\](?:\s+\[[^\]]+\])*/);
11
+ if (match) {
12
+ // Early exit: if first closing bracket is immediately followed by '[', it's likely link-style syntax
13
+ const firstClosingBracketIndex = src.indexOf(']');
14
+ if (firstClosingBracketIndex !== -1 && src[firstClosingBracketIndex + 1] === '[') {
15
+ return undefined;
16
+ }
17
+ // If followed by parentheses, it's likely a markdown link or image, so don't treat as citation
18
+ const remainingSrc = src.slice(match[0].length);
19
+ if (remainingSrc.match(/^\s*\(/)) {
20
+ return undefined;
21
+ }
22
+ // Extract all citation keys (anything inside brackets)
23
+ const citations = match[0].match(/\[([^\]]+)\]/g);
24
+ if (citations) {
25
+ // Filter out task list syntax ([ ], [x], [X]) after trimming
26
+ const validCitations = citations.filter((citation) => {
27
+ const content = citation.slice(1, -1).trim();
28
+ return content !== '' && content !== 'x' && content !== 'X';
29
+ });
30
+ if (validCitations.length > 0) {
31
+ // Process each bracket content and split on spaces, commas, or semicolons
32
+ const keys = validCitations.flatMap((citation) => {
33
+ const content = citation.slice(1, -1).trim(); // Remove brackets and trim
34
+ // Split on spaces, commas, or semicolons and filter out empty strings
35
+ return content.split(/[\s,;]+/).filter((key) => key.length > 0);
36
+ });
37
+ // Deduplicate keys after trimming
38
+ const uniqueKeys = Array.from(new Set(keys.map((key) => key.trim()).filter((key) => key.length > 0)));
39
+ return {
40
+ type: 'inline-citations',
41
+ keys: uniqueKeys,
42
+ text: match[0],
43
+ raw: match[0]
44
+ };
45
+ }
46
+ }
47
+ }
48
+ }
49
+ };
@@ -35,7 +35,6 @@ function splitRow(src) {
35
35
  continue;
36
36
  }
37
37
  if (ch === '`') {
38
- // count backticks
39
38
  let run = 1;
40
39
  while (i + run < src.length && src[i + run] === '`')
41
40
  run++;
@@ -52,7 +51,19 @@ function splitRow(src) {
52
51
  continue;
53
52
  }
54
53
  if (ch === '|' && !inCode) {
55
- out.push(buf.trim());
54
+ // Count consecutive pipes for colspan
55
+ let consecutivePipes = 1;
56
+ while (i + consecutivePipes < src.length && src[i + consecutivePipes] === '|') {
57
+ consecutivePipes++;
58
+ }
59
+ if (consecutivePipes > 1) {
60
+ // Multiple pipes = colspan marker
61
+ out.push(buf.trim() + '\x00COLSPAN:' + consecutivePipes);
62
+ i += consecutivePipes - 1;
63
+ }
64
+ else {
65
+ out.push(buf.trim());
66
+ }
56
67
  buf = '';
57
68
  continue;
58
69
  }
@@ -83,29 +94,41 @@ const processSpans = (cells, count, prevRow = [], maxColspan = null) => {
83
94
  let cellIndex = 0;
84
95
  const mergedIndices = new Set();
85
96
  for (i = 0; i < cells.length; i++) {
86
- // Skip cells that were merged into previous colspans
87
97
  if (mergedIndices.has(i))
88
98
  continue;
89
99
  trimmedCell = cells[i];
90
- // Count consecutive empty cells for colspan
91
100
  let colspan = 1;
92
- if (!trimmedCell.trim()) {
93
- // Count how many consecutive empty cells we have
94
- let j = i + 1;
95
- while (j < cells.length && !cells[j].trim()) {
96
- colspan++;
97
- mergedIndices.add(j); // Mark as merged
98
- j++;
101
+ // Check for colspan marker from consecutive pipes
102
+ if (trimmedCell.includes('\x00COLSPAN:')) {
103
+ const parts = trimmedCell.split('\x00COLSPAN:');
104
+ trimmedCell = parts[0];
105
+ colspan = parseInt(parts[1], 10);
106
+ }
107
+ else if (!trimmedCell.trim()) {
108
+ // Fallback: merge empty run into previous cell (backward compatibility)
109
+ let run = 1, k = i + 1;
110
+ while (k < cells.length && !cells[k].trim()) {
111
+ run++;
112
+ mergedIndices.add(k++);
113
+ }
114
+ if (processedCells.length) {
115
+ const target = processedCells[processedCells.length - 1];
116
+ const allowed = maxColspan != null ? Math.min(run, Math.max(0, maxColspan - target.colspan)) : run;
117
+ target.colspan += allowed;
118
+ numCols += allowed;
119
+ continue;
120
+ }
121
+ else {
122
+ colspan = maxColspan != null ? Math.min(run, maxColspan) : run;
99
123
  }
100
124
  }
101
- // Apply maxColspan limit if specified
102
125
  if (maxColspan !== null && colspan > maxColspan)
103
126
  colspan = maxColspan;
104
127
  processedCells[cellIndex] = {
105
128
  rowspan: 1,
106
129
  colspan: colspan,
107
130
  text: trimmedCell.trim().replace(/\\\|/g, '|'),
108
- position: numCols // Store original column position for better tracking
131
+ position: numCols
109
132
  };
110
133
  numCols += processedCells[cellIndex].colspan;
111
134
  cellIndex++;
package/dist/theme.d.ts CHANGED
@@ -41,7 +41,6 @@ export declare const theme: {
41
41
  container: string;
42
42
  header: string;
43
43
  buttons: string;
44
- button: string;
45
44
  language: string;
46
45
  skeleton: string;
47
46
  pre: string;
@@ -53,7 +52,6 @@ export declare const theme: {
53
52
  image: {
54
53
  base: string;
55
54
  image: string;
56
- downloadButton: string;
57
55
  };
58
56
  blockquote: {
59
57
  base: string;
@@ -104,8 +102,6 @@ export declare const theme: {
104
102
  };
105
103
  mermaid: {
106
104
  base: string;
107
- downloadButton: string;
108
- button: string;
109
105
  icon: string;
110
106
  buttons: string;
111
107
  };
@@ -125,9 +121,6 @@ export declare const theme: {
125
121
  footnoteRef: {
126
122
  base: string;
127
123
  };
128
- footnotePopover: {
129
- base: string;
130
- };
131
124
  descriptionList: {
132
125
  base: string;
133
126
  };
@@ -137,6 +130,28 @@ export declare const theme: {
137
130
  descriptionDetail: {
138
131
  base: string;
139
132
  };
133
+ inlineCitation: {
134
+ preview: string;
135
+ carousel: {
136
+ header: string;
137
+ stepCounter: string;
138
+ buttons: string;
139
+ title: string;
140
+ url: string;
141
+ favicon: string;
142
+ };
143
+ list: {
144
+ base: string;
145
+ item: string;
146
+ title: string;
147
+ url: string;
148
+ favicon: string;
149
+ };
150
+ };
151
+ components: {
152
+ button: string;
153
+ popover: string;
154
+ };
140
155
  };
141
156
  export declare const shadcnTheme: {
142
157
  link: {
@@ -179,7 +194,6 @@ export declare const shadcnTheme: {
179
194
  container: string;
180
195
  header: string;
181
196
  buttons: string;
182
- button: string;
183
197
  language: string;
184
198
  skeleton: string;
185
199
  pre: string;
@@ -191,7 +205,6 @@ export declare const shadcnTheme: {
191
205
  image: {
192
206
  base: string;
193
207
  image: string;
194
- downloadButton: string;
195
208
  };
196
209
  blockquote: {
197
210
  base: string;
@@ -242,8 +255,6 @@ export declare const shadcnTheme: {
242
255
  };
243
256
  mermaid: {
244
257
  base: string;
245
- downloadButton: string;
246
- button: string;
247
258
  icon: string;
248
259
  buttons: string;
249
260
  };
@@ -263,9 +274,6 @@ export declare const shadcnTheme: {
263
274
  footnoteRef: {
264
275
  base: string;
265
276
  };
266
- footnotePopover: {
267
- base: string;
268
- };
269
277
  descriptionList: {
270
278
  base: string;
271
279
  };
@@ -275,6 +283,28 @@ export declare const shadcnTheme: {
275
283
  descriptionDetail: {
276
284
  base: string;
277
285
  };
286
+ inlineCitation: {
287
+ preview: string;
288
+ carousel: {
289
+ header: string;
290
+ stepCounter: string;
291
+ buttons: string;
292
+ title: string;
293
+ url: string;
294
+ favicon: string;
295
+ };
296
+ list: {
297
+ base: string;
298
+ item: string;
299
+ title: string;
300
+ url: string;
301
+ favicon: string;
302
+ };
303
+ };
304
+ components: {
305
+ button: string;
306
+ popover: string;
307
+ };
278
308
  };
279
309
  export type Theme = typeof theme;
280
310
  type DeepPartial<T> = {
@@ -322,7 +352,6 @@ export declare const mergeTheme: (customTheme?: DeepPartialTheme, baseTheme?: "t
322
352
  container: string;
323
353
  header: string;
324
354
  buttons: string;
325
- button: string;
326
355
  language: string;
327
356
  skeleton: string;
328
357
  pre: string;
@@ -334,7 +363,6 @@ export declare const mergeTheme: (customTheme?: DeepPartialTheme, baseTheme?: "t
334
363
  image: {
335
364
  base: string;
336
365
  image: string;
337
- downloadButton: string;
338
366
  };
339
367
  blockquote: {
340
368
  base: string;
@@ -385,8 +413,6 @@ export declare const mergeTheme: (customTheme?: DeepPartialTheme, baseTheme?: "t
385
413
  };
386
414
  mermaid: {
387
415
  base: string;
388
- downloadButton: string;
389
- button: string;
390
416
  icon: string;
391
417
  buttons: string;
392
418
  };
@@ -406,9 +432,6 @@ export declare const mergeTheme: (customTheme?: DeepPartialTheme, baseTheme?: "t
406
432
  footnoteRef: {
407
433
  base: string;
408
434
  };
409
- footnotePopover: {
410
- base: string;
411
- };
412
435
  descriptionList: {
413
436
  base: string;
414
437
  };
@@ -418,5 +441,27 @@ export declare const mergeTheme: (customTheme?: DeepPartialTheme, baseTheme?: "t
418
441
  descriptionDetail: {
419
442
  base: string;
420
443
  };
444
+ inlineCitation: {
445
+ preview: string;
446
+ carousel: {
447
+ header: string;
448
+ stepCounter: string;
449
+ buttons: string;
450
+ title: string;
451
+ url: string;
452
+ favicon: string;
453
+ };
454
+ list: {
455
+ base: string;
456
+ item: string;
457
+ title: string;
458
+ url: string;
459
+ favicon: string;
460
+ };
461
+ };
462
+ components: {
463
+ button: string;
464
+ popover: string;
465
+ };
421
466
  };
422
467
  export {};
package/dist/theme.js CHANGED
@@ -42,7 +42,6 @@ export const theme = {
42
42
  container: ' relative overflow-visible bg-gray-100 p-2 font-mono text-sm ',
43
43
  header: 'flex items-center justify-between bg-gray-100/80 p-2 text-gray-600 text-xs',
44
44
  buttons: 'flex items-center gap-2',
45
- button: 'cursor-pointer size-6 p-1 text-gray-600 transition-all hover:text-gray-900 rounded hover:bg-gray-100',
46
45
  language: 'ml-1 font-mono lowercase',
47
46
  skeleton: 'block rounded-md font-mono text-transparent bg-gray-200 scale-y-90 animate-pulse whitespace-nowrap',
48
47
  pre: 'overflow-x-auto font-mono p-0 bg-gray-100/40',
@@ -53,8 +52,7 @@ export const theme = {
53
52
  },
54
53
  image: {
55
54
  base: 'group relative my-4 mx-auto w-fit block',
56
- image: 'max-w-full rounded-lg',
57
- downloadButton: 'absolute right-2 bottom-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-gray-200 bg-white/90 shadow-sm backdrop-blur-sm transition-all duration-200 hover:bg-white opacity-0 group-hover:opacity-100'
55
+ image: 'max-w-full rounded-lg'
58
56
  },
59
57
  blockquote: {
60
58
  base: 'border-gray-600/30 text-gray-600 my-4 border-l-4 pl-4 italic'
@@ -105,8 +103,6 @@ export const theme = {
105
103
  },
106
104
  mermaid: {
107
105
  base: 'group relative my-4 h-auto rounded-xl border border-gray-200 bg-white overflow-hidden items-center min-h-[500px]',
108
- downloadButton: 'cursor-pointer p-1 text-gray-600 transition-all hover:text-gray-900',
109
- button: 'cursor-pointer size-6 p-1 text-gray-600 transition-all hover:text-gray-900 rounded hover:bg-gray-100',
110
106
  icon: 'size-5',
111
107
  buttons: 'absolute right-1 top-1 z-10 flex h-fit w-fit items-center gap-1'
112
108
  },
@@ -126,9 +122,6 @@ export const theme = {
126
122
  footnoteRef: {
127
123
  base: 'text-gray-600 px-1 py-0.5 rounded-md bg-gray-100/80'
128
124
  },
129
- footnotePopover: {
130
- base: 'fixed z-50 max-h-[30vh] max-w-3xl overflow-y-auto rounded-lg bg-background p-4 shadow'
131
- },
132
125
  descriptionList: {
133
126
  base: 'my-4 space-y-2'
134
127
  },
@@ -137,6 +130,28 @@ export const theme = {
137
130
  },
138
131
  descriptionDetail: {
139
132
  base: 'text-gray-700 ml-4 leading-relaxed'
133
+ },
134
+ inlineCitation: {
135
+ preview: 'text-sm text-muted-foreground bg-muted rounded-md px-2 py-0.5 cursor-pointer inline-flex border border-border hover:bg-muted/50 outline-none focus:ring-1 focus:ring-primary',
136
+ carousel: {
137
+ header: 'flex items-center justify-between',
138
+ stepCounter: 'h-fit text-xs font-semibold text-muted-foreground tabular-nums',
139
+ buttons: 'flex w-fit items-center justify-end gap-2',
140
+ title: 'mb-2 line-clamp-2 font-semibold',
141
+ url: 'flex items-center gap-2 text-sm text-muted-foreground',
142
+ favicon: 'h-4 w-4 rounded'
143
+ },
144
+ list: {
145
+ base: 'grid gap-2',
146
+ item: 'grid gap-1 hover:bg-muted rounded-md p-2',
147
+ title: 'line-clamp-1 font-semibold text-sm',
148
+ url: 'flex items-center gap-2 text-xs text-muted-foreground',
149
+ favicon: 'h-3 w-3 rounded'
150
+ }
151
+ },
152
+ components: {
153
+ button: 'disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer p-1 text-gray-600 transition-all hover:text-gray-900 rounded hover:bg-gray-100 w-6 h-6',
154
+ popover: 'min-w-[250px] max-w-md fixed z-[1000] max-h-md overflow-y-auto rounded-lg bg-white p-4 shadow'
140
155
  }
141
156
  };
142
157
  export const shadcnTheme = {
@@ -180,7 +195,6 @@ export const shadcnTheme = {
180
195
  container: 'relative overflow-visible bg-muted p-2 font-mono text-sm',
181
196
  header: 'flex items-center justify-between bg-muted/80 px-2 py-1 text-muted-foreground text-xs',
182
197
  buttons: 'flex items-center gap-2',
183
- button: 'cursor-pointer size-6 p-1 text-muted-foreground transition-all hover:text-foreground rounded hover:bg-muted',
184
198
  language: 'ml-1 font-mono lowercase',
185
199
  skeleton: 'block rounded-md font-mono text-transparent bg-border/80 scale-y-90 w-fit animate-pulse whitespace-nowrap',
186
200
  pre: 'overflow-x-auto font-mono p-0 bg-muted/40',
@@ -191,8 +205,7 @@ export const shadcnTheme = {
191
205
  },
192
206
  image: {
193
207
  base: 'group relative my-4 mx-auto w-fit block',
194
- image: 'max-w-full rounded-lg',
195
- downloadButton: 'absolute right-2 bottom-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-border bg-background/90 shadow-sm backdrop-blur-sm transition-all duration-200 hover:bg-background opacity-0 group-hover:opacity-100'
208
+ image: 'max-w-full rounded-lg'
196
209
  },
197
210
  blockquote: {
198
211
  base: 'border-muted-foreground/30 text-muted-foreground my-4 border-l-4 pl-4 italic'
@@ -243,8 +256,6 @@ export const shadcnTheme = {
243
256
  },
244
257
  mermaid: {
245
258
  base: 'group relative my-4 h-auto rounded-lg border border-border bg-card overflow-hidden items-center min-h-[500px]',
246
- downloadButton: 'cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground',
247
- button: 'cursor-pointer size-6 p-1 text-muted-foreground transition-all hover:text-foreground rounded hover:bg-muted',
248
259
  icon: 'size-5',
249
260
  buttons: 'absolute right-1 top-1 z-10 flex h-fit w-fit items-center gap-1'
250
261
  },
@@ -262,10 +273,7 @@ export const shadcnTheme = {
262
273
  base: 'text-muted-foreground'
263
274
  },
264
275
  footnoteRef: {
265
- base: 'text-muted-foreground px-1 text-sm inline-block rounded-full bg-muted/80 aspect-square border border-border'
266
- },
267
- footnotePopover: {
268
- base: 'fixed z-50 max-h-[30vh] shadow max-w-3xl overflow-y-auto rounded-lg bg-background p-4'
276
+ base: 'text-muted-foreground text-sm rounded-full bg-muted cursor-pointer border border-border hover:bg-muted/50 tabular-nums min-w-5 min-h-5 outline-none focus:ring-1 focus:ring-primary'
269
277
  },
270
278
  descriptionList: {
271
279
  base: 'my-4 space-y-2'
@@ -275,6 +283,28 @@ export const shadcnTheme = {
275
283
  },
276
284
  descriptionDetail: {
277
285
  base: 'text-muted-foreground ml-4 leading-relaxed'
286
+ },
287
+ inlineCitation: {
288
+ preview: 'text-sm text-muted-foreground bg-muted rounded-md px-2 py-0.5 cursor-pointer inline-flex border border-border hover:bg-muted/50 outline-none focus:ring-1 focus:ring-primary',
289
+ carousel: {
290
+ header: 'flex items-center justify-between',
291
+ stepCounter: 'h-fit text-xs font-semibold text-muted-foreground tabular-nums',
292
+ buttons: 'flex w-fit items-center justify-end gap-2',
293
+ title: 'mb-2 line-clamp-2 font-semibold',
294
+ url: 'flex items-center gap-2 text-sm text-muted-foreground',
295
+ favicon: 'h-4 w-4 rounded'
296
+ },
297
+ list: {
298
+ base: 'grid gap-2',
299
+ item: 'grid gap-1 hover:bg-muted rounded-md p-2',
300
+ title: 'line-clamp-1 font-semibold text-sm',
301
+ url: 'flex items-center gap-2 text-xs text-muted-foreground',
302
+ favicon: 'h-3 w-3 rounded'
303
+ }
304
+ },
305
+ components: {
306
+ button: 'disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground rounded hover:bg-border flex items-center justify-center w-6 h-6',
307
+ popover: 'min-w-[250px] max-w-md fixed z-[1000] max-h-md overflow-y-auto rounded-lg bg-popover border border-border p-2 shadow'
278
308
  }
279
309
  };
280
310
  export const mergeTheme = (customTheme, baseTheme) => {
@@ -0,0 +1 @@
1
+ export declare const get: <T>(obj: Record<string, any> | undefined, path: string) => T | null;
@@ -0,0 +1,25 @@
1
+ // A method that gets a value from an object using a path
2
+ // It should work with nested objects and arrays
3
+ export const get = (obj, path) => {
4
+ if (!obj)
5
+ return null;
6
+ const keys = path.split('.');
7
+ if (keys.length === 1) {
8
+ return obj[path];
9
+ }
10
+ let value = obj;
11
+ for (const key of keys) {
12
+ if (value == null)
13
+ return null;
14
+ if (Array.isArray(value)) {
15
+ const index = Number(key);
16
+ if (isNaN(index) || index < 0 || index >= value.length)
17
+ return null;
18
+ value = value[index];
19
+ }
20
+ else {
21
+ value = value[key];
22
+ }
23
+ }
24
+ return value;
25
+ };
@@ -23,8 +23,7 @@ export declare const usePanzoom: (opts?: PanzoomOptions) => {
23
23
  zoomOut: (factor?: number) => void;
24
24
  moveBy: (dx: number, dy: number) => void;
25
25
  setTransform: (nx: number, ny: number, ns: number) => void;
26
- expand: () => void;
27
- collapse: () => void;
26
+ expand: (expand: boolean) => void;
28
27
  toggleExpand: () => Promise<void>;
29
28
  readonly transform: {
30
29
  readonly x: number;
@@ -32,7 +32,7 @@ export const usePanzoom = (opts = {}) => {
32
32
  if (e.key === 'Escape' || e.keyCode === 27) {
33
33
  if (isExpanded && !animating) {
34
34
  // fire and forget
35
- void collapse();
35
+ void expand(false);
36
36
  }
37
37
  }
38
38
  }
@@ -198,6 +198,11 @@ export const usePanzoom = (opts = {}) => {
198
198
  dragOffMove = dragOffUp = null;
199
199
  }
200
200
  function onTouchStart(e) {
201
+ const hasButton = e
202
+ .composedPath()
203
+ .some((el) => el.tagName?.toLowerCase?.() === 'button');
204
+ if (hasButton)
205
+ return;
201
206
  if (!node)
202
207
  return;
203
208
  if (animating) {
@@ -345,59 +350,99 @@ export const usePanzoom = (opts = {}) => {
345
350
  };
346
351
  });
347
352
  }
348
- const collapse = () => {
353
+ const expand = (expand) => {
349
354
  if (!eventTarget)
350
355
  return;
351
- // Add view transition name for CSS targeting
352
- eventTarget.style.viewTransitionName = 'panzoom-element';
353
- isExpanded = false;
354
- const run = () => {
355
- if (!eventTarget)
356
- return;
357
- eventTarget.dataset.expanded = 'false';
356
+ // Capture first state
357
+ const first = eventTarget.getBoundingClientRect();
358
+ if (expand) {
359
+ // Expanding: immediately apply expanded state
360
+ // We should add margin to the parent element to account for the expanded height
361
+ if (eventTarget.parentElement) {
362
+ const styleAttributes = ['margin-block', 'height'];
363
+ styleAttributes.forEach((attribute) => {
364
+ if (eventTarget?.parentElement) {
365
+ eventTarget.parentElement.style.setProperty(attribute, getComputedStyle(eventTarget).getPropertyValue(attribute));
366
+ }
367
+ });
368
+ }
369
+ eventTarget.dataset.expanded = 'true';
370
+ isExpanded = true;
358
371
  zoomToFit();
359
- };
360
- if (typeof document.startViewTransition === 'function') {
361
- document.startViewTransition(run).finished.finally(() => {
362
- if (eventTarget)
363
- eventTarget.style.viewTransitionName = '';
372
+ // Force layout to get the final position
373
+ const last = eventTarget.getBoundingClientRect();
374
+ // Calculate the inverse transform
375
+ const deltaX = first.left - last.left;
376
+ const deltaY = first.top - last.top;
377
+ const deltaW = first.width / last.width;
378
+ const deltaH = first.height / last.height;
379
+ // Animate from original position to expanded
380
+ const animation = eventTarget.animate([
381
+ {
382
+ transformOrigin: '0 0',
383
+ transform: `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})`
384
+ },
385
+ {
386
+ transformOrigin: '0 0',
387
+ transform: 'translate(0px, 0px) scale(1, 1)'
388
+ }
389
+ ], {
390
+ duration: 350,
391
+ easing: 'cubic-bezier(0.4, 0.0, 0.2, 1)',
392
+ fill: 'both'
393
+ });
394
+ animation.finished.then(() => {
395
+ animation.cancel();
364
396
  });
365
397
  }
366
398
  else {
367
- run();
368
- if (eventTarget)
369
- eventTarget.style.viewTransitionName = '';
370
- }
371
- };
372
- const expand = () => {
373
- if (!eventTarget)
374
- return;
375
- // Add view transition name for CSS targeting
376
- eventTarget.style.viewTransitionName = 'panzoom-element';
377
- isExpanded = true;
378
- const run = () => {
379
- if (!eventTarget)
380
- return;
399
+ // Collapsing: keep expanded state during animation, then remove
400
+ const last = {
401
+ left: first.left,
402
+ top: first.top,
403
+ width: first.width,
404
+ height: first.height
405
+ };
406
+ // Calculate where it will be after collapsing
407
+ eventTarget.dataset.expanded = 'false';
408
+ const final = eventTarget.getBoundingClientRect();
409
+ // Restore expanded state for animation
381
410
  eventTarget.dataset.expanded = 'true';
382
- zoomToFit();
383
- };
384
- if (typeof document.startViewTransition === 'function') {
385
- document.startViewTransition(run).finished.finally(() => {
386
- if (eventTarget)
387
- eventTarget.style.viewTransitionName = '';
411
+ const deltaX = final.left - last.left;
412
+ const deltaY = final.top - last.top;
413
+ const deltaW = final.width / last.width;
414
+ const deltaH = final.height / last.height;
415
+ // Animate from expanded to collapsed
416
+ const animation = eventTarget.animate([
417
+ {
418
+ transformOrigin: '0 0',
419
+ transform: 'translate(0px, 0px) scale(1, 1)'
420
+ },
421
+ {
422
+ transformOrigin: '0 0',
423
+ transform: `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})`
424
+ }
425
+ ], {
426
+ duration: 350,
427
+ easing: 'cubic-bezier(0.4, 0.0, 0.2, 1)',
428
+ fill: 'both'
429
+ });
430
+ animation.finished.then(() => {
431
+ animation.cancel();
432
+ // Only remove expanded state after animation completes
433
+ if (!eventTarget)
434
+ return;
435
+ eventTarget.dataset.expanded = 'false';
436
+ eventTarget.parentElement.style.height = 'fit-content';
437
+ isExpanded = false;
438
+ zoomToFit();
388
439
  });
389
- }
390
- else {
391
- run();
392
- if (eventTarget)
393
- eventTarget.style.viewTransitionName = '';
394
440
  }
395
441
  };
396
442
  async function toggleExpand() {
397
- zoomToFit();
398
443
  if (isExpanded)
399
- return collapse();
400
- return expand();
444
+ return expand(false);
445
+ return expand(true);
401
446
  }
402
447
  function zoomToFit(padding = 0.05) {
403
448
  if (!node)
@@ -467,7 +512,6 @@ export const usePanzoom = (opts = {}) => {
467
512
  moveBy,
468
513
  setTransform,
469
514
  expand,
470
- collapse,
471
515
  toggleExpand,
472
516
  get transform() {
473
517
  return { x, y, scale };