@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,662 +1,662 @@
1
1
  <script lang="ts">
2
- import { onMount } from 'svelte';
3
- import { Button } from '../button';
4
- import { textColor } from '../../tokens';
5
- import { Icon, type IconColor } from '../icons';
6
- import IconButton from '../icon-button/IconButton.svelte';
7
- import Divider from '../divider/Divider.svelte';
8
-
9
- const BASE_IMAGE_SIZE = 464;
10
- const BASE_MARKER_SIZE = 8;
11
- const BASE_MARKER_FONT_SIZE = 8;
12
- const MAX_ZOOM = 10;
13
- const MIN_ZOOM = 1;
14
- const ZOOM_STEP = 0.001;
15
- const MARKER_COLOR = textColor['icon-blue'];
16
- const TEXT_COLOR = textColor['primary-inverse'];
17
- const DRAG_THRESHOLD = 5;
18
-
19
- function getMarkerColorHex(semanticColor: string): string {
20
- switch (semanticColor) {
21
- case 'icon-blue':
22
- return textColor['icon-blue'];
23
- case 'icon-orange':
24
- return textColor['icon-orange'];
25
- case 'icon-pink':
26
- return textColor['icon-pink'];
27
- case 'icon-lime':
28
- return textColor['icon-lime'];
29
- default:
30
- return textColor['icon-blue'];
31
- }
32
- }
33
-
34
- interface ConfiguratedMark {
35
- x: number;
36
- y: number;
37
- configIndex: number;
38
- color: string;
39
- id?: string;
40
- }
41
-
42
- interface Props {
43
- imageUrl: string;
44
- marks?: Array<{ x: number; y: number }>;
45
- onclick?: (event: MouseEvent, marks: Array<{ x: number; y: number }>) => void;
46
- onMarksChange?: (marks: Array<{ x: number; y: number }>) => void;
47
- disabled?: boolean;
48
- hideMarkers?: boolean;
49
- allMarks?: ConfiguratedMark[];
50
- activeMarkerColor?: string;
51
- activeMarkerName?: string;
52
- editableConfigIndex?: number | null;
53
- showMultiConfig?: boolean;
54
- }
55
-
56
- let {
57
- imageUrl,
58
- onclick,
59
- onMarksChange,
60
- disabled = false,
61
- hideMarkers = false,
62
- marks = $bindable([]),
63
- allMarks = [],
64
- activeMarkerColor = MARKER_COLOR,
65
- activeMarkerName = '',
66
- editableConfigIndex = null,
67
- showMultiConfig = false
68
- }: Props = $props();
69
-
70
- let previousConfigIndex = $state<number | null>(null);
71
-
72
- let resolvedActiveMarkerColor = $derived(() => {
73
- if (!activeMarkerColor) return MARKER_COLOR;
74
-
75
- // If it starts with #, it's already a hex color
76
- if (activeMarkerColor.startsWith('#')) {
77
- return activeMarkerColor;
78
- }
79
-
80
- return getMarkerColorHex(activeMarkerColor);
81
- });
82
-
83
- let svgElement: SVGSVGElement;
84
- let viewport: SVGGraphicsElement;
85
- let dotsGroup: SVGElement;
86
- let container: HTMLDivElement;
87
-
88
- let containerW = BASE_IMAGE_SIZE;
89
- let containerH = BASE_IMAGE_SIZE;
90
-
91
- let transform = $state({ x: 0, y: 0, scale: 1 });
92
-
93
- let panningState = $state<null | 'ready' | 'active'>(null);
94
- let startPoint = $state({ x: 0, y: 0 });
95
- let isShiftPressed = $state(false);
96
- let resizeObserver: ResizeObserver;
97
-
98
- let imageAspectRatio = $state(1);
99
- let imageDisplayWidth = $state(0);
100
- let imageDisplayHeight = $state(0);
101
-
102
- function handleResize(entries: ResizeObserverEntry[]) {
103
- const entry = entries[0];
104
- if (entry) {
105
- const oldWidth = imageDisplayWidth || containerW;
106
- const oldHeight = imageDisplayHeight || containerH;
107
-
108
- containerW = entry.contentRect.width;
109
- containerH = entry.contentRect.height;
110
-
111
- if (oldWidth > 0 && oldHeight > 0 && marks.length > 0) {
112
- marks = marks.map((mark) => ({
113
- x: (mark.x / oldWidth) * containerW,
114
- y: (mark.y / oldHeight) * containerH
115
- }));
116
- }
117
-
118
- updateImageDimensions();
119
- }
120
- }
121
-
122
- function onImageLoad() {
123
- if (container) {
124
- containerW = container.clientWidth;
125
- containerH = container.clientHeight;
126
-
127
- const img = new Image();
128
- img.src = imageUrl;
129
- img.onload = () => {
130
- imageAspectRatio = img.naturalWidth / img.naturalHeight;
131
- updateImageDimensions();
132
- };
133
-
134
- if (img.complete && img.naturalWidth) {
135
- imageAspectRatio = img.naturalWidth / img.naturalHeight;
136
- updateImageDimensions();
137
- }
138
- }
139
- }
140
-
141
- function updateImageDimensions() {
142
- const containerRatio = containerW / containerH;
143
-
144
- if (containerRatio > imageAspectRatio) {
145
- imageDisplayHeight = containerH;
146
- imageDisplayWidth = containerH * imageAspectRatio;
147
- } else {
148
- imageDisplayWidth = containerW;
149
- imageDisplayHeight = containerW / imageAspectRatio;
150
- }
151
-
152
- updateTransform();
153
- }
154
-
155
- function clampTransform() {
156
- const maxX = 0;
157
- const maxY = 0;
158
- // Prevent division by zero or unexpected behavior if container dimensions aren't ready
159
- const safeContainerW = containerW || 1;
160
- const safeContainerH = containerH || 1;
161
- const minX = safeContainerW * (1 - transform.scale);
162
- const minY = safeContainerH * (1 - transform.scale);
163
-
164
- if (transform.scale <= 1) {
165
- transform.scale = 1;
166
- transform.x = 0;
167
- transform.y = 0;
168
- } else {
169
- transform.x = Math.max(minX, Math.min(maxX, transform.x));
170
- transform.y = Math.max(minY, Math.min(maxY, transform.y));
171
- }
172
- }
173
-
174
- function updateTransform() {
175
- if (!viewport) return;
176
-
177
- viewport.setAttribute(
178
- 'transform',
179
- `translate(${transform.x}, ${transform.y}) scale(${transform.scale})`
180
- );
181
-
182
- const imageElement = viewport.querySelector('image');
183
- if (imageElement) {
184
- imageElement.setAttribute('width', String(imageDisplayWidth));
185
- imageElement.setAttribute('height', String(imageDisplayHeight));
186
-
187
- // center the image in the container
188
- const offsetX = (containerW - imageDisplayWidth) / 2;
189
- const offsetY = (containerH - imageDisplayHeight) / 2;
190
-
191
- imageElement.setAttribute('x', String(offsetX));
192
- imageElement.setAttribute('y', String(offsetY));
193
- }
194
-
195
- renderMarkers();
196
- }
197
-
198
- function getSvgPoint(event: MouseEvent) {
199
- if (!svgElement || !container) return { x: 0, y: 0 };
200
-
201
- const rect = container.getBoundingClientRect();
202
-
203
- const relativeX = event.clientX - rect.left;
204
- const relativeY = event.clientY - rect.top;
205
-
206
- const svgX = (relativeX - transform.x) / transform.scale;
207
- const svgY = (relativeY - transform.y) / transform.scale;
208
-
209
- const offsetX = (containerW - imageDisplayWidth) / 2;
210
- const offsetY = (containerH - imageDisplayHeight) / 2;
211
-
212
- return {
213
- x: svgX - offsetX,
214
- y: svgY - offsetY
215
- };
216
- }
217
-
218
- function renderMarkers() {
219
- if (!dotsGroup) return;
220
-
221
- while (dotsGroup.firstChild) {
222
- dotsGroup.removeChild(dotsGroup.firstChild);
223
- }
224
-
225
- const offsetX = (containerW - imageDisplayWidth) / 2;
226
- const offsetY = (containerH - imageDisplayHeight) / 2;
227
-
228
- const svgns = 'http://www.w3.org/2000/svg';
229
- const adjustedMarkerSize = BASE_MARKER_SIZE / transform.scale;
230
- const adjustedFontSize = BASE_MARKER_FONT_SIZE / transform.scale;
231
-
232
- if (showMultiConfig && allMarks && allMarks.length > 0) {
233
- // Group marks by configuration to number them separately
234
- const marksByConfig = new Map<number, ConfiguratedMark[]>();
235
- allMarks.forEach((mark) => {
236
- if (!marksByConfig.has(mark.configIndex)) {
237
- marksByConfig.set(mark.configIndex, []);
238
- }
239
- marksByConfig.get(mark.configIndex)!.push(mark);
240
- });
241
-
242
- // Render multi-configuration marks with per-configuration numbering
243
- marksByConfig.forEach((configMarks, configIndex) => {
244
- configMarks.forEach((mark, configMarkIndex) => {
245
- const group = document.createElementNS(svgns, 'g');
246
- group.setAttribute(
247
- 'data-testid',
248
- `config-marker-${mark.configIndex}-${configMarkIndex + 1}`
249
- );
250
-
251
- const markX = mark.x + offsetX;
252
- const markY = mark.y + offsetY;
253
-
254
- // Resolve mark color - supports both hex colors and semantic color names
255
- const resolvedMarkColor = mark.color.startsWith('#')
256
- ? mark.color
257
- : getMarkerColorHex(mark.color);
258
-
259
- const circle = document.createElementNS(svgns, 'circle');
260
- circle.setAttribute('cx', String(markX));
261
- circle.setAttribute('cy', String(markY));
262
- circle.setAttribute('r', String(adjustedMarkerSize));
263
- circle.setAttribute('fill', resolvedMarkColor);
264
- circle.setAttribute('class', 'drop-shadow-sm');
265
- group.appendChild(circle);
266
-
267
- const text = document.createElementNS(svgns, 'text');
268
- text.setAttribute('x', String(markX));
269
- text.setAttribute('y', String(markY));
270
- text.setAttribute('text-anchor', 'middle');
271
- text.setAttribute('dominant-baseline', 'central');
272
- text.setAttribute('fill', TEXT_COLOR);
273
- text.setAttribute('font-size', String(adjustedFontSize));
274
- text.setAttribute('font-weight', 'bold');
275
- text.textContent = String(configMarkIndex + 1); // Number per configuration
276
- group.appendChild(text);
277
-
278
- dotsGroup.appendChild(group);
279
- });
280
- });
281
- } else {
282
- // Render single-configuration marks (existing behavior)
283
- marks.forEach((mark, index) => {
284
- const group = document.createElementNS(svgns, 'g');
285
- group.setAttribute('data-testid', `marker-${index + 1}`);
286
-
287
- const markX = mark.x + offsetX;
288
- const markY = mark.y + offsetY;
289
-
290
- const circle = document.createElementNS(svgns, 'circle');
291
- circle.setAttribute('cx', String(markX));
292
- circle.setAttribute('cy', String(markY));
293
- circle.setAttribute('r', String(adjustedMarkerSize));
294
- circle.setAttribute('fill', resolvedActiveMarkerColor());
295
- circle.setAttribute('class', 'drop-shadow-sm');
296
- group.appendChild(circle);
297
-
298
- const text = document.createElementNS(svgns, 'text');
299
- text.setAttribute('x', String(markX));
300
- text.setAttribute('y', String(markY));
301
- text.setAttribute('text-anchor', 'middle');
302
- text.setAttribute('dominant-baseline', 'central');
303
- text.setAttribute('fill', TEXT_COLOR);
304
- text.setAttribute('font-size', String(adjustedFontSize));
305
- text.setAttribute('font-weight', 'bold');
306
- text.textContent = String(index + 1);
307
- group.appendChild(text);
308
-
309
- dotsGroup.appendChild(group);
310
- });
311
- }
312
- }
313
-
314
- function handleClick(event: MouseEvent) {
315
- if (disabled || hideMarkers || !dotsGroup || isShiftPressed || panningState !== null) {
316
- panningState = null;
317
- return;
318
- }
319
-
320
- // In multi-config mode, only allow editing if there's an active editable configuration
321
- if (showMultiConfig && editableConfigIndex === null) {
322
- return;
323
- }
324
-
325
- const pt = getSvgPoint(event);
326
- marks.push({ x: pt.x, y: pt.y });
327
-
328
- renderMarkers();
329
-
330
- if (onclick) {
331
- onclick(event, marks);
332
- }
333
-
334
- onMarksChange?.(marks);
335
- }
336
-
337
- function handleKeyDown(event: KeyboardEvent) {
338
- if (event.shiftKey) {
339
- isShiftPressed = true;
340
- }
341
- }
342
-
343
- function handleKeyUp(event: KeyboardEvent) {
344
- let shouldResetReadyState = false;
345
-
346
- if (!event.shiftKey && isShiftPressed) {
347
- isShiftPressed = false;
348
- shouldResetReadyState = true;
349
- }
350
-
351
- if (shouldResetReadyState && panningState === 'ready') {
352
- panningState = null;
353
- }
354
- }
355
-
356
- function handleMouseDown(event: MouseEvent) {
357
- const isPanningTrigger = event.button === 2 || (event.button === 0 && isShiftPressed);
358
- if (!isPanningTrigger) return;
359
-
360
- event.preventDefault();
361
- panningState = 'ready';
362
- startPoint = { x: event.clientX, y: event.clientY };
363
- }
364
-
365
- function handleMouseMove(event: MouseEvent) {
366
- if (panningState === null) return;
367
-
368
- const dx = event.clientX - startPoint.x;
369
- const dy = event.clientY - startPoint.y;
370
-
371
- if (panningState === 'ready' && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
372
- panningState = 'active';
373
- }
374
-
375
- if (panningState === 'active') {
376
- startPoint = { x: event.clientX, y: event.clientY };
377
-
378
- transform.x += dx;
379
- transform.y += dy;
380
-
381
- clampTransform();
382
- updateTransform();
383
- }
384
- }
2
+ import { onMount } from 'svelte';
3
+ import { Button } from '../button';
4
+ import { textColor } from '../../tokens';
5
+ import { Icon, type IconColor } from '../icons';
6
+ import IconButton from '../icon-button/IconButton.svelte';
7
+ import Divider from '../divider/Divider.svelte';
8
+
9
+ const BASE_IMAGE_SIZE = 464;
10
+ const BASE_MARKER_SIZE = 8;
11
+ const BASE_MARKER_FONT_SIZE = 8;
12
+ const MAX_ZOOM = 10;
13
+ const MIN_ZOOM = 1;
14
+ const ZOOM_STEP = 0.001;
15
+ const MARKER_COLOR = textColor['icon-blue'];
16
+ const TEXT_COLOR = textColor['primary-inverse'];
17
+ const DRAG_THRESHOLD = 5;
18
+
19
+ function getMarkerColorHex(semanticColor: string): string {
20
+ switch (semanticColor) {
21
+ case 'icon-blue':
22
+ return textColor['icon-blue'];
23
+ case 'icon-orange':
24
+ return textColor['icon-orange'];
25
+ case 'icon-pink':
26
+ return textColor['icon-pink'];
27
+ case 'icon-lime':
28
+ return textColor['icon-lime'];
29
+ default:
30
+ return textColor['icon-blue'];
31
+ }
32
+ }
33
+
34
+ interface ConfiguratedMark {
35
+ x: number;
36
+ y: number;
37
+ configIndex: number;
38
+ color: string;
39
+ id?: string;
40
+ }
41
+
42
+ interface Props {
43
+ imageUrl: string;
44
+ marks?: Array<{ x: number; y: number }>;
45
+ onclick?: (event: MouseEvent, marks: Array<{ x: number; y: number }>) => void;
46
+ onMarksChange?: (marks: Array<{ x: number; y: number }>) => void;
47
+ disabled?: boolean;
48
+ hideMarkers?: boolean;
49
+ allMarks?: ConfiguratedMark[];
50
+ activeMarkerColor?: string;
51
+ activeMarkerName?: string;
52
+ editableConfigIndex?: number | null;
53
+ showMultiConfig?: boolean;
54
+ }
55
+
56
+ let {
57
+ imageUrl,
58
+ onclick,
59
+ onMarksChange,
60
+ disabled = false,
61
+ hideMarkers = false,
62
+ marks = $bindable([]),
63
+ allMarks = [],
64
+ activeMarkerColor = MARKER_COLOR,
65
+ activeMarkerName = '',
66
+ editableConfigIndex = null,
67
+ showMultiConfig = false,
68
+ }: Props = $props();
69
+
70
+ let previousConfigIndex = $state<number | null>(null);
71
+
72
+ let resolvedActiveMarkerColor = $derived(() => {
73
+ if (!activeMarkerColor) return MARKER_COLOR;
74
+
75
+ // If it starts with #, it's already a hex color
76
+ if (activeMarkerColor.startsWith('#')) {
77
+ return activeMarkerColor;
78
+ }
79
+
80
+ return getMarkerColorHex(activeMarkerColor);
81
+ });
82
+
83
+ let svgElement: SVGSVGElement;
84
+ let viewport: SVGGraphicsElement;
85
+ let dotsGroup: SVGElement;
86
+ let container: HTMLDivElement;
87
+
88
+ let containerW = BASE_IMAGE_SIZE;
89
+ let containerH = BASE_IMAGE_SIZE;
90
+
91
+ let transform = $state({ x: 0, y: 0, scale: 1 });
92
+
93
+ let panningState = $state<null | 'ready' | 'active'>(null);
94
+ let startPoint = $state({ x: 0, y: 0 });
95
+ let isShiftPressed = $state(false);
96
+ let resizeObserver: ResizeObserver;
97
+
98
+ let imageAspectRatio = $state(1);
99
+ let imageDisplayWidth = $state(0);
100
+ let imageDisplayHeight = $state(0);
101
+
102
+ function handleResize(entries: ResizeObserverEntry[]) {
103
+ const entry = entries[0];
104
+ if (entry) {
105
+ const oldWidth = imageDisplayWidth || containerW;
106
+ const oldHeight = imageDisplayHeight || containerH;
107
+
108
+ containerW = entry.contentRect.width;
109
+ containerH = entry.contentRect.height;
110
+
111
+ if (oldWidth > 0 && oldHeight > 0 && marks.length > 0) {
112
+ marks = marks.map((mark) => ({
113
+ x: (mark.x / oldWidth) * containerW,
114
+ y: (mark.y / oldHeight) * containerH,
115
+ }));
116
+ }
117
+
118
+ updateImageDimensions();
119
+ }
120
+ }
121
+
122
+ function onImageLoad() {
123
+ if (container) {
124
+ containerW = container.clientWidth;
125
+ containerH = container.clientHeight;
126
+
127
+ const img = new Image();
128
+ img.src = imageUrl;
129
+ img.onload = () => {
130
+ imageAspectRatio = img.naturalWidth / img.naturalHeight;
131
+ updateImageDimensions();
132
+ };
133
+
134
+ if (img.complete && img.naturalWidth) {
135
+ imageAspectRatio = img.naturalWidth / img.naturalHeight;
136
+ updateImageDimensions();
137
+ }
138
+ }
139
+ }
140
+
141
+ function updateImageDimensions() {
142
+ const containerRatio = containerW / containerH;
143
+
144
+ if (containerRatio > imageAspectRatio) {
145
+ imageDisplayHeight = containerH;
146
+ imageDisplayWidth = containerH * imageAspectRatio;
147
+ } else {
148
+ imageDisplayWidth = containerW;
149
+ imageDisplayHeight = containerW / imageAspectRatio;
150
+ }
151
+
152
+ updateTransform();
153
+ }
154
+
155
+ function clampTransform() {
156
+ const maxX = 0;
157
+ const maxY = 0;
158
+ // Prevent division by zero or unexpected behavior if container dimensions aren't ready
159
+ const safeContainerW = containerW || 1;
160
+ const safeContainerH = containerH || 1;
161
+ const minX = safeContainerW * (1 - transform.scale);
162
+ const minY = safeContainerH * (1 - transform.scale);
163
+
164
+ if (transform.scale <= 1) {
165
+ transform.scale = 1;
166
+ transform.x = 0;
167
+ transform.y = 0;
168
+ } else {
169
+ transform.x = Math.max(minX, Math.min(maxX, transform.x));
170
+ transform.y = Math.max(minY, Math.min(maxY, transform.y));
171
+ }
172
+ }
173
+
174
+ function updateTransform() {
175
+ if (!viewport) return;
176
+
177
+ viewport.setAttribute(
178
+ 'transform',
179
+ `translate(${transform.x}, ${transform.y}) scale(${transform.scale})`
180
+ );
181
+
182
+ const imageElement = viewport.querySelector('image');
183
+ if (imageElement) {
184
+ imageElement.setAttribute('width', String(imageDisplayWidth));
185
+ imageElement.setAttribute('height', String(imageDisplayHeight));
186
+
187
+ // center the image in the container
188
+ const offsetX = (containerW - imageDisplayWidth) / 2;
189
+ const offsetY = (containerH - imageDisplayHeight) / 2;
190
+
191
+ imageElement.setAttribute('x', String(offsetX));
192
+ imageElement.setAttribute('y', String(offsetY));
193
+ }
194
+
195
+ renderMarkers();
196
+ }
197
+
198
+ function getSvgPoint(event: MouseEvent) {
199
+ if (!svgElement || !container) return { x: 0, y: 0 };
200
+
201
+ const rect = container.getBoundingClientRect();
202
+
203
+ const relativeX = event.clientX - rect.left;
204
+ const relativeY = event.clientY - rect.top;
205
+
206
+ const svgX = (relativeX - transform.x) / transform.scale;
207
+ const svgY = (relativeY - transform.y) / transform.scale;
208
+
209
+ const offsetX = (containerW - imageDisplayWidth) / 2;
210
+ const offsetY = (containerH - imageDisplayHeight) / 2;
211
+
212
+ return {
213
+ x: svgX - offsetX,
214
+ y: svgY - offsetY,
215
+ };
216
+ }
217
+
218
+ function renderMarkers() {
219
+ if (!dotsGroup) return;
220
+
221
+ while (dotsGroup.firstChild) {
222
+ dotsGroup.removeChild(dotsGroup.firstChild);
223
+ }
224
+
225
+ const offsetX = (containerW - imageDisplayWidth) / 2;
226
+ const offsetY = (containerH - imageDisplayHeight) / 2;
227
+
228
+ const svgns = 'http://www.w3.org/2000/svg';
229
+ const adjustedMarkerSize = BASE_MARKER_SIZE / transform.scale;
230
+ const adjustedFontSize = BASE_MARKER_FONT_SIZE / transform.scale;
231
+
232
+ if (showMultiConfig && allMarks && allMarks.length > 0) {
233
+ // Group marks by configuration to number them separately
234
+ const marksByConfig = new Map<number, ConfiguratedMark[]>();
235
+ allMarks.forEach((mark) => {
236
+ if (!marksByConfig.has(mark.configIndex)) {
237
+ marksByConfig.set(mark.configIndex, []);
238
+ }
239
+ marksByConfig.get(mark.configIndex)!.push(mark);
240
+ });
241
+
242
+ // Render multi-configuration marks with per-configuration numbering
243
+ marksByConfig.forEach((configMarks, configIndex) => {
244
+ configMarks.forEach((mark, configMarkIndex) => {
245
+ const group = document.createElementNS(svgns, 'g');
246
+ group.setAttribute(
247
+ 'data-testid',
248
+ `config-marker-${mark.configIndex}-${configMarkIndex + 1}`
249
+ );
250
+
251
+ const markX = mark.x + offsetX;
252
+ const markY = mark.y + offsetY;
253
+
254
+ // Resolve mark color - supports both hex colors and semantic color names
255
+ const resolvedMarkColor = mark.color.startsWith('#')
256
+ ? mark.color
257
+ : getMarkerColorHex(mark.color);
258
+
259
+ const circle = document.createElementNS(svgns, 'circle');
260
+ circle.setAttribute('cx', String(markX));
261
+ circle.setAttribute('cy', String(markY));
262
+ circle.setAttribute('r', String(adjustedMarkerSize));
263
+ circle.setAttribute('fill', resolvedMarkColor);
264
+ circle.setAttribute('class', 'drop-shadow-sm');
265
+ group.appendChild(circle);
266
+
267
+ const text = document.createElementNS(svgns, 'text');
268
+ text.setAttribute('x', String(markX));
269
+ text.setAttribute('y', String(markY));
270
+ text.setAttribute('text-anchor', 'middle');
271
+ text.setAttribute('dominant-baseline', 'central');
272
+ text.setAttribute('fill', TEXT_COLOR);
273
+ text.setAttribute('font-size', String(adjustedFontSize));
274
+ text.setAttribute('font-weight', 'bold');
275
+ text.textContent = String(configMarkIndex + 1); // Number per configuration
276
+ group.appendChild(text);
277
+
278
+ dotsGroup.appendChild(group);
279
+ });
280
+ });
281
+ } else {
282
+ // Render single-configuration marks (existing behavior)
283
+ marks.forEach((mark, index) => {
284
+ const group = document.createElementNS(svgns, 'g');
285
+ group.setAttribute('data-testid', `marker-${index + 1}`);
286
+
287
+ const markX = mark.x + offsetX;
288
+ const markY = mark.y + offsetY;
289
+
290
+ const circle = document.createElementNS(svgns, 'circle');
291
+ circle.setAttribute('cx', String(markX));
292
+ circle.setAttribute('cy', String(markY));
293
+ circle.setAttribute('r', String(adjustedMarkerSize));
294
+ circle.setAttribute('fill', resolvedActiveMarkerColor());
295
+ circle.setAttribute('class', 'drop-shadow-sm');
296
+ group.appendChild(circle);
297
+
298
+ const text = document.createElementNS(svgns, 'text');
299
+ text.setAttribute('x', String(markX));
300
+ text.setAttribute('y', String(markY));
301
+ text.setAttribute('text-anchor', 'middle');
302
+ text.setAttribute('dominant-baseline', 'central');
303
+ text.setAttribute('fill', TEXT_COLOR);
304
+ text.setAttribute('font-size', String(adjustedFontSize));
305
+ text.setAttribute('font-weight', 'bold');
306
+ text.textContent = String(index + 1);
307
+ group.appendChild(text);
308
+
309
+ dotsGroup.appendChild(group);
310
+ });
311
+ }
312
+ }
313
+
314
+ function handleClick(event: MouseEvent) {
315
+ if (disabled || hideMarkers || !dotsGroup || isShiftPressed || panningState !== null) {
316
+ panningState = null;
317
+ return;
318
+ }
319
+
320
+ // In multi-config mode, only allow editing if there's an active editable configuration
321
+ if (showMultiConfig && editableConfigIndex === null) {
322
+ return;
323
+ }
324
+
325
+ const pt = getSvgPoint(event);
326
+ marks.push({ x: pt.x, y: pt.y });
327
+
328
+ renderMarkers();
329
+
330
+ if (onclick) {
331
+ onclick(event, marks);
332
+ }
333
+
334
+ onMarksChange?.(marks);
335
+ }
336
+
337
+ function handleKeyDown(event: KeyboardEvent) {
338
+ if (event.shiftKey) {
339
+ isShiftPressed = true;
340
+ }
341
+ }
342
+
343
+ function handleKeyUp(event: KeyboardEvent) {
344
+ let shouldResetReadyState = false;
345
+
346
+ if (!event.shiftKey && isShiftPressed) {
347
+ isShiftPressed = false;
348
+ shouldResetReadyState = true;
349
+ }
350
+
351
+ if (shouldResetReadyState && panningState === 'ready') {
352
+ panningState = null;
353
+ }
354
+ }
355
+
356
+ function handleMouseDown(event: MouseEvent) {
357
+ const isPanningTrigger = event.button === 2 || (event.button === 0 && isShiftPressed);
358
+ if (!isPanningTrigger) return;
359
+
360
+ event.preventDefault();
361
+ panningState = 'ready';
362
+ startPoint = { x: event.clientX, y: event.clientY };
363
+ }
364
+
365
+ function handleMouseMove(event: MouseEvent) {
366
+ if (panningState === null) return;
367
+
368
+ const dx = event.clientX - startPoint.x;
369
+ const dy = event.clientY - startPoint.y;
370
+
371
+ if (panningState === 'ready' && Math.sqrt(dx * dx + dy * dy) > DRAG_THRESHOLD) {
372
+ panningState = 'active';
373
+ }
374
+
375
+ if (panningState === 'active') {
376
+ startPoint = { x: event.clientX, y: event.clientY };
377
+
378
+ transform.x += dx;
379
+ transform.y += dy;
380
+
381
+ clampTransform();
382
+ updateTransform();
383
+ }
384
+ }
385
385
 
