@reshape-biotech/design-system 1.2.5 → 1.2.7

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 (175) hide show
  1. package/README.md +3 -1
  2. package/dist/app.css +97 -97
  3. package/dist/components/activity/Activity.stories.svelte +104 -104
  4. package/dist/components/activity/Activity.svelte +112 -112
  5. package/dist/components/avatar/Avatar.stories.svelte +23 -23
  6. package/dist/components/avatar/Avatar.svelte +54 -54
  7. package/dist/components/banner/Banner.stories.svelte +105 -105
  8. package/dist/components/banner/Banner.svelte +42 -42
  9. package/dist/components/button/Button.stories.svelte +61 -61
  10. package/dist/components/button/Button.svelte +95 -95
  11. package/dist/components/card/Card.stories.svelte +112 -112
  12. package/dist/components/card/Card.svelte +18 -18
  13. package/dist/components/checkbox/Checkbox.stories.svelte +8 -8
  14. package/dist/components/checkbox/Checkbox.svelte +17 -17
  15. package/dist/components/collapsible/Collapsible.stories.svelte +26 -26
  16. package/dist/components/collapsible/components/collapsible-content.svelte +20 -20
  17. package/dist/components/collapsible/components/collapsible-trigger.svelte +12 -12
  18. package/dist/components/combobox/Combobox.stories.svelte +412 -412
  19. package/dist/components/combobox/components/combobox-add.svelte +8 -8
  20. package/dist/components/combobox/components/combobox-content.svelte +39 -39
  21. package/dist/components/combobox/components/combobox-indicator.svelte +1 -1
  22. package/dist/components/datepicker/DatePicker.stories.svelte +196 -196
  23. package/dist/components/datepicker/DatePicker.svelte +173 -173
  24. package/dist/components/divider/Divider.stories.svelte +7 -7
  25. package/dist/components/divider/Divider.svelte +9 -9
  26. package/dist/components/drawer/Drawer.stories.svelte +51 -51
  27. package/dist/components/drawer/Drawer.svelte +33 -33
  28. package/dist/components/drawer/DrawerLabel.svelte +10 -10
  29. package/dist/components/dropdown/Dropdown.stories.svelte +210 -210
  30. package/dist/components/dropdown/Dropdown.svelte +57 -57
  31. package/dist/components/dropdown/components/DropdownContent.svelte +16 -16
  32. package/dist/components/dropdown/components/DropdownMenu.svelte +10 -10
  33. package/dist/components/dropdown/components/DropdownTrigger.svelte +37 -37
  34. package/dist/components/dropdown/components/OutlinedButton.svelte +9 -9
  35. package/dist/components/empty-content/EmptyContent.stories.svelte +38 -38
  36. package/dist/components/empty-content/EmptyContent.svelte +12 -12
  37. package/dist/components/graphs/bar-chart/BarChart.stories.svelte +91 -91
  38. package/dist/components/graphs/bar-chart/BarChart.svelte +147 -147
  39. package/dist/components/graphs/bar-chart/StackedBarChart.stories.svelte +57 -57
  40. package/dist/components/graphs/bar-chart/StackedBarChart.svelte +198 -199
  41. package/dist/components/graphs/chart/Chart.stories.svelte +96 -96
  42. package/dist/components/graphs/chart/Chart.svelte +207 -207
  43. package/dist/components/graphs/line/LineChart.stories.svelte +138 -138
  44. package/dist/components/graphs/line/LineChart.svelte +140 -142
  45. package/dist/components/graphs/matrix/Matrix.stories.svelte +117 -117
  46. package/dist/components/graphs/matrix/Matrix.svelte +141 -141
  47. package/dist/components/graphs/multiline/MultiLineChart.stories.svelte +168 -168
  48. package/dist/components/graphs/multiline/MultiLineChart.svelte +236 -236
  49. package/dist/components/graphs/scatterplot/Scatterplot.stories.svelte +84 -84
  50. package/dist/components/graphs/scatterplot/Scatterplot.svelte +302 -302
  51. package/dist/components/graphs/utils/duration.d.ts +1 -0
  52. package/dist/components/graphs/utils/duration.js +33 -0
  53. package/dist/components/graphs/utils/tooltipFormatter.js +1 -1
  54. package/dist/components/icon-button/IconButton.stories.svelte +64 -64
  55. package/dist/components/icon-button/IconButton.svelte +88 -88
  56. package/dist/components/icons/AnalysisIcon.stories.svelte +18 -18
  57. package/dist/components/icons/AnalysisIcon.svelte +96 -96
  58. package/dist/components/icons/Icon.stories.svelte +111 -111
  59. package/dist/components/icons/Icon.svelte +17 -17
  60. package/dist/components/icons/PrincipalIcon.svelte +59 -59
  61. package/dist/components/icons/custom/Halo.svelte +31 -31
  62. package/dist/components/icons/custom/Well.svelte +27 -27
  63. package/dist/components/icons/index.d.ts +3 -2
  64. package/dist/components/icons/index.js +3 -1
  65. package/dist/components/image/Image.svelte +42 -42
  66. package/dist/components/input/Input.stories.svelte +55 -55
  67. package/dist/components/input/Input.svelte +121 -121
  68. package/dist/components/label/Label.stories.svelte +18 -18
  69. package/dist/components/label/Label.svelte +11 -11
  70. package/dist/components/list/List.stories.svelte +84 -84
  71. package/dist/components/list/List.svelte +20 -20
  72. package/dist/components/logo/Logo.stories.svelte +15 -15
  73. package/dist/components/logo/Logo.svelte +30 -30
  74. package/dist/components/manual-cfu-counter/ManualCFUCounter.stories.svelte +102 -102
  75. package/dist/components/manual-cfu-counter/ManualCFUCounter.svelte +557 -565
  76. package/dist/components/manual-cfu-counter/test/ManualCFUCounterTestWrapper.svelte +11 -11
  77. package/dist/components/markdown/Markdown.stories.svelte +10 -10
  78. package/dist/components/markdown/Markdown.svelte +6 -6
  79. package/dist/components/modal/Modal.stories.svelte +29 -29
  80. package/dist/components/modal/Modal.svelte +71 -71
  81. package/dist/components/multi-cfu-counter/MultiCFUCounter.stories.svelte +215 -0
  82. package/dist/components/multi-cfu-counter/MultiCFUCounter.stories.svelte.d.ts +3 -0
  83. package/dist/components/multi-cfu-counter/MultiCFUCounter.svelte +662 -0
  84. package/dist/components/multi-cfu-counter/MultiCFUCounter.svelte.d.ts +32 -0
  85. package/dist/components/multi-cfu-counter/index.d.ts +1 -0
  86. package/dist/components/multi-cfu-counter/index.js +1 -0
  87. package/dist/components/multi-cfu-counter/test/MultiCFUCounterTestWrapper.svelte +28 -0
  88. package/dist/components/multi-cfu-counter/test/MultiCFUCounterTestWrapper.svelte.d.ts +20 -0
  89. package/dist/components/notification-popup/NotificationPopup.stories.svelte +18 -18
  90. package/dist/components/notification-popup/NotificationPopup.svelte +26 -26
  91. package/dist/components/notifications/Notifications.stories.svelte +101 -101
  92. package/dist/components/notifications/Notifications.svelte +9 -9
  93. package/dist/components/pill/Pill.stories.svelte +8 -8
  94. package/dist/components/pill/Pill.svelte +27 -27
  95. package/dist/components/progress-circle/ProgressCircle.stories.svelte +8 -8
  96. package/dist/components/progress-circle/ProgressCircle.svelte +54 -54
  97. package/dist/components/required-status-indicator/RequiredStatusIndicator.stories.svelte +18 -18
  98. package/dist/components/required-status-indicator/RequiredStatusIndicator.svelte +14 -14
  99. package/dist/components/segmented-control-buttons/ControlButton.svelte +36 -36
  100. package/dist/components/segmented-control-buttons/SegmentedControlButtons.stories.svelte +35 -35
  101. package/dist/components/segmented-control-buttons/SegmentedControlButtons.svelte +13 -13
  102. package/dist/components/select/Select.stories.svelte +77 -77
  103. package/dist/components/select/Select.svelte +114 -114
  104. package/dist/components/select-new/Select.stories.svelte +188 -188
  105. package/dist/components/select-new/components/Group.svelte +17 -17
  106. package/dist/components/select-new/components/MultiSelectTrigger.svelte +57 -57
  107. package/dist/components/select-new/components/SelectContent.svelte +22 -22
  108. package/dist/components/select-new/components/SelectGroupHeading.svelte +10 -10
  109. package/dist/components/select-new/components/SelectItem.svelte +25 -25
  110. package/dist/components/select-new/components/SelectTrigger.svelte +38 -38
  111. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte +76 -76
  112. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte.d.ts +1 -1
  113. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte +53 -53
  114. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte.d.ts +1 -1
  115. package/dist/components/sjsf-wrappers/sjsfCustomTheme.js +1 -1
  116. package/dist/components/skeleton-loader/SkeletonLoader.stories.svelte +32 -32
  117. package/dist/components/skeleton-loader/SkeletonLoader.svelte +10 -10
  118. package/dist/components/skeleton-loader/StatcardSkeleton.svelte +9 -9
  119. package/dist/components/skeleton-loader/components/Skeleton.svelte +7 -7
  120. package/dist/components/skeleton-loader/components/SkeletonImage.svelte +12 -12
  121. package/dist/components/slider/Slider.stories.svelte +23 -23
  122. package/dist/components/slider/Slider.svelte +107 -107
  123. package/dist/components/spinner/Spinner.stories.svelte +8 -8
  124. package/dist/components/spinner/Spinner.svelte +18 -18
  125. package/dist/components/stat-card/StatCard.stories.svelte +26 -26
  126. package/dist/components/stat-card/StatCard.svelte +128 -128
  127. package/dist/components/status-badge/StatusBadge.stories.svelte +365 -365
  128. package/dist/components/status-badge/StatusBadge.svelte +54 -54
  129. package/dist/components/stepper/Stepper.stories.svelte +219 -219
  130. package/dist/components/stepper/components/stepper-root.svelte +12 -12
  131. package/dist/components/stepper/components/stepper-step.svelte +83 -83
  132. package/dist/components/table/Table.stories.svelte +87 -87
  133. package/dist/components/table/Table.svelte +32 -32
  134. package/dist/components/table/components/TBody.svelte +7 -7
  135. package/dist/components/table/components/THead.svelte +7 -7
  136. package/dist/components/table/components/Td.svelte +8 -8
  137. package/dist/components/table/components/Th.svelte +8 -8
  138. package/dist/components/table/components/Tr.svelte +11 -11
  139. package/dist/components/tabs/Tabs.stories.svelte +20 -20
  140. package/dist/components/tabs/Tabs.svelte +8 -8
  141. package/dist/components/tabs/components/Content.svelte +8 -8
  142. package/dist/components/tabs/components/Tab.svelte +14 -14
  143. package/dist/components/tabs/components/Tabs.svelte +7 -7
  144. package/dist/components/tag/Tag.stories.svelte +57 -57
  145. package/dist/components/tag/Tag.svelte +95 -95
  146. package/dist/components/textarea/Textarea.stories.svelte +70 -70
  147. package/dist/components/textarea/Textarea.svelte +76 -76
  148. package/dist/components/toast/Toast.stories.svelte +204 -204
  149. package/dist/components/toast/Toast.svelte +53 -53
  150. package/dist/components/toggle/Toggle.stories.svelte +9 -9
  151. package/dist/components/toggle/Toggle.svelte +53 -53
  152. package/dist/components/toggle-icon-button/ToggleIconButton.stories.svelte +152 -152
  153. package/dist/components/toggle-icon-button/ToggleIconButton.svelte +99 -99
  154. package/dist/components/tooltip/Tooltip.stories.svelte +105 -105
  155. package/dist/components/tooltip/Tooltip.svelte +26 -26
  156. package/dist/fonts/index.js +1 -1
  157. package/dist/index.d.ts +4 -1
  158. package/dist/index.js +5 -1
  159. package/dist/notifications.d.ts +1 -4
  160. package/dist/notifications.js +1 -1
  161. package/dist/styles.d.ts +1 -0
  162. package/dist/styles.js +4 -0
  163. package/dist/tailwind-safelist.js +406 -398
  164. package/dist/tailwind.preset.d.ts +4 -0
  165. package/dist/tailwind.preset.js +10 -10
  166. package/dist/tokens/colors.d.ts +246 -0
  167. package/dist/tokens/colors.js +139 -0
  168. package/dist/tokens/index.d.ts +3 -0
  169. package/dist/tokens/index.js +5 -0
  170. package/dist/tokens/typography.d.ts +48 -0
  171. package/dist/tokens/typography.js +48 -0
  172. package/dist/tokens.d.ts +16 -252
  173. package/dist/tokens.js +33 -164
  174. package/dist/types/fonts.d.ts +2 -2
  175. package/package.json +398 -78
