@reshape-biotech/design-system 1.2.6 → 2.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.
Files changed (187) hide show
  1. package/README.md +5 -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/collapsible/index.d.ts +1 -1
  19. package/dist/components/combobox/Combobox.stories.svelte +412 -412
  20. package/dist/components/combobox/components/combobox-add.svelte +8 -8
  21. package/dist/components/combobox/components/combobox-content.svelte +39 -39
  22. package/dist/components/combobox/components/combobox-indicator.svelte +1 -1
  23. package/dist/components/combobox/index.d.ts +1 -1
  24. package/dist/components/datepicker/DatePicker.stories.svelte +196 -196
  25. package/dist/components/datepicker/DatePicker.svelte +173 -173
  26. package/dist/components/divider/Divider.stories.svelte +7 -7
  27. package/dist/components/divider/Divider.svelte +9 -9
  28. package/dist/components/drawer/Drawer.stories.svelte +51 -51
  29. package/dist/components/drawer/Drawer.svelte +33 -33
  30. package/dist/components/drawer/DrawerLabel.svelte +10 -10
  31. package/dist/components/dropdown/Dropdown.stories.svelte +210 -210
  32. package/dist/components/dropdown/Dropdown.svelte +57 -57
  33. package/dist/components/dropdown/components/DropdownContent.svelte +16 -16
  34. package/dist/components/dropdown/components/DropdownMenu.svelte +10 -10
  35. package/dist/components/dropdown/components/DropdownTrigger.svelte +37 -37
  36. package/dist/components/dropdown/components/OutlinedButton.svelte +9 -9
  37. package/dist/components/empty-content/EmptyContent.stories.svelte +38 -38
  38. package/dist/components/empty-content/EmptyContent.svelte +12 -12
  39. package/dist/components/graphs/bar-chart/BarChart.stories.svelte +91 -91
  40. package/dist/components/graphs/bar-chart/BarChart.svelte +147 -147
  41. package/dist/components/graphs/bar-chart/StackedBarChart.stories.svelte +57 -57
  42. package/dist/components/graphs/bar-chart/StackedBarChart.svelte +198 -199
  43. package/dist/components/graphs/chart/Chart.stories.svelte +96 -96
  44. package/dist/components/graphs/chart/Chart.svelte +207 -207
  45. package/dist/components/graphs/line/LineChart.stories.svelte +138 -138
  46. package/dist/components/graphs/line/LineChart.svelte +140 -142
  47. package/dist/components/graphs/matrix/Matrix.stories.svelte +117 -117
  48. package/dist/components/graphs/matrix/Matrix.svelte +141 -141
  49. package/dist/components/graphs/multiline/MultiLineChart.stories.svelte +168 -168
  50. package/dist/components/graphs/multiline/MultiLineChart.svelte +236 -236
  51. package/dist/components/graphs/scatterplot/Scatterplot.stories.svelte +84 -84
  52. package/dist/components/graphs/scatterplot/Scatterplot.svelte +302 -302
  53. package/dist/components/graphs/utils/duration.d.ts +1 -0
  54. package/dist/components/graphs/utils/duration.js +33 -0
  55. package/dist/components/graphs/utils/tooltipFormatter.js +1 -1
  56. package/dist/components/icon-button/IconButton.stories.svelte +64 -64
  57. package/dist/components/icon-button/IconButton.svelte +88 -88
  58. package/dist/components/icons/AnalysisIcon.stories.svelte +18 -18
  59. package/dist/components/icons/AnalysisIcon.svelte +96 -96
  60. package/dist/components/icons/Icon.stories.svelte +111 -111
  61. package/dist/components/icons/Icon.svelte +17 -17
  62. package/dist/components/icons/PrincipalIcon.svelte +59 -59
  63. package/dist/components/icons/custom/Halo.svelte +31 -31
  64. package/dist/components/icons/custom/Well.svelte +27 -27
  65. package/dist/components/icons/index.js +1 -1
  66. package/dist/components/image/Image.svelte +42 -42
  67. package/dist/components/input/Input.stories.svelte +55 -55
  68. package/dist/components/input/Input.svelte +121 -121
  69. package/dist/components/label/Label.stories.svelte +18 -18
  70. package/dist/components/label/Label.svelte +11 -11
  71. package/dist/components/list/List.stories.svelte +84 -84
  72. package/dist/components/list/List.svelte +20 -20
  73. package/dist/components/logo/Logo.stories.svelte +15 -15
  74. package/dist/components/logo/Logo.svelte +30 -30
  75. package/dist/components/manual-cfu-counter/ManualCFUCounter.stories.svelte +102 -102
  76. package/dist/components/manual-cfu-counter/ManualCFUCounter.svelte +557 -557
  77. package/dist/components/manual-cfu-counter/test/ManualCFUCounterTestWrapper.svelte +11 -11
  78. package/dist/components/markdown/Markdown.stories.svelte +10 -10
  79. package/dist/components/markdown/Markdown.svelte +6 -6
  80. package/dist/components/modal/Modal.stories.svelte +29 -29
  81. package/dist/components/modal/Modal.svelte +71 -71
  82. package/dist/components/multi-cfu-counter/MultiCFUCounter.stories.svelte +201 -201
  83. package/dist/components/multi-cfu-counter/MultiCFUCounter.svelte +606 -606
  84. package/dist/components/multi-cfu-counter/test/MultiCFUCounterTestWrapper.svelte +17 -17
  85. package/dist/components/notification-popup/NotificationPopup.stories.svelte +18 -18
  86. package/dist/components/notification-popup/NotificationPopup.svelte +26 -26
  87. package/dist/components/notifications/Notifications.stories.svelte +101 -101
  88. package/dist/components/notifications/Notifications.svelte +9 -9
  89. package/dist/components/pill/Pill.stories.svelte +8 -8
  90. package/dist/components/pill/Pill.svelte +27 -27
  91. package/dist/components/progress-circle/ProgressCircle.stories.svelte +8 -8
  92. package/dist/components/progress-circle/ProgressCircle.svelte +54 -54
  93. package/dist/components/required-status-indicator/RequiredStatusIndicator.stories.svelte +18 -18
  94. package/dist/components/required-status-indicator/RequiredStatusIndicator.svelte +14 -14
  95. package/dist/components/segmented-control-buttons/ControlButton.svelte +36 -36
  96. package/dist/components/segmented-control-buttons/SegmentedControlButtons.stories.svelte +35 -35
  97. package/dist/components/segmented-control-buttons/SegmentedControlButtons.svelte +13 -13
  98. package/dist/components/select/Select.stories.svelte +200 -94
  99. package/dist/components/select/Select.stories.svelte.d.ts +1 -1
  100. package/dist/components/select/components/Group.svelte +24 -0
  101. package/dist/components/select/components/MultiSelectTrigger.svelte +66 -0
  102. package/dist/components/select/components/SelectContent.svelte +33 -0
  103. package/dist/components/select/components/SelectGroupHeading.svelte +19 -0
  104. package/dist/components/select/components/SelectItem.svelte +39 -0
  105. package/dist/components/select/components/SelectTrigger.svelte +48 -0
  106. package/dist/components/select/index.d.ts +10 -7
  107. package/dist/components/select/index.js +12 -1
  108. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte +102 -87
  109. package/dist/components/sjsf-wrappers/SjsfNumberInputWrapper.svelte.d.ts +1 -1
  110. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte +53 -53
  111. package/dist/components/sjsf-wrappers/SjsfTextInputWrapper.svelte.d.ts +1 -1
  112. package/dist/components/sjsf-wrappers/sjsfCustomTheme.js +1 -1
  113. package/dist/components/skeleton-loader/SkeletonLoader.stories.svelte +32 -32
  114. package/dist/components/skeleton-loader/SkeletonLoader.svelte +10 -10
  115. package/dist/components/skeleton-loader/StatcardSkeleton.svelte +9 -9
  116. package/dist/components/skeleton-loader/components/Skeleton.svelte +7 -7
  117. package/dist/components/skeleton-loader/components/SkeletonImage.svelte +12 -12
  118. package/dist/components/slider/Slider.stories.svelte +23 -23
  119. package/dist/components/slider/Slider.svelte +107 -107
  120. package/dist/components/spinner/Spinner.stories.svelte +8 -8
  121. package/dist/components/spinner/Spinner.svelte +18 -18
  122. package/dist/components/stat-card/StatCard.stories.svelte +26 -26
  123. package/dist/components/stat-card/StatCard.svelte +128 -128
  124. package/dist/components/status-badge/StatusBadge.stories.svelte +365 -365
  125. package/dist/components/status-badge/StatusBadge.svelte +54 -54
  126. package/dist/components/stepper/Stepper.stories.svelte +219 -219
  127. package/dist/components/stepper/components/stepper-root.svelte +12 -12
  128. package/dist/components/stepper/components/stepper-step.svelte +83 -83
  129. package/dist/components/table/Table.stories.svelte +87 -87
  130. package/dist/components/table/Table.svelte +32 -32
  131. package/dist/components/table/components/TBody.svelte +7 -7
  132. package/dist/components/table/components/THead.svelte +7 -7
  133. package/dist/components/table/components/Td.svelte +8 -8
  134. package/dist/components/table/components/Th.svelte +8 -8
  135. package/dist/components/table/components/Tr.svelte +11 -11
  136. package/dist/components/tabs/Tabs.stories.svelte +20 -20
  137. package/dist/components/tabs/Tabs.svelte +8 -8
  138. package/dist/components/tabs/components/Content.svelte +8 -8
  139. package/dist/components/tabs/components/Tab.svelte +14 -14
  140. package/dist/components/tabs/components/Tabs.svelte +7 -7
  141. package/dist/components/tag/Tag.stories.svelte +57 -57
  142. package/dist/components/tag/Tag.svelte +95 -95
  143. package/dist/components/textarea/Textarea.stories.svelte +70 -70
  144. package/dist/components/textarea/Textarea.svelte +76 -76
  145. package/dist/components/toast/Toast.stories.svelte +204 -204
  146. package/dist/components/toast/Toast.svelte +53 -53
  147. package/dist/components/toggle/Toggle.stories.svelte +9 -9
  148. package/dist/components/toggle/Toggle.svelte +53 -53
  149. package/dist/components/toggle-icon-button/ToggleIconButton.stories.svelte +152 -152
  150. package/dist/components/toggle-icon-button/ToggleIconButton.svelte +99 -99
  151. package/dist/components/tooltip/Tooltip.stories.svelte +85 -111
  152. package/dist/components/tooltip/Tooltip.svelte +57 -46
  153. package/dist/components/tooltip/Tooltip.svelte.d.ts +1 -1
  154. package/dist/components/tooltip/TooltipTest.svelte +31 -0
  155. package/dist/components/tooltip/TooltipTest.svelte.d.ts +11 -0
  156. package/dist/fonts/index.js +1 -1
  157. package/dist/index.d.ts +0 -1
  158. package/dist/index.js +0 -1
  159. package/dist/notifications.d.ts +1 -4
  160. package/dist/notifications.js +1 -1
  161. package/dist/tailwind-safelist.js +406 -406
  162. package/dist/tailwind.preset.js +10 -10
  163. package/dist/tokens/colors.js +18 -18
  164. package/dist/tokens/typography.js +6 -6
  165. package/dist/tokens.js +19 -19
  166. package/dist/types/fonts.d.ts +2 -2
  167. package/package.json +199 -204
  168. package/dist/components/select/Select.svelte +0 -139
  169. package/dist/components/select/Select.svelte.d.ts +0 -60
  170. package/dist/components/select-new/Select.stories.svelte +0 -219
  171. package/dist/components/select-new/Select.stories.svelte.d.ts +0 -19
  172. package/dist/components/select-new/components/Group.svelte +0 -24
  173. package/dist/components/select-new/components/MultiSelectTrigger.svelte +0 -66
  174. package/dist/components/select-new/components/SelectContent.svelte +0 -33
  175. package/dist/components/select-new/components/SelectGroupHeading.svelte +0 -19
  176. package/dist/components/select-new/components/SelectItem.svelte +0 -39
  177. package/dist/components/select-new/components/SelectTrigger.svelte +0 -48
  178. package/dist/components/select-new/index.d.ts +0 -10
  179. package/dist/components/select-new/index.js +0 -12
  180. /package/dist/components/{select-new → select}/components/Group.svelte.d.ts +0 -0
  181. /package/dist/components/{select-new → select}/components/MultiSelectTrigger.svelte.d.ts +0 -0
  182. /package/dist/components/{select-new → select}/components/SelectContent.svelte.d.ts +0 -0
  183. /package/dist/components/{select-new → select}/components/SelectGroupHeading.svelte.d.ts +0 -0
  184. /package/dist/components/{select-new → select}/components/SelectItem.svelte.d.ts +0 -0
  185. /package/dist/components/{select-new → select}/components/SelectTrigger.svelte.d.ts +0 -0
  186. /package/dist/components/{select-new → select}/types.d.ts +0 -0
  187. /package/dist/components/{select-new → select}/types.js +0 -0
@@ -1,573 +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 = 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
- });
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
+ });
468
468
  </script>
469
469
 
470
470
  {#snippet TopLeftActions()}
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}
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}
497
497
  {/snippet}
498
498
 
499
499
  {#snippet ZoomControls()}
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>
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>
522
522
  {/snippet}
523
523
 
524
524
  <div bind:this={container} class="group relative h-full w-full overflow-hidden rounded-lg border">
525
- {#if !hideMarkers && !disabled}
526
- {@render TopLeftActions()}
527
- {@render ZoomControls()}
528
- {/if}
525
+ {#if !hideMarkers && !disabled}
526
+ {@render TopLeftActions()}
527
+ {@render ZoomControls()}
528
+ {/if}
529
529
 
530
- <!--
530
+ <!--
531
531
  We need to use SVG for this interactive component.
532
532
  The SVG element is treated as a canvas for clicking, panning, and zooming.
533
533
  We add accessibility attributes to make it more accessible despite the interactive nature.
534
534
  -->
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>
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>
573
573
  </div>