386
- function handleMouseUp() {
387
- panningState = null;
388
- }
386
+ function handleMouseUp() {
387
+ panningState = null;
388
+ }
389
389
 
390
- function handleMouseLeave() {
391
- panningState = null;
392
- }
390
+ function handleMouseLeave() {
391
+ panningState = null;
392
+ }
393
393
 
394
- function handleContextMenu(event: MouseEvent) {
395
- event.preventDefault();
396
- }
394
+ function handleContextMenu(event: MouseEvent) {
395
+ event.preventDefault();
396
+ }
397
397
 
398
- function handleWheel(event: WheelEvent) {
399
- event.preventDefault();
398
+ function handleWheel(event: WheelEvent) {
399
+ event.preventDefault();
400
400
 
401
- if (!svgElement || !viewport || !container) return;
401
+ if (!svgElement || !viewport || !container) return;
402
402
 
403
- containerW = container.clientWidth;
404
- containerH = container.clientHeight;
403
+ containerW = container.clientWidth;
404
+ containerH = container.clientHeight;
405
405
 
406
- const zoomIntensity = ZOOM_STEP;
407
- const delta = event.deltaY;
408
- const zoomFactor = 1 - delta * zoomIntensity;
409
- const currentScale = transform.scale;
406
+ const zoomIntensity = ZOOM_STEP;
407
+ const delta = event.deltaY;
408
+ const zoomFactor = 1 - delta * zoomIntensity;
409
+ const currentScale = transform.scale;
410
410
 
411
- let potentialNewScale = currentScale * zoomFactor;
411
+ let potentialNewScale = currentScale * zoomFactor;
412
412
 
413
- let finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
413
+ let finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
414
414
 
415
- if (finalScale <= MIN_ZOOM) {
416
- transform.scale = MIN_ZOOM;
417
- transform.x = 0;
418
- transform.y = 0;
419
- } else {
420
- const rect = container.getBoundingClientRect();
421
- const mouseX = event.clientX - rect.left;
422
- const mouseY = event.clientY - rect.top;
415
+ if (finalScale <= MIN_ZOOM) {
416
+ transform.scale = MIN_ZOOM;
417
+ transform.x = 0;
418
+ transform.y = 0;
419
+ } else {
420
+ const rect = container.getBoundingClientRect();
421
+ const mouseX = event.clientX - rect.left;
422
+ const mouseY = event.clientY - rect.top;
423
423
 
424
- const distX = mouseX - transform.x;
425
- const distY = mouseY - transform.y;
424
+ const distX = mouseX - transform.x;
425
+ const distY = mouseY - transform.y;
426
426
 
427
- const scaleRatio = finalScale / currentScale;
428
- const newDistX = distX * scaleRatio;
429
- const newDistY = distY * scaleRatio;
427
+ const scaleRatio = finalScale / currentScale;
428
+ const newDistX = distX * scaleRatio;
429
+ const newDistY = distY * scaleRatio;
430
430
 
431
- transform.x = mouseX - newDistX;
432
- transform.y = mouseY - newDistY;
433
- transform.scale = finalScale;
434
- }
431
+ transform.x = mouseX - newDistX;
432
+ transform.y = mouseY - newDistY;
433
+ transform.scale = finalScale;
434
+ }
435
435
 
436
- clampTransform();
437
- updateTransform();
438
- }
436
+ clampTransform();
437
+ updateTransform();
438
+ }
439
439
 