@@ -1,581 +1,573 @@
1
1
  <script lang="ts">
2
- import { onMount } from 'svelte';
3
- import { Button } from '../button';
4
- import { textColor, borderColor } from '../../tokens';
5
- import { Icon } from '../icons';
6
- import IconButton from '../icon-button/IconButton.svelte';
7
- import Divider from '../divider/Divider.svelte';
8
-
9
- // Constants for the component
10
- const BASE_IMAGE_SIZE = 464; // Base size, will be overriden by container dimensions
11
- const BASE_MARKER_SIZE = 12; // Base size at zoom level 1
12
- const BASE_MARKER_FONT_SIZE = 10; // Base font size at zoom level 1
13
- const MAX_ZOOM = 10;
14
- const MIN_ZOOM = 1;
15
- const ZOOM_STEP = 0.001;
16
- const MARKER_COLOR = textColor['icon-blue'] || '#2563eb';
17
- const MARKER_BORDER_COLOR = borderColor['interactive'] || '#12192A1A';
18
- const TEXT_COLOR = textColor['primary-inverse'];
19
- const DRAG_THRESHOLD = 5;
20
-
21
- interface Props {
22
- imageUrl: string;
23
- marks?: Array<{ x: number; y: number }>;
24
- onclick?: (event: MouseEvent, marks: Array<{ x: number; y: number }>) => void;
25
- disabled?: boolean;
26
- hideMarkers?: boolean;
27
- }
28
-
29
- let {
30
- imageUrl,
31
- onclick,
32
- disabled = false,
33
- hideMarkers = false,
34
- marks = $bindable([])
35
- }: Props = $props();
36
-
37
- let svgElement: SVGSVGElement;
38
- let viewport: SVGGraphicsElement;
39
- let dotsGroup: SVGElement;
40
- let marksHistory: Array<Array<{ x: number; y: number }>> = $state([]);
41
- let container: HTMLDivElement;
42
-
43
- let containerW = BASE_IMAGE_SIZE;
44
- let containerH = BASE_IMAGE_SIZE;
45
-
46
- let transform = $state({ x: 0, y: 0, scale: 1 });
47
-
48
- // Panning state: null = not panning, 'ready' = mouse button down, 'active' = dragging
49
- let panningState = $state<null | 'ready' | 'active'>(null);
50
- let startPoint = $state({ x: 0, y: 0 });
51
- let isShiftPressed = $state(false);
52
- let resizeObserver: ResizeObserver;
53
-
54
- let imageAspectRatio = $state(1); // default 1:1 ratio
55
- let imageDisplayWidth = $state(0);
56
- let imageDisplayHeight = $state(0);
57
-
58
- function handleResize(entries: ResizeObserverEntry[]) {
59
- const entry = entries[0];
60
- if (entry) {
61
- const oldWidth = imageDisplayWidth || containerW;
62
- const oldHeight = imageDisplayHeight || containerH;
63
-
64
- containerW = entry.contentRect.width;
65
- containerH = entry.contentRect.height;
66
-
67
- // If we already had dimensions and marks, update their positions
68
- if (oldWidth > 0 && oldHeight > 0 && marks.length > 0) {
69
- // Adjust marks positions based on new container dimensions
70
- marks = marks.map((mark) => ({
71
- x: (mark.x / oldWidth) * containerW,
72
- y: (mark.y / oldHeight) * containerH
73
- }));
74
- }
75
-
76
- updateImageDimensions();
77
- }
78
- }
79
-
80
- function onImageLoad() {
81
- if (container) {
82
- containerW = container.clientWidth;
83
- containerH = container.clientHeight;
84
-
85
- // get original image aspect ratio
86
- const img = new Image();
87
- img.src = imageUrl;
88
- img.onload = () => {
89
- imageAspectRatio = img.naturalWidth / img.naturalHeight;
90
- updateImageDimensions();
91
- };
92
-
93
- if (img.complete && img.naturalWidth) {
94
- imageAspectRatio = img.naturalWidth / img.naturalHeight;
95
- updateImageDimensions();
96
- }
97
- }
98
- }
99
-
100
- function updateImageDimensions() {
101
- // calculate dimensions that maintain aspect ratio within container
102
- const containerRatio = containerW / containerH;
103
-
104
- if (containerRatio > imageAspectRatio) {
105
- // container is wider than image - fit to height
106
- imageDisplayHeight = containerH;
107
- imageDisplayWidth = containerH * imageAspectRatio;
108
- } else {
109
- // container is taller than image - fit to width
110
- imageDisplayWidth = containerW;
111
- imageDisplayHeight = containerW / imageAspectRatio;
112
- }
113
-
114
- updateTransform();
115
- }
116
-
117
- // Properly constrain the transform to prevent moving outside container
118
- function clampTransform() {
119
- // Calculate maximum translation offsets based on scale > 1
120
- const maxX = 0;
121
- const maxY = 0;
122
- // Prevent division by zero or unexpected behavior if container dimensions aren't ready
123
- const safeContainerW = containerW || 1;
124
- const safeContainerH = containerH || 1;
125
- const minX = safeContainerW * (1 - transform.scale);
126
- const minY = safeContainerH * (1 - transform.scale);
127
-
128
- if (transform.scale <= 1) {
129
- // If scale is 1 or less, center the view
130
- // Check if scale is exactly 1 before modifying to avoid floating point issues if needed
131
- // Or simply reset if it goes below
132
- transform.scale = 1;
133
- transform.x = 0;
134
- transform.y = 0;
135
- } else {
136
- // Apply clamping for scale > 1
137
- transform.x = Math.max(minX, Math.min(maxX, transform.x));
138
- transform.y = Math.max(minY, Math.min(maxY, transform.y));
139
- }
140
- }
141
-
142
- function updateTransform() {
143
- if (!viewport) return;
144
-
145
- // update viewport transform
146
- viewport.setAttribute(
147
- 'transform',
148
- `translate(${transform.x}, ${transform.y}) scale(${transform.scale})`
149
- );
150
-
151
- // set image dimensions based on calculated size that maintains aspect ratio
152
- const imageElement = viewport.querySelector('image');
153
- if (imageElement) {
154
- imageElement.setAttribute('width', String(imageDisplayWidth));
155
- imageElement.setAttribute('height', String(imageDisplayHeight));
156
-
157
- // center the image in the container
158
- const offsetX = (containerW - imageDisplayWidth) / 2;
159
- const offsetY = (containerH - imageDisplayHeight) / 2;
160
-
161
- imageElement.setAttribute('x', String(offsetX));
162
- imageElement.setAttribute('y', String(offsetY));
163
- }
164
-
165
- // update markers to maintain consistent size
166
- renderMarkers();
167
- }
168
-
169
- function getSvgPoint(event: MouseEvent) {
170
- if (!svgElement || !container) return { x: 0, y: 0 };
171
-
172
- // get container bounding rect
173
- const rect = container.getBoundingClientRect();
174
-
175
- // calculate position relative to container
176
- const relativeX = event.clientX - rect.left;
177
- const relativeY = event.clientY - rect.top;
178
-
179
- // apply inverse of current transform to get SVG coordinates
180
- const svgX = (relativeX - transform.x) / transform.scale;
181
- const svgY = (relativeY - transform.y) / transform.scale;
182
-
183
- // adjust for image position within container (letterboxing)
184
- const offsetX = (containerW - imageDisplayWidth) / 2;
185
- const offsetY = (containerH - imageDisplayHeight) / 2;
186
-
187
- // return coordinates relative to the image, not the container
188
- return {
189
- x: svgX - offsetX,
190
- y: svgY - offsetY
191
- };
192
- }
193
-
194
- // Render SVG markers from the marks array
195
- function renderMarkers() {
196
- if (!dotsGroup) return;
197
-
198
- while (dotsGroup.firstChild) {
199
- dotsGroup.removeChild(dotsGroup.firstChild);
200
- }
201
-
202
- const offsetX = (containerW - imageDisplayWidth) / 2;
203
- const offsetY = (containerH - imageDisplayHeight) / 2;
204
-
205
- const svgns = 'http://www.w3.org/2000/svg';
206
- const adjustedMarkerSize = BASE_MARKER_SIZE / transform.scale;
207
- const adjustedFontSize = BASE_MARKER_FONT_SIZE / transform.scale;
208
-
209
- marks.forEach((mark, index) => {
210
- // Create a marker group
211
- const group = document.createElementNS(svgns, 'g');
212
- group.setAttribute('data-testid', `marker-${index + 1}`);
213
-
214
- const markX = mark.x + offsetX;
215
- const markY = mark.y + offsetY;
216
-
217
- const circle = document.createElementNS(svgns, 'circle');
218
- circle.setAttribute('cx', String(markX));
219
- circle.setAttribute('cy', String(markY));
220
- circle.setAttribute('r', String(adjustedMarkerSize));
221
- circle.setAttribute('fill', MARKER_COLOR);
222
- circle.setAttribute('class', 'drop-shadow-sm');
223
- circle.setAttribute('opacity', '0.75');
224
- circle.setAttribute('stroke', MARKER_BORDER_COLOR);
225
- circle.setAttribute('stroke-width', String(1 / transform.scale));
226
- group.appendChild(circle);
227
-
228
- const text = document.createElementNS(svgns, 'text');
229
- text.setAttribute('x', String(markX));
230
- text.setAttribute('y', String(markY));
231
- text.setAttribute('text-anchor', 'middle');
232
- text.setAttribute('dominant-baseline', 'central');
233
- text.setAttribute('fill', TEXT_COLOR);
234
- text.setAttribute('font-size', String(adjustedFontSize));
235
- text.setAttribute('font-weight', 'bold');
236
- text.textContent = String(index + 1);
237
- group.appendChild(text);
238
-
239
- dotsGroup.appendChild(group);
240
- });
241
- }
242
-
243
- function handleClick(event: MouseEvent) {
244
- if (disabled || hideMarkers || !dotsGroup || isShiftPressed || panningState !== null) {
245
- panningState = null;
246
- return;
247
- }
248
- saveCurrentState();
249
- const pt = getSvgPoint(event);
250
- marks.push({ x: pt.x, y: pt.y });
251
-
252
- renderMarkers();
253
- if (onclick) {
254
- onclick(event, marks);
255
- }
256
- }
257
-
258
- function handleKeyDown(event: KeyboardEvent) {
259
- if (event.shiftKey) {
260
- isShiftPressed = true;
261
- }
262
- }
263
-
264
- function handleKeyUp(event: KeyboardEvent) {
265
- let shouldResetReadyState = false;
266
-
267
- if (!event.shiftKey && isShiftPressed) {
268
- isShiftPressed = false;
269
- shouldResetReadyState = true;
270
- }
271
-
272
- // If panning was in 'ready' state and the relevant key was released, reset state
273
- if (shouldResetReadyState && panningState === 'ready') {
274
- panningState = null;
275
- }
276
- }
277
-
278
- function handleMouseDown(event: MouseEvent) {
279
- // Only start panning on right click, shift+left click
280
- const isPanningTrigger = event.button === 2 || (event.button === 0 && isShiftPressed);
281
- if (!isPanningTrigger) return;
282
-
283
- event.preventDefault(); // Prevent default context menu or text selection
284
- panningState = 'ready';
285
- startPoint = { x: event.clientX, y: event.clientY };
286
- }
287
-
288
- // Handle panning on mouse move
289
- function handleMouseMove(event: MouseEvent) {
290
- if (panningState === null) return;
291
-
292
- const dx = event.clientX - startPoint.x;
293
- const dy = event.clientY - startPoint.y;
294
-
295
- // Detect if we're dragging (moved beyond threshold)
296
- if (panningState === 'ready' && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
297
- panningState = 'active';
298
- }
299
-
300
- // Only update position if we're actually panning
301
- if (panningState === 'active') {
302
- startPoint = { x: event.clientX, y: event.clientY };
303
-
304
- transform.x += dx;
305
- transform.y += dy;
306
-
307
- // Ensure pan stays within bounds
308
- clampTransform();
309
- updateTransform();
310
- }
311
- }
312
-
313
- function handleMouseUp() {
314
- panningState = null;
315
- }
316
-
317
- function handleMouseLeave() {
318
- panningState = null;
319
- }
320
-
321
- function handleContextMenu(event: MouseEvent) {
322
- event.preventDefault();
323
- }
324
-
325
- function handleWheel(event: WheelEvent) {
326
- event.preventDefault();
327
-
328
- if (!svgElement || !viewport || !container) return;
329
-
330
- // Update container dimensions just in case they changed
331
- containerW = container.clientWidth;
332
- containerH = container.clientHeight;
333
-
334
- const zoomIntensity = ZOOM_STEP;
335
- const delta = event.deltaY;
336
- const zoomFactor = 1 - delta * zoomIntensity;
337
- const currentScale = transform.scale; // Store current scale before modification
338
-
339
- let potentialNewScale = currentScale * zoomFactor;
340
-
341
- // Clamp the scale
342
- let finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
343
-
344
- if (finalScale <= MIN_ZOOM) {
345
- // Reset view if zooming out below minimum or exactly at minimum
346
- transform.scale = MIN_ZOOM;
347
- transform.x = 0;
348
- transform.y = 0;
349
- } else {
350
- // Calculate transform based on finalScale (which might be MAX_ZOOM)
351
- const rect = container.getBoundingClientRect();
352
- const mouseX = event.clientX - rect.left;
353
- const mouseY = event.clientY - rect.top;
354
-
355
- // Calculate distance from origin at current scale
356
- const distX = mouseX - transform.x;
357
- const distY = mouseY - transform.y;
358
-
359
- // Calculate what the distance should be at the final scale
360
- // Use currentScale from *before* this zoom event for the ratio
361
- const scaleRatio = finalScale / currentScale;
362
- const newDistX = distX * scaleRatio;
363
- const newDistY = distY * scaleRatio;
364
-
365
- // Update the transform to maintain the mouse position relative to the final scale
366
- transform.x = mouseX - newDistX;
367
- transform.y = mouseY - newDistY;
368
- transform.scale = finalScale;
369
- }
370
-
371
- clampTransform();
372
- updateTransform();
373
- }
374
-
375
- function saveCurrentState() {
376
- marksHistory.push([...marks]);
377
- }
378
-
379
- function undo() {
380
- if (marksHistory.length === 0) return;
381
- const previousState = marksHistory.pop();
382
- if (previousState) {
383
- marks.splice(0, marks.length, ...previousState);
384
- marks = marks;
385
- renderMarkers();
386
- }
387
- }
388
-
389
- function reset() {
390
- if (marks.length === 0) return;
391
- saveCurrentState();
392
-
393
- marks = [];
394
- renderMarkers();
395
- }
396
-
397
- // --- Zoom Functions ---
398
- function applyZoom(zoomFactor: number) {
399
- if (!container) return;
400
-
401
- const currentScale = transform.scale;
402
- const potentialNewScale = currentScale * zoomFactor;
403
- const finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
404
-
405
- // If scale didn't change (already at min/max), do nothing
406
- if (finalScale === currentScale) return;
407
-
408
- // Get center of the container view
409
- const centerX = containerW / 2;
410
- const centerY = containerH / 2;
411
-
412
- // Calculate distance from origin at current scale
413
- const distX = centerX - transform.x;
414
- const distY = centerY - transform.y;
415
-
416
- // Calculate what the distance should be at the final scale
417
- const scaleRatio = finalScale / currentScale;
418
- const newDistX = distX * scaleRatio;
419
- const newDistY = distY * scaleRatio;
420
-
421
- // Update the transform to maintain the center position relative to the final scale
422
- transform.x = centerX - newDistX;
423
- transform.y = centerY - newDistY;
424
- transform.scale = finalScale;
425
-
426
- clampTransform();
427
- updateTransform();
428
- }
429
-
430
- function zoomIn() {
431
- applyZoom(1.2); // Zoom in by 20%
432
- }
433
-
434
- function zoomOut() {
435
- applyZoom(1 / 1.2); // Zoom out by 20%
436
- }
437
-
438
- onMount(() => {
439
- if (container) {
440
- containerW = container.clientWidth;
441
- containerH = container.clientHeight;
442
-
443
- // setup resize observer
444
- resizeObserver = new ResizeObserver(handleResize);
445
- resizeObserver.observe(container);
446
- }
447
-
448
- window.addEventListener('keydown', handleKeyDown);
449
- window.addEventListener('keyup', handleKeyUp);
450
-
451
- updateTransform();
452
- renderMarkers();
453
-
454
- const img = new Image();
455
- img.onload = onImageLoad;
456
- img.src = imageUrl;
457
-
458
- if (img.complete) {
459
- onImageLoad();
460
- }
461
-
462
- return () => {
463
- window.removeEventListener('keydown', handleKeyDown);
464
- window.removeEventListener('keyup', handleKeyUp);
465
- // cleanup resize observer
466
- if (resizeObserver) {
467
- resizeObserver.disconnect();
468
- }
469
- };
470
- });
471
-
472
- // Effect to render when marks prop changes from parent or internally
473
- $effect(() => {
474
- renderMarkers();
475
- });
2
+ import { onMount } from 'svelte';
3
+ import { Button } from '../button';
4
+ import { textColor, borderColor } from '../../tokens';
5
+ import { Icon } from '../icons';
6
+ import IconButton from '../icon-button/IconButton.svelte';
7
+ import Divider from '../divider/Divider.svelte';
8
+
9
+ // Constants for the component
10
+ const BASE_IMAGE_SIZE = 464; // Base size, will be overriden by container dimensions
11
+ const BASE_MARKER_SIZE = 8; // Base size at zoom level 1
12
+ const BASE_MARKER_FONT_SIZE = 8; // Base font size at zoom level 1
13
+ const MAX_ZOOM = 10;
14
+ const MIN_ZOOM = 1;
15
+ const ZOOM_STEP = 0.001;
16
+ const MARKER_COLOR = textColor['icon-blue'] || '#2563eb';
17
+ const MARKER_BORDER_COLOR = borderColor['interactive'] || '#12192A1A';
18
+ const TEXT_COLOR = textColor['primary-inverse'];
19
+ const DRAG_THRESHOLD = 5;
20
+
21
+ interface Props {
22
+ imageUrl: string;
23
+ marks?: Array<{ x: number; y: number }>;
24
+ onclick?: (event: MouseEvent, marks: Array<{ x: number; y: number }>) => void;
25
+ disabled?: boolean;
26
+ hideMarkers?: boolean;
27
+ }
28
+
29
+ let {
30
+ imageUrl,
31
+ onclick,
32
+ disabled = false,
33
+ hideMarkers = false,
34
+ marks = $bindable([]),
35
+ }: Props = $props();
36
+
37
+ let svgElement: SVGSVGElement;
38
+ let viewport: SVGGraphicsElement;
39
+ let dotsGroup: SVGElement;
40
+ let container: HTMLDivElement;
41
+
42
+ let containerW = BASE_IMAGE_SIZE;
43
+ let containerH = BASE_IMAGE_SIZE;
44
+
45
+ let transform = $state({ x: 0, y: 0, scale: 1 });
46
+
47
+ // Panning state: null = not panning, 'ready' = mouse button down, 'active' = dragging
48
+ let panningState = $state<null | 'ready' | 'active'>(null);
49
+ let startPoint = $state({ x: 0, y: 0 });
50
+ let isShiftPressed = $state(false);
51
+ let resizeObserver: ResizeObserver;
52
+
53
+ let imageAspectRatio = $state(1); // default 1:1 ratio
54
+ let imageDisplayWidth = $state(0);
55
+ let imageDisplayHeight = $state(0);
56
+
57
+ function handleResize(entries: ResizeObserverEntry[]) {
58
+ const entry = entries[0];
59
+ if (entry) {
60
+ const oldWidth = imageDisplayWidth || containerW;
61
+ const oldHeight = imageDisplayHeight || containerH;
62
+
63
+ containerW = entry.contentRect.width;
64
+ containerH = entry.contentRect.height;
65
+
66
+ // If we already had dimensions and marks, update their positions
67
+ if (oldWidth > 0 && oldHeight > 0 && marks.length > 0) {
68
+ // Adjust marks positions based on new container dimensions
69
+ marks = marks.map((mark) => ({
70
+ x: (mark.x / oldWidth) * containerW,
71
+ y: (mark.y / oldHeight) * containerH,
72
+ }));
73
+ }
74
+
75
+ updateImageDimensions();
76
+ }
77
+ }
78
+
79
+ function onImageLoad() {
80
+ if (container) {
81
+ containerW = container.clientWidth;
82
+ containerH = container.clientHeight;
83
+
84
+ // get original image aspect ratio
85
+ const img = new Image();
86
+ img.src = imageUrl;
87
+ img.onload = () => {
88
+ imageAspectRatio = img.naturalWidth / img.naturalHeight;
89
+ updateImageDimensions();
90
+ };
91
+
92
+ if (img.complete && img.naturalWidth) {
93
+ imageAspectRatio = img.naturalWidth / img.naturalHeight;
94
+ updateImageDimensions();
95
+ }
96
+ }
97
+ }
98
+
99
+ function updateImageDimensions() {
100
+ // calculate dimensions that maintain aspect ratio within container
101
+ const containerRatio = containerW / containerH;
102
+
103
+ if (containerRatio > imageAspectRatio) {
104
+ // container is wider than image - fit to height
105
+ imageDisplayHeight = containerH;
106
+ imageDisplayWidth = containerH * imageAspectRatio;
107
+ } else {
108
+ // container is taller than image - fit to width
109
+ imageDisplayWidth = containerW;
110
+ imageDisplayHeight = containerW / imageAspectRatio;
111
+ }
112
+
113
+ updateTransform();
114
+ }
115
+
116
+ // Properly constrain the transform to prevent moving outside container
117
+ function clampTransform() {
118
+ // Calculate maximum translation offsets based on scale > 1
119
+ const maxX = 0;
120
+ const maxY = 0;
121
+ // Prevent division by zero or unexpected behavior if container dimensions aren't ready
122
+ const safeContainerW = containerW || 1;
123
+ const safeContainerH = containerH || 1;
124
+ const minX = safeContainerW * (1 - transform.scale);
125
+ const minY = safeContainerH * (1 - transform.scale);
126
+
127
+ if (transform.scale <= 1) {
128
+ // If scale is 1 or less, center the view
129
+ // Check if scale is exactly 1 before modifying to avoid floating point issues if needed
130
+ // Or simply reset if it goes below
131
+ transform.scale = 1;
132
+ transform.x = 0;
133
+ transform.y = 0;
134
+ } else {
135
+ // Apply clamping for scale > 1
136
+ transform.x = Math.max(minX, Math.min(maxX, transform.x));
137
+ transform.y = Math.max(minY, Math.min(maxY, transform.y));
138
+ }
139
+ }
140
+
141
+ function updateTransform() {
142
+ if (!viewport) return;
143
+
144
+ // update viewport transform
145
+ viewport.setAttribute(
146
+ 'transform',
147
+ `translate(${transform.x}, ${transform.y}) scale(${transform.scale})`
148
+ );
149
+
150
+ // set image dimensions based on calculated size that maintains aspect ratio
151
+ const imageElement = viewport.querySelector('image');
152
+ if (imageElement) {
153
+ imageElement.setAttribute('width', String(imageDisplayWidth));
154
+ imageElement.setAttribute('height', String(imageDisplayHeight));
155
+
156
+ // center the image in the container
157
+ const offsetX = (containerW - imageDisplayWidth) / 2;
158
+ const offsetY = (containerH - imageDisplayHeight) / 2;
159
+
160
+ imageElement.setAttribute('x', String(offsetX));
161
+ imageElement.setAttribute('y', String(offsetY));
162
+ }
163
+
164
+ // update markers to maintain consistent size
165
+ renderMarkers();
166
+ }
167
+
168
+ function getSvgPoint(event: MouseEvent) {
169
+ if (!svgElement || !container) return { x: 0, y: 0 };
170
+
171
+ // get container bounding rect
172
+ const rect = container.getBoundingClientRect();
173
+
174
+ // calculate position relative to container
175
+ const relativeX = event.clientX - rect.left;
176
+ const relativeY = event.clientY - rect.top;
177
+
178
+ // apply inverse of current transform to get SVG coordinates
179
+ const svgX = (relativeX - transform.x) / transform.scale;
180
+ const svgY = (relativeY - transform.y) / transform.scale;
181
+
182
+ // adjust for image position within container (letterboxing)
183
+ const offsetX = (containerW - imageDisplayWidth) / 2;
184
+ const offsetY = (containerH - imageDisplayHeight) / 2;
185
+
186
+ // return coordinates relative to the image, not the container
187
+ return {
188
+ x: svgX - offsetX,
189
+ y: svgY - offsetY,
190
+ };
191
+ }
192
+
193
+ // Render SVG markers from the marks array
194
+ function renderMarkers() {
195
+ if (!dotsGroup) return;
196
+
197
+ while (dotsGroup.firstChild) {
198
+ dotsGroup.removeChild(dotsGroup.firstChild);
199
+ }
200
+
201
+ const offsetX = (containerW - imageDisplayWidth) / 2;
202
+ const offsetY = (containerH - imageDisplayHeight) / 2;
203
+
204
+ const svgns = 'http://www.w3.org/2000/svg';
205
+ const adjustedMarkerSize = BASE_MARKER_SIZE / transform.scale;
206
+ const adjustedFontSize = BASE_MARKER_FONT_SIZE / transform.scale;
207
+
208
+ marks.forEach((mark, index) => {
209
+ // Create a marker group
210
+ const group = document.createElementNS(svgns, 'g');
211
+ group.setAttribute('data-testid', `marker-${index + 1}`);
212
+
213
+ const markX = mark.x + offsetX;
214
+ const markY = mark.y + offsetY;
215
+
216
+ const circle = document.createElementNS(svgns, 'circle');
217
+ circle.setAttribute('cx', String(markX));
218
+ circle.setAttribute('cy', String(markY));
219
+ circle.setAttribute('r', String(adjustedMarkerSize));
220
+ circle.setAttribute('fill', MARKER_COLOR);
221
+ circle.setAttribute('class', 'drop-shadow-sm');
222
+ circle.setAttribute('opacity', '0.75');
223
+ circle.setAttribute('stroke', MARKER_BORDER_COLOR);
224
+ circle.setAttribute('stroke-width', String(1 / transform.scale));
225
+ group.appendChild(circle);
226
+
227
+ const text = document.createElementNS(svgns, 'text');
228
+ text.setAttribute('x', String(markX));
229
+ text.setAttribute('y', String(markY));
230
+ text.setAttribute('text-anchor', 'middle');
231
+ text.setAttribute('dominant-baseline', 'central');
232
+ text.setAttribute('fill', TEXT_COLOR);
233
+ text.setAttribute('font-size', String(adjustedFontSize));
234
+ text.setAttribute('font-weight', 'bold');
235
+ text.textContent = String(index + 1);
236
+ group.appendChild(text);
237
+
238
+ dotsGroup.appendChild(group);
239
+ });
240
+ }
241
+
242
+ function handleClick(event: MouseEvent) {
243
+ if (disabled || hideMarkers || !dotsGroup || isShiftPressed || panningState !== null) {
244
+ panningState = null;
245
+ return;
246
+ }
247
+ const pt = getSvgPoint(event);
248
+ marks.push({ x: pt.x, y: pt.y });
249
+
250
+ renderMarkers();
251
+ if (onclick) {
252
+ onclick(event, marks);
253
+ }
254
+ }
255
+
256
+ function handleKeyDown(event: KeyboardEvent) {
257
+ if (event.shiftKey) {
258
+ isShiftPressed = true;
259
+ }
260
+ }
261
+
262
+ function handleKeyUp(event: KeyboardEvent) {
263
+ let shouldResetReadyState = false;
264
+
265
+ if (!event.shiftKey && isShiftPressed) {
266
+ isShiftPressed = false;
267
+ shouldResetReadyState = true;
268
+ }
269
+
270
+ // If panning was in 'ready' state and the relevant key was released, reset state
271
+ if (shouldResetReadyState && panningState === 'ready') {
272
+ panningState = null;
273
+ }
274
+ }
275
+
276
+ function handleMouseDown(event: MouseEvent) {
277
+ // Only start panning on right click, shift+left click
278
+ const isPanningTrigger = event.button === 2 || (event.button === 0 && isShiftPressed);
279
+ if (!isPanningTrigger) return;
280
+
281
+ event.preventDefault(); // Prevent default context menu or text selection
282
+ panningState = 'ready';
283
+ startPoint = { x: event.clientX, y: event.clientY };
284
+ }
285
+
286
+ // Handle panning on mouse move
287
+ function handleMouseMove(event: MouseEvent) {
288
+ if (panningState === null) return;
289
+
290
+ const dx = event.clientX - startPoint.x;
291
+ const dy = event.clientY - startPoint.y;
292
+
293
+ // Detect if we're dragging (moved beyond threshold)
294
+ if (panningState === 'ready' && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
295
+ panningState = 'active';
296
+ }
297
+
298
+ // Only update position if we're actually panning
299
+ if (panningState === 'active') {
300
+ startPoint = { x: event.clientX, y: event.clientY };
301
+
302
+ transform.x += dx;
303
+ transform.y += dy;
304
+
305
+ // Ensure pan stays within bounds
306
+ clampTransform();
307
+ updateTransform();
308
+ }
309
+ }
310
+
311
+ function handleMouseUp() {
312
+ panningState = null;
313
+ }
314
+
315
+ function handleMouseLeave() {
316
+ panningState = null;
317
+ }
318
+
319
+ function handleContextMenu(event: MouseEvent) {
320
+ event.preventDefault();
321
+ }
322
+
323
+ function handleWheel(event: WheelEvent) {
324
+ event.preventDefault();
325
+
326
+ if (!svgElement || !viewport || !container) return;
327
+
328
+ // Update container dimensions just in case they changed
329
+ containerW = container.clientWidth;
330
+ containerH = container.clientHeight;
331
+
332
+ const zoomIntensity = ZOOM_STEP;
333
+ const delta = event.deltaY;
334
+ const zoomFactor = 1 - delta * zoomIntensity;
335
+ const currentScale = transform.scale; // Store current scale before modification
336
+
337
+ let potentialNewScale = currentScale * zoomFactor;
338
+
339
+ // Clamp the scale
340
+ let finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
341
+
342
+ if (finalScale <= MIN_ZOOM) {
343
+ // Reset view if zooming out below minimum or exactly at minimum
344
+ transform.scale = MIN_ZOOM;
345
+ transform.x = 0;
346
+ transform.y = 0;
347
+ } else {
348
+ // Calculate transform based on finalScale (which might be MAX_ZOOM)
349
+ const rect = container.getBoundingClientRect();
350
+ const mouseX = event.clientX - rect.left;
351
+ const mouseY = event.clientY - rect.top;
352
+
353
+ // Calculate distance from origin at current scale
354
+ const distX = mouseX - transform.x;
355
+ const distY = mouseY - transform.y;
356
+
357
+ // Calculate what the distance should be at the final scale
358
+ // Use currentScale from *before* this zoom event for the ratio
359
+ const scaleRatio = finalScale / currentScale;
360
+ const newDistX = distX * scaleRatio;
361
+ const newDistY = distY * scaleRatio;
362
+
363
+ // Update the transform to maintain the mouse position relative to the final scale
364
+ transform.x = mouseX - newDistX;
365
+ transform.y = mouseY - newDistY;
366
+ transform.scale = finalScale;
367
+ }
368
+
369
+ clampTransform();
370
+ updateTransform();
371
+ }
372
+
373
+ // Simplified undo function - just pop the last mark
374
+ function undo() {
375
+ if (marks.length === 0) return;
376
+
377
+ marks.pop();
378
+ marks = marks;
379
+ renderMarkers();
380
+ }
381
+
382
+ function reset() {
383
+ if (marks.length === 0) return;
384
+
385
+ marks = [];
386
+ renderMarkers();
387
+ }
388
+
389
+ // --- Zoom Functions ---
390
+ function applyZoom(zoomFactor: number) {
391
+ if (!container) return;
392
+
393
+ const currentScale = transform.scale;
394
+ const potentialNewScale = currentScale * zoomFactor;
395
+ const finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
396
+
397
+ // If scale didn't change (already at min/max), do nothing
398
+ if (finalScale === currentScale) return;
399
+
400
+ // Get center of the container view
401
+ const centerX = containerW / 2;
402
+ const centerY = containerH / 2;
403
+
404
+ // Calculate distance from origin at current scale
405
+ const distX = centerX - transform.x;
406
+ const distY = centerY - transform.y;
407
+
408
+ // Calculate what the distance should be at the final scale
409
+ const scaleRatio = finalScale / currentScale;
410
+ const newDistX = distX * scaleRatio;
411
+ const newDistY = distY * scaleRatio;
412
+
413
+ // Update the transform to maintain the center position relative to the final scale
414
+ transform.x = centerX - newDistX;
415
+ transform.y = centerY - newDistY;
416
+ transform.scale = finalScale;
417
+
418
+ clampTransform();
419
+ updateTransform();
420
+ }
421
+
422
+ function zoomIn() {
423
+ applyZoom(1.2); // Zoom in by 20%
424
+ }
425
+
426
+ function zoomOut() {
427
+ applyZoom(1 / 1.2); // Zoom out by 20%
428
+ }
429
+
430
+ onMount(() => {
431
+ if (container) {
432
+ containerW = container.clientWidth;
433
+ containerH = container.clientHeight;
434
+
435
+ // setup resize observer
436
+ resizeObserver = new ResizeObserver(handleResize);
437
+ resizeObserver.observe(container);
438
+ }
439
+
440
+ window.addEventListener('keydown', handleKeyDown);
441
+ window.addEventListener('keyup', handleKeyUp);
442
+
443
+ updateTransform();
444
+ renderMarkers();
445
+
446
+ const img = new Image();
447
+ img.onload = onImageLoad;
448
+ img.src = imageUrl;
449
+
450
+ if (img.complete) {
451
+ onImageLoad();
452
+ }
453
+
454
+ return () => {
455
+ window.removeEventListener('keydown', handleKeyDown);
456
+ window.removeEventListener('keyup', handleKeyUp);
457
+ // cleanup resize observer
458
+ if (resizeObserver) {
459
+ resizeObserver.disconnect();
460
+ }
461
+ };
462
+ });
463
+
464
+ // Effect to render when marks prop changes from parent or internally
465
+ $effect(() => {
466
+ renderMarkers();
467
+ });
476
468
  </script>