440
- function undo() {
441
- if (marks.length === 0) return;
440
+ function undo() {
441
+ if (marks.length === 0) return;
442
442
 
443
- marks.pop();
444
- marks = marks;
445
- renderMarkers();
443
+ marks.pop();
444
+ marks = marks;
445
+ renderMarkers();
446
446
 
447
- onMarksChange?.(marks);
448
- }
447
+ onMarksChange?.(marks);
448
+ }
449
449
 
450
- function reset() {
451
- if (marks.length === 0) return;
450
+ function reset() {
451
+ if (marks.length === 0) return;
452
452
 
453
- marks = [];
454
- renderMarkers();
455
- onMarksChange?.(marks);
456
- }
453
+ marks = [];
454
+ renderMarkers();
455
+ onMarksChange?.(marks);
456
+ }
457
457
 
458
- function applyZoom(zoomFactor: number) {
459
- if (!container) return;
458
+ function applyZoom(zoomFactor: number) {
459
+ if (!container) return;
460
460
 
461
- const currentScale = transform.scale;
462
- const potentialNewScale = currentScale * zoomFactor;
463
- const finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
461
+ const currentScale = transform.scale;
462
+ const potentialNewScale = currentScale * zoomFactor;
463
+ const finalScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, potentialNewScale));
464
464
 