477
469
 
478
470
  {#snippet TopLeftActions()}
479
- {#if marks.length > 0}
480
- <div
481
- class="absolute left-2 top-2 z-20 flex items-center gap-1 rounded-lg border border-interactive-inverse bg-base-inverse p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
482
- >
483
- <IconButton
484
- variant="transparent-inverse"
485
- rounded={false}
486
- onclick={undo}
487
- disabled={marksHistory.length === 0 || disabled}
488
- aria-label="Undo last mark"
489
- >
490
- <Icon iconName={'ArrowUUpLeft'} />
491
- </IconButton>
492
- <Divider vertical inverse class="!h-5" />
493
- <Button
494
- variant="transparent-inverse"
495
- size="sm"
496
- onClick={reset}
497
- disabled={marks.length === 0 || disabled}
498
- accessibilityLabel="Reset all marks"
499
- class="!text-primary-inverse"
500
- >
501
- Clear all
502
- </Button>
503
- </div>
504
- {/if}
471
+ {#if marks.length > 0}
472
+ <div
473
+ class="absolute left-2 top-2 z-20 flex items-center gap-1 rounded-lg border border-interactive-inverse bg-base-inverse p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
474
+ >
475
+ <IconButton
476
+ variant="transparent-inverse"
477
+ rounded={false}
478
+ onclick={undo}
479
+ disabled={marks.length === 0 || disabled}
480
+ aria-label="Undo last mark"
481
+ >
482
+ <Icon iconName={'ArrowUUpLeft'} />
483
+ </IconButton>
484
+ <Divider vertical inverse class="!h-5" />
485
+ <Button
486
+ variant="transparent-inverse"
487
+ size="sm"
488
+ onClick={reset}
489
+ disabled={marks.length === 0 || disabled}
490
+ accessibilityLabel="Reset all marks"
491
+ class="!text-primary-inverse"
492
+ >
493
+ Clear all
494
+ </Button>
495
+ </div>
496
+ {/if}
505
497
  {/snippet}
506
498
 
507
499
  {#snippet ZoomControls()}
508
- <div
509
- class="absolute bottom-2 right-2 z-20 flex flex-col items-center gap-1 rounded-md border border-interactive-inverse bg-base-inverse p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
510
- >
511
- <IconButton
512
- variant="transparent-inverse"
513
- rounded={false}
514
- onclick={zoomIn}
515
- aria-label="Zoom In"
516
- disabled={transform.scale >= MAX_ZOOM || disabled}
517
- >
518
- <Icon iconName={'Plus'} />
519
- </IconButton>
520
- <IconButton
521
- variant="transparent-inverse"
522
- rounded={false}
523
- onclick={zoomOut}
524
- aria-label="Zoom Out"
525
- disabled={transform.scale <= MIN_ZOOM || disabled}
526
- >
527
- <Icon iconName={'Minus'} />
528
- </IconButton>
529
- </div>
500
+ <div
501
+ class="absolute bottom-2 right-2 z-20 flex flex-col items-center gap-1 rounded-md border border-interactive-inverse bg-base-inverse p-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100"
502
+ >
503
+ <IconButton
504
+ variant="transparent-inverse"
505
+ rounded={false}
506
+ onclick={zoomIn}
507
+ aria-label="Zoom In"
508
+ disabled={transform.scale >= MAX_ZOOM || disabled}
509
+ >
510
+ <Icon iconName={'Plus'} />
511
+ </IconButton>
512
+ <IconButton
513
+ variant="transparent-inverse"
514
+ rounded={false}
515
+ onclick={zoomOut}
516
+ aria-label="Zoom Out"
517
+ disabled={transform.scale <= MIN_ZOOM || disabled}
518
+ >
519
+ <Icon iconName={'Minus'} />
520
+ </IconButton>
521
+ </div>
530
522
  {/snippet}
531
523
 
532
524
  <div bind:this={container} class="group relative h-full w-full overflow-hidden rounded-lg border">
533
- {#if !hideMarkers && !disabled}
534
- {@render TopLeftActions()}
535
- {@render ZoomControls()}
536
- {/if}
525
+ {#if !hideMarkers && !disabled}
526
+ {@render TopLeftActions()}
527
+ {@render ZoomControls()}
528
+ {/if}
537
529
 
538
- <!--
530
+ <!--
539
531
  We need to use SVG for this interactive component.
540
532
  The SVG element is treated as a canvas for clicking, panning, and zooming.
541
533
  We add accessibility attributes to make it more accessible despite the interactive nature.
542
534
  -->
543
- <!-- svelte-ignore a11y_click_events_have_key_events -->
544
- <!-- svelte-ignore a11y_no_static_element_interactions -->
545
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
546
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
547
- <svg
548
- bind:this={svgElement}
549
- onclick={handleClick}
550
- onmousedown={handleMouseDown}
551
- onmousemove={handleMouseMove}
552
- onmouseup={handleMouseUp}
553
- onmouseleave={handleMouseLeave}
554
- oncontextmenu={handleContextMenu}
555
- onwheel={handleWheel}
556
- class:cursor-grabbing={panningState === 'active'}
557
- class:cursor-grab={!disabled && (panningState === 'ready' || (isShiftPressed && !panningState))}
558
- class:cursor-not-allowed={disabled}
559
- class:cursor-crosshair={!disabled && !panningState && !isShiftPressed}
560
- class="h-full w-full"
561
- role="application"
562
- aria-label={disabled
563
- ? 'CFU Counter (disabled)'
564
- : 'CFU Counter - Click to add markers, right click or shift+click to pan'}
565
- tabindex="0"
566
- >
567
- <g bind:this={viewport} id="viewport" class="h-full w-full">
568
- <image href={imageUrl} x="0" y="0" width="100%" />
569
- <g
570
- bind:this={dotsGroup}
571
- id="dots"
572
- class="pointer-events-none"
573
- aria-hidden={hideMarkers}
574
- class:hidden={hideMarkers}
575
- />
576
- </g>
577
- </svg>
578
-
579
- <!-- Debug info for marks count - useful for testing -->
580
- <span class="sr-only" data-testid="marks-count">{marks.length}</span>
535
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
536
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
537
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
538
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
539
+ <svg
540
+ bind:this={svgElement}
541
+ onclick={handleClick}
542
+ onmousedown={handleMouseDown}
543
+ onmousemove={handleMouseMove}
544
+ onmouseup={handleMouseUp}
545
+ onmouseleave={handleMouseLeave}
546
+ oncontextmenu={handleContextMenu}
547
+ onwheel={handleWheel}
548
+ class:cursor-grabbing={panningState === 'active'}
549
+ class:cursor-grab={!disabled && (panningState === 'ready' || (isShiftPressed && !panningState))}
550
+ class:cursor-not-allowed={disabled}
551
+ class:cursor-crosshair={!disabled && !panningState && !isShiftPressed}
552
+ class="h-full w-full"
553
+ role="application"
554
+ aria-label={disabled
555
+ ? 'CFU Counter (disabled)'
556
+ : 'CFU Counter - Click to add markers, right click or shift+click to pan'}
557
+ tabindex="0"
558
+ >
559
+ <g bind:this={viewport} id="viewport" class="h-full w-full">
560
+ <image href={imageUrl} x="0" y="0" width="100%" />
561
+ <g
562
+ bind:this={dotsGroup}
563
+ id="dots"
564
+ class="pointer-events-none"
565
+ aria-hidden={hideMarkers}
566
+ class:hidden={hideMarkers}
567
+ />
568
+ </g>
569
+ </svg>
570
+
571
+ <!-- Debug info for marks count - useful for testing -->
572
+ <span class="sr-only" data-testid="marks-count">{marks.length}</span>
581
573
  </div>