465
- if (finalScale === currentScale) return;
465
+ if (finalScale === currentScale) return;
466
466
 
467
- const centerX = containerW / 2;
468
- const centerY = containerH / 2;
467
+ const centerX = containerW / 2;
468
+ const centerY = containerH / 2;
469
469
 
470
- const distX = centerX - transform.x;
471
- const distY = centerY - transform.y;
470
+ const distX = centerX - transform.x;
471
+ const distY = centerY - transform.y;
472
472
 
473
- const scaleRatio = finalScale / currentScale;
474
- const newDistX = distX * scaleRatio;
475
- const newDistY = distY * scaleRatio;
473
+ const scaleRatio = finalScale / currentScale;
474
+ const newDistX = distX * scaleRatio;
475
+ const newDistY = distY * scaleRatio;
476
476
 
477
- transform.x = centerX - newDistX;
478
- transform.y = centerY - newDistY;
479
- transform.scale = finalScale;
477
+ transform.x = centerX - newDistX;
478
+ transform.y = centerY - newDistY;
479
+ transform.scale = finalScale;
480
480
 
481
- clampTransform();
482
- updateTransform();
483
- }
481
+ clampTransform();
482
+ updateTransform();
483
+ }
484
484
 
485
- function zoomIn() {
486
- applyZoom(1.2);
487
- }
485
+ function zoomIn() {
486
+ applyZoom(1.2);
487
+ }
488
488
 
489
- function zoomOut() {
490
- applyZoom(1 / 1.2);
491
- }
489
+ function zoomOut() {
490
+ applyZoom(1 / 1.2);
491
+ }
492
492
 
493
- $effect(() => {
494
- if (editableConfigIndex !== previousConfigIndex) {
495
- previousConfigIndex = editableConfigIndex;
496
- }
497
- });
493
+ $effect(() => {
494
+ if (editableConfigIndex !== previousConfigIndex) {
495
+ previousConfigIndex = editableConfigIndex;
496
+ }
497
+ });
498
498
 
499
- onMount(() => {
500
- if (container) {
501
- containerW = container.clientWidth;
502
- containerH = container.clientHeight;
499
+ onMount(() => {
500
+ if (container) {
501
+ containerW = container.clientWidth;
502
+ containerH = container.clientHeight;
503
503
 
504
- resizeObserver = new ResizeObserver(handleResize);
505
- resizeObserver.observe(container);
506
- }
504
+ resizeObserver = new ResizeObserver(handleResize);
505
+ resizeObserver.observe(container);
506
+ }
507
507
 
508
- window.addEventListener('keydown', handleKeyDown);
509
- window.addEventListener('keyup', handleKeyUp);
508
+ window.addEventListener('keydown', handleKeyDown);
509
+ window.addEventListener('keyup', handleKeyUp);
510
510
 
511
- updateTransform();
512
- renderMarkers();
513
-
514
- const img = new Image();
515
- img.onload = onImageLoad;
516
- img.src = imageUrl;
517
-
518
- if (img.complete) {
519
- onImageLoad();
520
- }
511
+ updateTransform();
512
+ renderMarkers();
513
+
514
+ const img = new Image();
515
+ img.onload = onImageLoad;
516
+ img.src = imageUrl;
517
+
518
+ if (img.complete) {
519
+ onImageLoad();
520
+ }
521
521
 
522
- return () => {
523
- window.removeEventListener('keydown', handleKeyDown);
524
- window.removeEventListener('keyup', handleKeyUp);
525
- if (resizeObserver) {
526
- resizeObserver.disconnect();
527
- }
528
- };
529
- });
522
+ return () => {
523
+ window.removeEventListener('keydown', handleKeyDown);
524
+ window.removeEventListener('keyup', handleKeyUp);
525
+ if (resizeObserver) {
526
+ resizeObserver.disconnect();
527
+ }
528
+ };
529
+ });
530
530
 
531
- $effect(() => {
532
- renderMarkers();
533
- });
531
+ $effect(() => {
532
+ renderMarkers();
533
+ });
534
534
  </script>
535
535
 
536
536
  {#snippet UndoResetControls()}
537
- <div class="flex items-center gap-1">
538
- <IconButton
539
- variant="transparent-inverse"
540
- rounded={false}
541
- onclick={undo}
542
- disabled={marks.length === 0 || disabled}
543
- aria-label="Undo last mark"
544
- >
545
- <Icon iconName={'ArrowUUpLeft'} />
546
- </IconButton>
547
- <Divider vertical inverse class="!h-5" />
548
- <Button
549
- variant="transparent-inverse"
550
- size="sm"
551
- onClick={reset}
552
- disabled={marks.length === 0 || disabled}
553
- accessibilityLabel="Reset all marks"
554
- class="!text-primary-inverse"
555
- >
556
- Clear all
557
- </Button>
558
- </div>
537
+ <div class="flex items-center gap-1">
538
+ <IconButton
539
+ variant="transparent-inverse"
540
+ rounded={false}
541
+ onclick={undo}
542
+ disabled={marks.length === 0 || disabled}
543
+ aria-label="Undo last mark"
544
+ >
545
+ <Icon iconName={'ArrowUUpLeft'} />
546
+ </IconButton>
547
+ <Divider vertical inverse class="!h-5" />
548
+ <Button
549
+ variant="transparent-inverse"
550
+ size="sm"
551
+ onClick={reset}
552
+ disabled={marks.length === 0 || disabled}
553
+ accessibilityLabel="Reset all marks"
554
+ class="!text-primary-inverse"
555
+ >
556
+ Clear all
557
+ </Button>
558
+ </div>
559
559
  {/snippet}
560
560
 
561
561
  {#snippet TopLeftActions()}
562
- {#if activeMarkerName && !hideMarkers && !disabled}
563
- <div
564
- class="absolute left-2 top-2 z-20 flex w-full max-w-[calc(100%-1rem)] items-center justify-between rounded-lg border border-interactive-inverse bg-base-inverse px-3 py-2"
565
- >
566
- <div class="flex items-center gap-2">
567
- <div
568
- class="flex !h-6 !w-6 shrink-0 items-center justify-center rounded-md"
569
- style="background-color: {resolvedActiveMarkerColor()}"
570
- >
571
- <Icon iconName={'CursorClick'} size={16} color="primary-inverse" />
572
- </div>
573
-
574
- <span class="text-sm text-primary-inverse">
575
- Click to count each object in <strong>{activeMarkerName}</strong>
576
- </span>
577
- </div>
578
-
579
- {@render UndoResetControls()}
580
- </div>
581
- {/if}
562
+ {#if activeMarkerName && !hideMarkers && !disabled}
563
+ <div
564
+ class="absolute left-2 top-2 z-20 flex w-full max-w-[calc(100%-1rem)] items-center justify-between rounded-lg border border-interactive-inverse bg-base-inverse px-3 py-2"
565
+ >
566
+ <div class="flex items-center gap-2">
567
+ <div
568
+ class="flex !h-6 !w-6 shrink-0 items-center justify-center rounded-md"
569
+ style="background-color: {resolvedActiveMarkerColor()}"
570
+ >
571
+ <Icon iconName={'CursorClick'} size={16} color="primary-inverse" />
572
+ </div>
573
+
574
+ <span class="text-sm text-primary-inverse">
575
+ Click to count each object in <strong>{activeMarkerName}</strong>
576
+ </span>
577
+ </div>
578
+
579
+ {@render UndoResetControls()}
580
+ </div>
581
+ {/if}
582
582
  {/snippet}
583
583
 
584
584
  {#snippet ZoomControls()}
585
- <div
586
- 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"
587
- >
588
- <IconButton
589
- variant="transparent-inverse"
590
- rounded={false}
591
- onclick={zoomIn}
592
- aria-label="Zoom In"
593
- disabled={transform.scale >= MAX_ZOOM || disabled}
594
- >
595
- <Icon iconName={'Plus'} />
596
- </IconButton>
597
- <IconButton
598
- variant="transparent-inverse"
599
- rounded={false}
600
- onclick={zoomOut}
601
- aria-label="Zoom Out"
602
- disabled={transform.scale <= MIN_ZOOM || disabled}
603
- >
604
- <Icon iconName={'Minus'} />
605
- </IconButton>
606
- </div>
585
+ <div
586
+ 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"
587
+ >
588
+ <IconButton
589
+ variant="transparent-inverse"
590
+ rounded={false}
591
+ onclick={zoomIn}
592
+ aria-label="Zoom In"
593
+ disabled={transform.scale >= MAX_ZOOM || disabled}
594
+ >
595
+ <Icon iconName={'Plus'} />
596
+ </IconButton>
597
+ <IconButton
598
+ variant="transparent-inverse"
599
+ rounded={false}
600
+ onclick={zoomOut}
601
+ aria-label="Zoom Out"
602
+ disabled={transform.scale <= MIN_ZOOM || disabled}
603
+ >
604
+ <Icon iconName={'Minus'} />
605
+ </IconButton>
606
+ </div>
607
607
  {/snippet}
608
608
 
609
609
  <div
610
- bind:this={container}
611
- class="group relative h-full w-full overflow-hidden rounded-lg border"
612
- data-testid="manual-cfu-counter"
610
+ bind:this={container}
611
+ class="group relative h-full w-full overflow-hidden rounded-lg border"
612
+ data-testid="manual-cfu-counter"
613
613
  >
614
- {#if !hideMarkers && !disabled}
615
- {@render TopLeftActions()}
616
- {@render ZoomControls()}
617
- {/if}
614
+ {#if !hideMarkers && !disabled}
615
+ {@render TopLeftActions()}
616
+ {@render ZoomControls()}
617
+ {/if}
618
618
 
619
- <!--
619
+ <!--
620
620
  We need to use SVG for this interactive component.
621
621
  The SVG element is treated as a canvas for clicking, panning, and zooming.
622
622
  We add accessibility attributes to make it more accessible despite the interactive nature.
623
623
  -->
624
- <!-- svelte-ignore a11y_click_events_have_key_events -->
625
- <!-- svelte-ignore a11y_no_static_element_interactions -->
626
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
627
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
628
- <svg
629
- bind:this={svgElement}
630
- onclick={handleClick}
631
- onmousedown={handleMouseDown}
632
- onmousemove={handleMouseMove}
633
- onmouseup={handleMouseUp}
634
- onmouseleave={handleMouseLeave}
635
- oncontextmenu={handleContextMenu}
636
- onwheel={handleWheel}
637
- class:cursor-grabbing={panningState === 'active'}
638
- class:cursor-grab={!disabled && (panningState === 'ready' || (isShiftPressed && !panningState))}
639
- class:cursor-not-allowed={disabled}
640
- class:cursor-crosshair={!disabled && !panningState && !isShiftPressed}
641
- class="h-full w-full"
642
- role="application"
643
- aria-label={disabled
644
- ? 'CFU Counter (disabled)'
645
- : 'CFU Counter - Click to add markers, right click or shift+click to pan'}
646
- tabindex="0"
647
- >
648
- <g bind:this={viewport} id="viewport" class="h-full w-full">
649
- <image href={imageUrl} x="0" y="0" width="100%" />
650
- <g
651
- bind:this={dotsGroup}
652
- id="dots"
653
- class="pointer-events-none"
654
- aria-hidden={hideMarkers}
655
- class:hidden={hideMarkers}
656
- />
657
- </g>
658
- </svg>
659
-
660
- <!-- Debug info for marks count - useful for testing -->
661
- <span class="sr-only" data-testid="marks-count">{marks.length}</span>
624
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
625
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
626
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
627
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
628
+ <svg
629
+ bind:this={svgElement}
630
+ onclick={handleClick}
631
+ onmousedown={handleMouseDown}
632
+ onmousemove={handleMouseMove}
633
+ onmouseup={handleMouseUp}
634
+ onmouseleave={handleMouseLeave}
635
+ oncontextmenu={handleContextMenu}
636
+ onwheel={handleWheel}
637
+ class:cursor-grabbing={panningState === 'active'}
638
+ class:cursor-grab={!disabled && (panningState === 'ready' || (isShiftPressed && !panningState))}
639
+ class:cursor-not-allowed={disabled}
640
+ class:cursor-crosshair={!disabled && !panningState && !isShiftPressed}
641
+ class="h-full w-full"
642
+ role="application"
643
+ aria-label={disabled
644
+ ? 'CFU Counter (disabled)'
645
+ : 'CFU Counter - Click to add markers, right click or shift+click to pan'}
646
+ tabindex="0"
647
+ >
648
+ <g bind:this={viewport} id="viewport" class="h-full w-full">
649
+ <image href={imageUrl} x="0" y="0" width="100%" />
650
+ <g
651
+ bind:this={dotsGroup}
652
+ id="dots"
653
+ class="pointer-events-none"
654
+ aria-hidden={hideMarkers}
655
+ class:hidden={hideMarkers}
656
+ />
657
+ </g>
658
+ </svg>
659
+
660
+ <!-- Debug info for marks count - useful for testing -->
661
+ <span class="sr-only" data-testid="marks-count">{marks.length}</span>
662
662
  </div>