@semiont/react-ui 0.2.33-build.78 → 0.2.33-build.80

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 (219) hide show
  1. package/dist/EventBusContext-7GvDyO0d.d.mts +414 -0
  2. package/dist/{PdfAnnotationCanvas.client-ADC4FFSE.mjs → PdfAnnotationCanvas.client-RAJRPQLU.mjs} +42 -27
  3. package/dist/PdfAnnotationCanvas.client-RAJRPQLU.mjs.map +1 -0
  4. package/dist/{ar-RNNSPLQB.mjs → ar-4ZEORRW2.mjs} +8 -4
  5. package/dist/ar-4ZEORRW2.mjs.map +1 -0
  6. package/dist/{bn-S2CDL7EC.mjs → bn-SEDE5BQJ.mjs} +8 -4
  7. package/dist/bn-SEDE5BQJ.mjs.map +1 -0
  8. package/dist/{chunk-UDX2Q35T.mjs → chunk-D7NBW4RV.mjs} +8 -4
  9. package/dist/chunk-D7NBW4RV.mjs.map +1 -0
  10. package/dist/{chunk-35LLVRFK.mjs → chunk-ZR4ZV2LY.mjs} +206 -146
  11. package/dist/chunk-ZR4ZV2LY.mjs.map +1 -0
  12. package/dist/{cs-RSV675WU.mjs → cs-7W4WF5WD.mjs} +8 -4
  13. package/dist/cs-7W4WF5WD.mjs.map +1 -0
  14. package/dist/{da-CHXNPWJC.mjs → da-75XGBCBK.mjs} +8 -4
  15. package/dist/da-75XGBCBK.mjs.map +1 -0
  16. package/dist/{de-KPEZ53D4.mjs → de-ODJVFLHM.mjs} +8 -4
  17. package/dist/de-ODJVFLHM.mjs.map +1 -0
  18. package/dist/{el-MW2BME5T.mjs → el-C4PM4WB3.mjs} +8 -4
  19. package/dist/el-C4PM4WB3.mjs.map +1 -0
  20. package/dist/{en-EVMIX24Y.mjs → en-KJCJQ4OO.mjs} +2 -2
  21. package/dist/{es-HQ24NYS3.mjs → es-WD33R7QL.mjs} +8 -4
  22. package/dist/es-WD33R7QL.mjs.map +1 -0
  23. package/dist/{fa-W34LRLHG.mjs → fa-2BP6V56P.mjs} +8 -4
  24. package/dist/fa-2BP6V56P.mjs.map +1 -0
  25. package/dist/{fi-3U44IGOA.mjs → fi-USRRW24J.mjs} +8 -4
  26. package/dist/fi-USRRW24J.mjs.map +1 -0
  27. package/dist/{fr-N7DKX6NN.mjs → fr-EC5S6WVF.mjs} +8 -4
  28. package/dist/fr-EC5S6WVF.mjs.map +1 -0
  29. package/dist/{he-CS4WRXN3.mjs → he-7TBVIKAA.mjs} +8 -4
  30. package/dist/he-7TBVIKAA.mjs.map +1 -0
  31. package/dist/{hi-GJDY46KA.mjs → hi-FO4VIZLA.mjs} +8 -4
  32. package/dist/hi-FO4VIZLA.mjs.map +1 -0
  33. package/dist/{id-WAEZJK2Y.mjs → id-7U7GGVWY.mjs} +8 -4
  34. package/dist/id-7U7GGVWY.mjs.map +1 -0
  35. package/dist/index.css +123 -85
  36. package/dist/index.css.map +1 -1
  37. package/dist/index.d.mts +699 -529
  38. package/dist/index.mjs +4291 -3491
  39. package/dist/index.mjs.map +1 -1
  40. package/dist/{it-VDNDMZPU.mjs → it-Y4OPL6I2.mjs} +8 -4
  41. package/dist/it-Y4OPL6I2.mjs.map +1 -0
  42. package/dist/{ja-5PEH56J5.mjs → ja-PK7SQL55.mjs} +8 -4
  43. package/dist/ja-PK7SQL55.mjs.map +1 -0
  44. package/dist/{ko-JYPL3WVA.mjs → ko-L25PXMYD.mjs} +8 -4
  45. package/dist/ko-L25PXMYD.mjs.map +1 -0
  46. package/dist/{ms-5PZVW76T.mjs → ms-STH777QM.mjs} +8 -4
  47. package/dist/ms-STH777QM.mjs.map +1 -0
  48. package/dist/{nl-YXES36KM.mjs → nl-Y7LECDDR.mjs} +8 -4
  49. package/dist/nl-Y7LECDDR.mjs.map +1 -0
  50. package/dist/{no-XRA2UCQD.mjs → no-KEKCEWU6.mjs} +8 -4
  51. package/dist/no-KEKCEWU6.mjs.map +1 -0
  52. package/dist/{pl-WH6LJA5G.mjs → pl-7A7OC75O.mjs} +8 -4
  53. package/dist/pl-7A7OC75O.mjs.map +1 -0
  54. package/dist/{pt-7GAG57BM.mjs → pt-35HTM7RA.mjs} +8 -4
  55. package/dist/pt-35HTM7RA.mjs.map +1 -0
  56. package/dist/{ro-BTDDRB7N.mjs → ro-VAWL5KQA.mjs} +8 -4
  57. package/dist/ro-VAWL5KQA.mjs.map +1 -0
  58. package/dist/{sv-7V5C2IT4.mjs → sv-7ZK5EQEB.mjs} +8 -4
  59. package/dist/sv-7ZK5EQEB.mjs.map +1 -0
  60. package/dist/test-utils.d.mts +18 -8
  61. package/dist/test-utils.mjs +36 -14
  62. package/dist/test-utils.mjs.map +1 -1
  63. package/dist/{th-LPKYLBX5.mjs → th-UDWZ4X34.mjs} +8 -4
  64. package/dist/th-UDWZ4X34.mjs.map +1 -0
  65. package/dist/{tr-DU4RQL4M.mjs → tr-4WMPK3UX.mjs} +8 -4
  66. package/dist/tr-4WMPK3UX.mjs.map +1 -0
  67. package/dist/{uk-36UHTDDI.mjs → uk-SSLASQYJ.mjs} +8 -4
  68. package/dist/uk-SSLASQYJ.mjs.map +1 -0
  69. package/dist/{vi-GDHOUZDH.mjs → vi-IF42Z5PU.mjs} +8 -4
  70. package/dist/vi-IF42Z5PU.mjs.map +1 -0
  71. package/dist/{zh-TYUID4XZ.mjs → zh-HRQTNTAI.mjs} +8 -4
  72. package/dist/zh-HRQTNTAI.mjs.map +1 -0
  73. package/package.json +8 -2
  74. package/src/components/CodeMirrorRenderer.tsx +66 -93
  75. package/src/components/DetectionProgressWidget.tsx +16 -5
  76. package/src/components/LiveRegion.tsx +18 -18
  77. package/src/components/ResizeHandle.tsx +10 -4
  78. package/src/components/SessionTimer.tsx +2 -2
  79. package/src/components/Toolbar.tsx +18 -9
  80. package/src/components/__tests__/SessionTimer.test.tsx +9 -9
  81. package/src/components/annotation/AnnotateToolbar.tsx +17 -15
  82. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +165 -63
  83. package/src/components/annotation/annotation-entries.css +10 -0
  84. package/src/components/annotation-popups/JsonLdView.tsx +8 -2
  85. package/src/components/image-annotation/AnnotationOverlay.tsx +42 -22
  86. package/src/components/image-annotation/SvgDrawingCanvas.tsx +27 -30
  87. package/src/components/layout/__tests__/LeftSidebar.test.tsx +12 -33
  88. package/src/components/layout/__tests__/PageLayout.test.tsx +37 -32
  89. package/src/components/layout/__tests__/UnifiedHeader.test.tsx +21 -40
  90. package/src/components/modals/ResourceSearchModal.tsx +2 -2
  91. package/src/components/modals/SearchModal.tsx +1 -1
  92. package/src/components/navigation/CollapsibleResourceNavigation.tsx +14 -9
  93. package/src/components/navigation/NavigationTabs.css +36 -24
  94. package/src/components/navigation/ObservableLink.tsx +91 -0
  95. package/src/components/navigation/SimpleNavigation.tsx +20 -16
  96. package/src/components/navigation/SortableResourceTab.tsx +11 -5
  97. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +51 -26
  98. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +28 -22
  99. package/src/components/resource/AnnotateView.tsx +64 -138
  100. package/src/components/resource/AnnotationHistory.tsx +12 -13
  101. package/src/components/resource/BrowseView.tsx +89 -177
  102. package/src/components/resource/HistoryEvent.tsx +16 -11
  103. package/src/components/resource/ResourceViewer.tsx +201 -370
  104. package/src/components/resource/__tests__/BrowseView.test.tsx +631 -0
  105. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +231 -0
  106. package/src/components/resource/event-formatting.ts +316 -0
  107. package/src/components/resource/panels/AssessmentEntry.tsx +25 -33
  108. package/src/components/resource/panels/AssessmentPanel.tsx +137 -31
  109. package/src/components/resource/panels/CollaborationPanel.tsx +20 -13
  110. package/src/components/resource/panels/CommentEntry.tsx +38 -32
  111. package/src/components/resource/panels/CommentsPanel.tsx +153 -31
  112. package/src/components/resource/panels/DetectSection.css +36 -1
  113. package/src/components/resource/panels/DetectSection.tsx +38 -10
  114. package/src/components/resource/panels/HighlightEntry.tsx +25 -33
  115. package/src/components/resource/panels/HighlightPanel.tsx +100 -25
  116. package/src/components/resource/panels/ReferenceEntry.tsx +61 -75
  117. package/src/components/resource/panels/ReferencesPanel.tsx +166 -49
  118. package/src/components/resource/panels/ResourceInfoPanel.tsx +47 -48
  119. package/src/components/resource/panels/StatisticsPanel.tsx +9 -19
  120. package/src/components/resource/panels/TagEntry.tsx +25 -33
  121. package/src/components/resource/panels/TaggingPanel.tsx +141 -25
  122. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +46 -101
  123. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +566 -0
  124. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +86 -78
  125. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +146 -141
  126. package/src/components/resource/panels/__tests__/DetectSection.test.tsx +480 -0
  127. package/src/components/resource/panels/__tests__/HighlightPanel.detectionProgress.test.tsx +362 -0
  128. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +228 -103
  129. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +117 -61
  130. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +586 -0
  131. package/src/components/settings/SettingsPanel.tsx +15 -12
  132. package/src/features/admin-devops/__tests__/AdminDevOpsPage.test.tsx +1 -46
  133. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +0 -9
  134. package/src/features/admin-security/__tests__/AdminSecurityPage.test.tsx +0 -3
  135. package/src/features/admin-security/components/AdminSecurityPage.tsx +0 -9
  136. package/src/features/admin-users/__tests__/AdminUsersPage.test.tsx +0 -3
  137. package/src/features/admin-users/components/AdminUsersPage.tsx +0 -9
  138. package/src/features/moderate-entity-tags/__tests__/EntityTagsPage.test.tsx +0 -3
  139. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +1 -9
  140. package/src/features/moderate-recent/__tests__/RecentDocumentsPage.test.tsx +0 -32
  141. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +1 -9
  142. package/src/features/moderate-tag-schemas/__tests__/TagSchemasPage.test.tsx +0 -32
  143. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -9
  144. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +51 -54
  145. package/src/features/resource-compose/components/ResourceComposePage.tsx +3 -13
  146. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +39 -45
  147. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +16 -27
  148. package/src/features/resource-viewer/__tests__/AnnotationDeletionIntegration.test.tsx +231 -0
  149. package/src/features/resource-viewer/__tests__/DetectionFlowBug.test.tsx +234 -0
  150. package/src/features/resource-viewer/__tests__/DetectionFlowIntegration.test.tsx +388 -0
  151. package/src/features/resource-viewer/__tests__/DetectionProgressDismissal.test.tsx +318 -0
  152. package/src/features/resource-viewer/__tests__/GenerationFlowIntegration.test.tsx +504 -0
  153. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +145 -91
  154. package/src/features/resource-viewer/__tests__/detection-progress-flow.test.tsx +322 -0
  155. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +325 -476
  156. package/src/styles/motivations/motivation-assessment.css +28 -0
  157. package/src/styles/patterns/panel-helpers.css +26 -0
  158. package/translations/ar.json +7 -3
  159. package/translations/bn.json +7 -3
  160. package/translations/cs.json +7 -3
  161. package/translations/da.json +7 -3
  162. package/translations/de.json +7 -3
  163. package/translations/el.json +7 -3
  164. package/translations/en.json +7 -3
  165. package/translations/es.json +7 -3
  166. package/translations/fa.json +7 -3
  167. package/translations/fi.json +7 -3
  168. package/translations/fr.json +7 -3
  169. package/translations/he.json +7 -3
  170. package/translations/hi.json +7 -3
  171. package/translations/id.json +7 -3
  172. package/translations/it.json +7 -3
  173. package/translations/ja.json +7 -3
  174. package/translations/ko.json +7 -3
  175. package/translations/ms.json +7 -3
  176. package/translations/nl.json +7 -3
  177. package/translations/no.json +7 -3
  178. package/translations/pl.json +7 -3
  179. package/translations/pt.json +7 -3
  180. package/translations/ro.json +7 -3
  181. package/translations/sv.json +7 -3
  182. package/translations/th.json +7 -3
  183. package/translations/tr.json +7 -3
  184. package/translations/uk.json +7 -3
  185. package/translations/vi.json +7 -3
  186. package/translations/zh.json +7 -3
  187. package/dist/PdfAnnotationCanvas.client-ADC4FFSE.mjs.map +0 -1
  188. package/dist/TranslationManager-Co_5fSxl.d.mts +0 -118
  189. package/dist/ar-RNNSPLQB.mjs.map +0 -1
  190. package/dist/bn-S2CDL7EC.mjs.map +0 -1
  191. package/dist/chunk-35LLVRFK.mjs.map +0 -1
  192. package/dist/chunk-UDX2Q35T.mjs.map +0 -1
  193. package/dist/cs-RSV675WU.mjs.map +0 -1
  194. package/dist/da-CHXNPWJC.mjs.map +0 -1
  195. package/dist/de-KPEZ53D4.mjs.map +0 -1
  196. package/dist/el-MW2BME5T.mjs.map +0 -1
  197. package/dist/es-HQ24NYS3.mjs.map +0 -1
  198. package/dist/fa-W34LRLHG.mjs.map +0 -1
  199. package/dist/fi-3U44IGOA.mjs.map +0 -1
  200. package/dist/fr-N7DKX6NN.mjs.map +0 -1
  201. package/dist/he-CS4WRXN3.mjs.map +0 -1
  202. package/dist/hi-GJDY46KA.mjs.map +0 -1
  203. package/dist/id-WAEZJK2Y.mjs.map +0 -1
  204. package/dist/it-VDNDMZPU.mjs.map +0 -1
  205. package/dist/ja-5PEH56J5.mjs.map +0 -1
  206. package/dist/ko-JYPL3WVA.mjs.map +0 -1
  207. package/dist/ms-5PZVW76T.mjs.map +0 -1
  208. package/dist/nl-YXES36KM.mjs.map +0 -1
  209. package/dist/no-XRA2UCQD.mjs.map +0 -1
  210. package/dist/pl-WH6LJA5G.mjs.map +0 -1
  211. package/dist/pt-7GAG57BM.mjs.map +0 -1
  212. package/dist/ro-BTDDRB7N.mjs.map +0 -1
  213. package/dist/sv-7V5C2IT4.mjs.map +0 -1
  214. package/dist/th-LPKYLBX5.mjs.map +0 -1
  215. package/dist/tr-DU4RQL4M.mjs.map +0 -1
  216. package/dist/uk-36UHTDDI.mjs.map +0 -1
  217. package/dist/vi-GDHOUZDH.mjs.map +0 -1
  218. package/dist/zh-TYUID4XZ.mjs.map +0 -1
  219. /package/dist/{en-EVMIX24Y.mjs.map → en-KJCJQ4OO.mjs.map} +0 -0
@@ -1,8 +1,10 @@
1
1
  'use client';
2
2
 
3
+ import { useRef } from 'react';
3
4
  import type { components } from '@semiont/api-client';
4
5
  import { getSvgSelector, isHighlight, isReference, isAssessment, isComment, isTag, isBodyResolved, isResolvedReference } from '@semiont/api-client';
5
6
  import { parseSvgSelector } from '@semiont/api-client';
7
+ import type { EventBus } from '../../contexts/EventBusContext';
6
8
 
7
9
  type Annotation = components['schemas']['Annotation'];
8
10
 
@@ -12,8 +14,7 @@ interface AnnotationOverlayProps {
12
14
  imageHeight: number;
13
15
  displayWidth: number;
14
16
  displayHeight: number;
15
- onAnnotationClick?: (annotation: Annotation) => void;
16
- onAnnotationHover?: (annotationId: string | null) => void;
17
+ eventBus?: EventBus;
17
18
  hoveredAnnotationId?: string | null;
18
19
  selectedAnnotationId?: string | null;
19
20
  }
@@ -59,6 +60,9 @@ function getAnnotationTooltip(annotation: Annotation): string {
59
60
 
60
61
  /**
61
62
  * Render annotation overlay - displays existing annotations as SVG shapes
63
+ *
64
+ * @emits annotation:hover - Annotation hovered or unhovered. Payload: { annotationId: string | null }
65
+ * @emits annotation:click - Annotation clicked. Payload: { annotationId: string, motivation: Motivation }
62
66
  */
63
67
  export function AnnotationOverlay({
64
68
  annotations,
@@ -66,14 +70,30 @@ export function AnnotationOverlay({
66
70
  imageHeight,
67
71
  displayWidth,
68
72
  displayHeight,
69
- onAnnotationClick,
70
- onAnnotationHover,
73
+ eventBus,
71
74
  hoveredAnnotationId,
72
75
  selectedAnnotationId
73
76
  }: AnnotationOverlayProps) {
74
77
  const scaleX = displayWidth / imageWidth;
75
78
  const scaleY = displayHeight / imageHeight;
76
79
 
80
+ // Track current hover state to prevent redundant emissions
81
+ const currentHover = useRef<string | null>(null);
82
+
83
+ const handleMouseEnter = (annotationId: string) => {
84
+ if (currentHover.current !== annotationId) {
85
+ currentHover.current = annotationId;
86
+ eventBus?.emit('annotation:hover', { annotationId });
87
+ }
88
+ };
89
+
90
+ const handleMouseLeave = () => {
91
+ if (currentHover.current !== null) {
92
+ currentHover.current = null;
93
+ eventBus?.emit('annotation:hover', { annotationId: null });
94
+ }
95
+ };
96
+
77
97
  return (
78
98
  <svg
79
99
  className="semiont-annotation-overlay"
@@ -120,9 +140,9 @@ export function AnnotationOverlay({
120
140
  className="semiont-annotation-overlay__shape"
121
141
  data-hovered={isHovered ? 'true' : 'false'}
122
142
  data-selected={isSelected ? 'true' : 'false'}
123
- onClick={() => onAnnotationClick?.(annotation)}
124
- onMouseEnter={() => onAnnotationHover?.(annotation.id)}
125
- onMouseLeave={() => onAnnotationHover?.(null)}
143
+ onClick={() => eventBus?.emit('annotation:click', { annotationId: annotation.id, motivation: annotation.motivation })}
144
+ onMouseEnter={() => handleMouseEnter(annotation.id)}
145
+ onMouseLeave={handleMouseLeave}
126
146
  />
127
147
  {statusEmoji && (
128
148
  <text
@@ -133,10 +153,10 @@ export function AnnotationOverlay({
133
153
  style={{ userSelect: 'none' }}
134
154
  onClick={(e) => {
135
155
  e.stopPropagation();
136
- onAnnotationClick?.(annotation);
156
+ eventBus?.emit('annotation:click', { annotationId: annotation.id, motivation: annotation.motivation });
137
157
  }}
138
- onMouseEnter={() => onAnnotationHover?.(annotation.id)}
139
- onMouseLeave={() => onAnnotationHover?.(null)}
158
+ onMouseEnter={() => handleMouseEnter(annotation.id)}
159
+ onMouseLeave={handleMouseLeave}
140
160
  >
141
161
  {statusEmoji}
142
162
  </text>
@@ -167,9 +187,9 @@ export function AnnotationOverlay({
167
187
  className="semiont-annotation-overlay__shape"
168
188
  data-hovered={isHovered ? 'true' : 'false'}
169
189
  data-selected={isSelected ? 'true' : 'false'}
170
- onClick={() => onAnnotationClick?.(annotation)}
171
- onMouseEnter={() => onAnnotationHover?.(annotation.id)}
172
- onMouseLeave={() => onAnnotationHover?.(null)}
190
+ onClick={() => eventBus?.emit('annotation:click', { annotationId: annotation.id, motivation: annotation.motivation })}
191
+ onMouseEnter={() => handleMouseEnter(annotation.id)}
192
+ onMouseLeave={handleMouseLeave}
173
193
  />
174
194
  {statusEmoji && (
175
195
  <text
@@ -180,10 +200,10 @@ export function AnnotationOverlay({
180
200
  style={{ userSelect: 'none' }}
181
201
  onClick={(e) => {
182
202
  e.stopPropagation();
183
- onAnnotationClick?.(annotation);
203
+ eventBus?.emit('annotation:click', { annotationId: annotation.id, motivation: annotation.motivation });
184
204
  }}
185
- onMouseEnter={() => onAnnotationHover?.(annotation.id)}
186
- onMouseLeave={() => onAnnotationHover?.(null)}
205
+ onMouseEnter={() => handleMouseEnter(annotation.id)}
206
+ onMouseLeave={handleMouseLeave}
187
207
  >
188
208
  {statusEmoji}
189
209
  </text>
@@ -227,9 +247,9 @@ export function AnnotationOverlay({
227
247
  className="semiont-annotation-overlay__shape"
228
248
  data-hovered={isHovered ? 'true' : 'false'}
229
249
  data-selected={isSelected ? 'true' : 'false'}
230
- onClick={() => onAnnotationClick?.(annotation)}
231
- onMouseEnter={() => onAnnotationHover?.(annotation.id)}
232
- onMouseLeave={() => onAnnotationHover?.(null)}
250
+ onClick={() => eventBus?.emit('annotation:click', { annotationId: annotation.id, motivation: annotation.motivation })}
251
+ onMouseEnter={() => handleMouseEnter(annotation.id)}
252
+ onMouseLeave={handleMouseLeave}
233
253
  />
234
254
  {statusEmoji && (
235
255
  <text
@@ -240,10 +260,10 @@ export function AnnotationOverlay({
240
260
  style={{ userSelect: 'none' }}
241
261
  onClick={(e) => {
242
262
  e.stopPropagation();
243
- onAnnotationClick?.(annotation);
263
+ eventBus?.emit('annotation:click', { annotationId: annotation.id, motivation: annotation.motivation });
244
264
  }}
245
- onMouseEnter={() => onAnnotationHover?.(annotation.id)}
246
- onMouseLeave={() => onAnnotationHover?.(null)}
265
+ onMouseEnter={() => handleMouseEnter(annotation.id)}
266
+ onMouseLeave={handleMouseLeave}
247
267
  >
248
268
  {statusEmoji}
249
269
  </text>
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import React, { useRef, useState, useEffect, useCallback } from 'react';
3
+ import React, { useRef, useState, useEffect, useCallback, useMemo } from 'react';
4
4
  import type { components, ResourceUri } from '@semiont/api-client';
5
5
  import { createRectangleSvg, createCircleSvg, createPolygonSvg, scaleSvgToNative, parseSvgSelector, type Point } from '@semiont/api-client';
6
6
  import { AnnotationOverlay } from './AnnotationOverlay';
7
7
  import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
8
+ import type { EventBus } from '../../contexts/EventBusContext';
8
9
 
9
10
  type Annotation = components['schemas']['Annotation'];
10
11
 
@@ -38,30 +39,30 @@ interface SvgDrawingCanvasProps {
38
39
  existingAnnotations?: Annotation[];
39
40
  drawingMode: DrawingMode;
40
41
  selectedMotivation?: SelectionMotivation | null;
41
- onAnnotationCreate?: (svg: string, position?: { x: number; y: number }) => void;
42
- onAnnotationClick?: (annotation: Annotation) => void;
43
- onAnnotationHover?: (annotationId: string | null) => void;
42
+ eventBus?: EventBus;
44
43
  hoveredAnnotationId?: string | null;
45
44
  selectedAnnotationId?: string | null;
46
45
  }
47
46
 
47
+ /**
48
+ * SVG-based drawing canvas for creating image annotations with shapes
49
+ *
50
+ * @emits annotation:click - Annotation clicked on canvas. Payload: { annotationId: string, motivation: Motivation }
51
+ * @emits annotation:requested - New annotation drawn on canvas. Payload: { selector: SvgSelector, motivation: SelectionMotivation }
52
+ */
48
53
  export function SvgDrawingCanvas({
49
54
  resourceUri,
50
55
  existingAnnotations = [],
51
56
  drawingMode,
52
57
  selectedMotivation,
53
- onAnnotationCreate,
54
- onAnnotationClick,
55
- onAnnotationHover,
58
+ eventBus,
56
59
  hoveredAnnotationId,
57
60
  selectedAnnotationId
58
61
  }: SvgDrawingCanvasProps) {
59
- // Extract resource ID from W3C canonical URI (last segment of path)
60
- const resourceId = resourceUri.split('/').pop();
61
-
62
- // Use Next.js API route proxy instead of direct backend call
63
- // This allows us to add authentication headers which <img> tags can't send
64
- const imageUrl = `/api/resources/${resourceId}`;
62
+ const imageUrl = useMemo(() => {
63
+ const resourceId = resourceUri.split('/').pop();
64
+ return `/api/resources/${resourceId}`;
65
+ }, [resourceUri]);
65
66
  const containerRef = useRef<HTMLDivElement>(null);
66
67
  const imageRef = useRef<HTMLImageElement>(null);
67
68
  const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null);
@@ -172,7 +173,7 @@ export function SvgDrawingCanvas({
172
173
 
173
174
  if (dragDistance < MIN_DRAG_DISTANCE) {
174
175
  // This was a click, not a drag - check if we clicked an existing annotation
175
- if (onAnnotationClick && existingAnnotations.length > 0) {
176
+ if (existingAnnotations.length > 0) {
176
177
  // Find annotation at click point
177
178
  // Note: We're checking in display coordinates
178
179
  const clickedAnnotation = existingAnnotations.find(ann => {
@@ -212,7 +213,7 @@ export function SvgDrawingCanvas({
212
213
  });
213
214
 
214
215
  if (clickedAnnotation) {
215
- onAnnotationClick(clickedAnnotation);
216
+ eventBus?.emit('annotation:click', { annotationId: clickedAnnotation.id, motivation: clickedAnnotation.motivation });
216
217
  setIsDrawing(false);
217
218
  setStartPoint(null);
218
219
  setCurrentPoint(null);
@@ -273,25 +274,22 @@ export function SvgDrawingCanvas({
273
274
  imageDimensions.height
274
275
  );
275
276
 
276
- // Calculate center position for popup placement (in screen coordinates)
277
- const centerX = (startPoint.x + endPoint.x) / 2;
278
- const centerY = (startPoint.y + endPoint.y) / 2;
279
- const rect = imageRef.current?.getBoundingClientRect();
280
- const screenPosition = rect ? {
281
- x: rect.left + centerX,
282
- y: rect.top + centerY
283
- } : undefined;
284
-
285
- // Notify parent
286
- if (onAnnotationCreate) {
287
- onAnnotationCreate(nativeSvg, screenPosition);
277
+ // Emit annotation:requested event with SvgSelector
278
+ if (eventBus && selectedMotivation) {
279
+ eventBus.emit('annotation:requested', {
280
+ selector: {
281
+ type: 'SvgSelector',
282
+ value: nativeSvg
283
+ },
284
+ motivation: selectedMotivation
285
+ });
288
286
  }
289
287
 
290
288
  // Reset drawing state
291
289
  setIsDrawing(false);
292
290
  setStartPoint(null);
293
291
  setCurrentPoint(null);
294
- }, [isDrawing, startPoint, drawingMode, displayDimensions, imageDimensions, getRelativeCoordinates, onAnnotationCreate, onAnnotationClick, existingAnnotations]);
292
+ }, [isDrawing, startPoint, drawingMode, displayDimensions, imageDimensions, getRelativeCoordinates, selectedMotivation, existingAnnotations]);
295
293
 
296
294
  // Cancel drawing on mouse leave
297
295
  const handleMouseLeave = useCallback(() => {
@@ -340,8 +338,7 @@ export function SvgDrawingCanvas({
340
338
  imageHeight={imageDimensions.height}
341
339
  displayWidth={displayDimensions.width}
342
340
  displayHeight={displayDimensions.height}
343
- {...(onAnnotationClick && { onAnnotationClick })}
344
- {...(onAnnotationHover && { onAnnotationHover })}
341
+ {...(eventBus && { eventBus })}
345
342
  {...(hoveredAnnotationId !== undefined && { hoveredAnnotationId })}
346
343
  {...(selectedAnnotationId !== undefined && { selectedAnnotationId })}
347
344
  />
@@ -4,32 +4,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
4
4
  import '@testing-library/jest-dom';
5
5
  import { LeftSidebar } from '../LeftSidebar';
6
6
 
7
- // Mock dependencies
8
- vi.mock('../../navigation/NavigationMenu', () => ({
9
- NavigationMenu: ({ t, onItemClick }: any) => (
10
- <div data-testid="navigation-menu">
11
- <button onClick={onItemClick}>Menu Item</button>
12
- <span>{t('home')}</span>
13
- </div>
14
- ),
15
- }));
16
-
17
- vi.mock('../../branding/SemiontBranding', () => ({
18
- SemiontBranding: ({ t, size, showTagline }: any) => (
19
- <div data-testid="semiont-branding">
20
- <span>Semiont {size}</span>
21
- {showTagline && <span>Tagline</span>}
22
- </div>
23
- ),
24
- }));
25
-
26
- vi.mock('@/hooks/useAuth', () => ({
27
- useAuth: vi.fn(() => ({
28
- isAuthenticated: true,
29
- isAdmin: false,
30
- isModerator: false,
31
- })),
32
- }));
7
+ // No mocks - using real components via composition
33
8
 
34
9
  // Mock Link component
35
10
  const MockLink = ({ href, children, ...props }: any) => (
@@ -98,7 +73,8 @@ describe('LeftSidebar Component', () => {
98
73
  </LeftSidebar>
99
74
  );
100
75
 
101
- expect(screen.getByTestId('semiont-branding')).toBeInTheDocument();
76
+ // Real SemiontBranding renders "Semiont" text
77
+ expect(screen.getByText('Semiont')).toBeInTheDocument();
102
78
  });
103
79
 
104
80
  it('should render branding link', () => {
@@ -276,7 +252,9 @@ describe('LeftSidebar Component', () => {
276
252
  );
277
253
 
278
254
  expect(mockChildren).toHaveBeenCalled();
279
- expect(screen.getByTestId('navigation-menu')).toBeInTheDocument();
255
+ // Real NavigationMenu renders navigation links
256
+ const childrenContent = screen.getByTestId('children-content');
257
+ expect(childrenContent.querySelector('nav.semiont-navigation-menu')).toBeInTheDocument();
280
258
  });
281
259
 
282
260
  it('should pass onClose callback to NavigationMenu', () => {
@@ -297,8 +275,8 @@ describe('LeftSidebar Component', () => {
297
275
  </LeftSidebar>
298
276
  );
299
277
 
300
- // Click the menu item which should trigger onClose
301
- const menuItem = screen.getByText('Menu Item');
278
+ // Real NavigationMenu renders a link (translated 'know' key)
279
+ const menuItem = screen.getByText('nav.know');
302
280
  fireEvent.click(menuItem);
303
281
 
304
282
  expect(mockOnClose).toHaveBeenCalledOnce();
@@ -355,7 +333,8 @@ describe('LeftSidebar Component', () => {
355
333
  );
356
334
 
357
335
  expect(screen.getByText('S')).toBeInTheDocument();
358
- expect(screen.queryByTestId('semiont-branding')).not.toBeInTheDocument();
336
+ // When collapsed, full "Semiont" branding is not shown
337
+ expect(screen.queryByText(/^Semiont$/)).not.toBeInTheDocument();
359
338
  });
360
339
  });
361
340
 
@@ -408,7 +387,7 @@ describe('LeftSidebar Component', () => {
408
387
  </LeftSidebar>
409
388
  );
410
389
 
411
- expect(screen.getByTestId('semiont-branding')).toBeInTheDocument();
390
+ expect(screen.getByText('Semiont')).toBeInTheDocument();
412
391
  });
413
392
 
414
393
  it('should default branding link to /', () => {
@@ -423,7 +402,7 @@ describe('LeftSidebar Component', () => {
423
402
  </LeftSidebar>
424
403
  );
425
404
 
426
- expect(screen.getByTestId('semiont-branding')).toBeInTheDocument();
405
+ expect(screen.getByText('Semiont')).toBeInTheDocument();
427
406
  });
428
407
  });
429
408
  });
@@ -4,22 +4,15 @@ import { render, screen } from '@testing-library/react';
4
4
  import '@testing-library/jest-dom';
5
5
  import { PageLayout } from '../PageLayout';
6
6
 
7
- // Mock UnifiedHeader
8
- vi.mock('../UnifiedHeader', () => ({
9
- UnifiedHeader: ({ t }: any) => (
10
- <div data-testid="unified-header">
11
- <span>{t('home')}</span>
12
- </div>
13
- ),
14
- }));
15
-
16
- // Mock Footer
17
- vi.mock('../../navigation/Footer', () => ({
18
- Footer: ({ t }: any) => (
19
- <div data-testid="footer">
20
- <span>{t('copyright')}</span>
21
- </div>
22
- ),
7
+ // No mocks - using real components via composition
8
+ // Need to mock useDropdown hook used by UnifiedHeader
9
+ vi.mock('@/hooks/useUI', () => ({
10
+ useDropdown: vi.fn(() => ({
11
+ isOpen: false,
12
+ toggle: vi.fn(),
13
+ close: vi.fn(),
14
+ dropdownRef: { current: null },
15
+ })),
23
16
  }));
24
17
 
25
18
  // Mock Link component
@@ -69,7 +62,19 @@ describe('PageLayout Component', () => {
69
62
  </PageLayout>
70
63
  );
71
64
 
72
- expect(screen.getByTestId('unified-header')).toBeInTheDocument();
65
+ // Real UnifiedHeader renders a header element
66
+ const { container } = render(
67
+ <PageLayout
68
+ Link={MockLink}
69
+ routes={mockRoutes}
70
+ t={mockT}
71
+ tNav={mockTNav}
72
+ tHome={mockTHome}
73
+ >
74
+ <div>Content</div>
75
+ </PageLayout>
76
+ );
77
+ expect(container.querySelector('header')).toBeInTheDocument();
73
78
  });
74
79
 
75
80
  it('should render footer', () => {
@@ -85,7 +90,8 @@ describe('PageLayout Component', () => {
85
90
  </PageLayout>
86
91
  );
87
92
 
88
- expect(screen.getByTestId('footer')).toBeInTheDocument();
93
+ // Real Footer renders contentinfo role
94
+ expect(screen.getByRole('contentinfo')).toBeInTheDocument();
89
95
  });
90
96
 
91
97
  it('should render children in main element', () => {
@@ -180,7 +186,7 @@ describe('PageLayout Component', () => {
180
186
 
181
187
  describe('Props Handling', () => {
182
188
  it('should pass showAuthLinks to UnifiedHeader', () => {
183
- const { rerender } = render(
189
+ const { rerender, container } = render(
184
190
  <PageLayout
185
191
  Link={MockLink}
186
192
  routes={mockRoutes}
@@ -193,7 +199,7 @@ describe('PageLayout Component', () => {
193
199
  </PageLayout>
194
200
  );
195
201
 
196
- expect(screen.getByTestId('unified-header')).toBeInTheDocument();
202
+ expect(container.querySelector('header')).toBeInTheDocument();
197
203
 
198
204
  rerender(
199
205
  <PageLayout
@@ -208,11 +214,11 @@ describe('PageLayout Component', () => {
208
214
  </PageLayout>
209
215
  );
210
216
 
211
- expect(screen.getByTestId('unified-header')).toBeInTheDocument();
217
+ expect(container.querySelector('header')).toBeInTheDocument();
212
218
  });
213
219
 
214
220
  it('should default showAuthLinks to true', () => {
215
- render(
221
+ const { container } = render(
216
222
  <PageLayout
217
223
  Link={MockLink}
218
224
  routes={mockRoutes}
@@ -224,7 +230,7 @@ describe('PageLayout Component', () => {
224
230
  </PageLayout>
225
231
  );
226
232
 
227
- expect(screen.getByTestId('unified-header')).toBeInTheDocument();
233
+ expect(container.querySelector('header')).toBeInTheDocument();
228
234
  });
229
235
 
230
236
  it('should pass translation functions to components', () => {
@@ -240,11 +246,9 @@ describe('PageLayout Component', () => {
240
246
  </PageLayout>
241
247
  );
242
248
 
243
- // UnifiedHeader should receive tNav (which renders t('home'))
244
- expect(screen.getByText('nav.home')).toBeInTheDocument();
245
-
246
- // Footer should receive t
247
- expect(screen.getByText('translated.copyright')).toBeInTheDocument();
249
+ // Real Footer renders copyright with dynamic year
250
+ const currentYear = new Date().getFullYear();
251
+ expect(screen.getByText(`translated.copyright`)).toBeInTheDocument();
248
252
  });
249
253
  });
250
254
 
@@ -283,7 +287,8 @@ describe('PageLayout Component', () => {
283
287
  </PageLayout>
284
288
  );
285
289
 
286
- expect(screen.getByTestId('footer')).toBeInTheDocument();
290
+ // Real Footer renders contentinfo role
291
+ expect(screen.getByRole('contentinfo')).toBeInTheDocument();
287
292
  });
288
293
 
289
294
  it('should render with onOpenKeyboardHelp handler', () => {
@@ -302,7 +307,7 @@ describe('PageLayout Component', () => {
302
307
  </PageLayout>
303
308
  );
304
309
 
305
- expect(screen.getByTestId('footer')).toBeInTheDocument();
310
+ expect(screen.getByRole('contentinfo')).toBeInTheDocument();
306
311
  });
307
312
  });
308
313
 
@@ -355,14 +360,14 @@ describe('PageLayout Component', () => {
355
360
  const wrapper = container.firstChild as HTMLElement;
356
361
  const header = wrapper.querySelector('header');
357
362
  const main = wrapper.querySelector('main');
358
- const footer = wrapper.querySelector('footer')?.parentElement;
363
+ const footer = wrapper.querySelector('footer');
359
364
 
360
365
  // Header should come before main
361
366
  expect(header?.compareDocumentPosition(main!)).toBe(
362
367
  Node.DOCUMENT_POSITION_FOLLOWING
363
368
  );
364
369
 
365
- // Main should come before footer
370
+ // Main should come before footer (real Footer component has footer element)
366
371
  if (footer) {
367
372
  expect(main?.compareDocumentPosition(footer)).toBe(
368
373
  Node.DOCUMENT_POSITION_FOLLOWING
@@ -4,32 +4,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
4
4
  import '@testing-library/jest-dom';
5
5
  import { UnifiedHeader } from '../UnifiedHeader';
6
6
 
7
- // Mock dependencies
8
- vi.mock('../../branding/SemiontBranding', () => ({
9
- SemiontBranding: ({ t, size, showTagline, compactTagline }: any) => (
10
- <div data-testid="semiont-branding">
11
- Semiont {size} {showTagline && '- Tagline'} {compactTagline && '(compact)'}
12
- </div>
13
- ),
14
- }));
15
-
16
- vi.mock('../../navigation/NavigationMenu', () => ({
17
- NavigationMenu: ({ t, onItemClick }: any) => (
18
- <div data-testid="navigation-menu">
19
- <button onClick={onItemClick}>Menu Item</button>
20
- <span>{t('home')}</span>
21
- </div>
22
- ),
23
- }));
24
-
25
- vi.mock('@/hooks/useAuth', () => ({
26
- useAuth: vi.fn(() => ({
27
- isAuthenticated: true,
28
- isAdmin: false,
29
- isModerator: false,
30
- })),
31
- }));
32
-
7
+ // Mock only hooks - using real components via composition
33
8
  vi.mock('@/hooks/useUI', () => ({
34
9
  useDropdown: vi.fn(() => ({
35
10
  isOpen: false,
@@ -95,7 +70,8 @@ describe('UnifiedHeader Component', () => {
95
70
  />
96
71
  );
97
72
 
98
- expect(screen.getByTestId('semiont-branding')).toBeInTheDocument();
73
+ // Real SemiontBranding renders "Semiont" text
74
+ expect(screen.getByText('Semiont')).toBeInTheDocument();
99
75
  });
100
76
 
101
77
  it('should default to standalone variant', () => {
@@ -140,7 +116,7 @@ describe('UnifiedHeader Component', () => {
140
116
  />
141
117
  );
142
118
 
143
- expect(screen.getByTestId('semiont-branding')).toBeInTheDocument();
119
+ expect(screen.getByText('Semiont')).toBeInTheDocument();
144
120
  });
145
121
  });
146
122
 
@@ -171,7 +147,7 @@ describe('UnifiedHeader Component', () => {
171
147
  />
172
148
  );
173
149
 
174
- expect(screen.getByTestId('semiont-branding')).toBeInTheDocument();
150
+ expect(screen.getByText('Semiont')).toBeInTheDocument();
175
151
  });
176
152
 
177
153
  it('should not render floating variant when showBranding is false', () => {
@@ -202,7 +178,7 @@ describe('UnifiedHeader Component', () => {
202
178
  />
203
179
  );
204
180
 
205
- expect(screen.getByTestId('semiont-branding')).toBeInTheDocument();
181
+ expect(screen.getByText('Semiont')).toBeInTheDocument();
206
182
  });
207
183
 
208
184
  it('should hide branding when showBranding is false', () => {
@@ -216,7 +192,7 @@ describe('UnifiedHeader Component', () => {
216
192
  />
217
193
  );
218
194
 
219
- expect(screen.queryByTestId('semiont-branding')).not.toBeInTheDocument();
195
+ expect(screen.queryByText('Semiont')).not.toBeInTheDocument();
220
196
  });
221
197
 
222
198
  it('should render navigation button for branding', () => {
@@ -277,7 +253,8 @@ describe('UnifiedHeader Component', () => {
277
253
  />
278
254
  );
279
255
 
280
- expect(screen.getByTestId('navigation-menu')).toBeInTheDocument();
256
+ // Real NavigationMenu renders nav.know link
257
+ expect(screen.getByText('nav.know')).toBeInTheDocument();
281
258
  });
282
259
 
283
260
  it('should not show dropdown menu when not authenticated', () => {
@@ -298,7 +275,7 @@ describe('UnifiedHeader Component', () => {
298
275
  />
299
276
  );
300
277
 
301
- expect(screen.queryByTestId('navigation-menu')).not.toBeInTheDocument();
278
+ expect(screen.queryByText('nav.know')).not.toBeInTheDocument();
302
279
  });
303
280
 
304
281
  it('should not show dropdown menu when closed', () => {
@@ -319,7 +296,7 @@ describe('UnifiedHeader Component', () => {
319
296
  />
320
297
  );
321
298
 
322
- expect(screen.queryByTestId('navigation-menu')).not.toBeInTheDocument();
299
+ expect(screen.queryByText('nav.know')).not.toBeInTheDocument();
323
300
  });
324
301
 
325
302
  it('should close dropdown when menu item clicked', () => {
@@ -341,7 +318,8 @@ describe('UnifiedHeader Component', () => {
341
318
  />
342
319
  );
343
320
 
344
- const menuItem = screen.getByText('Menu Item');
321
+ // Real NavigationMenu renders nav.know link
322
+ const menuItem = screen.getByText('nav.know');
345
323
  fireEvent.click(menuItem);
346
324
 
347
325
  expect(mockClose).toHaveBeenCalledOnce();
@@ -368,7 +346,7 @@ describe('UnifiedHeader Component', () => {
368
346
  );
369
347
 
370
348
  // Should render branding with custom link
371
- expect(screen.getByTestId('semiont-branding')).toBeInTheDocument();
349
+ expect(screen.getByText('Semiont')).toBeInTheDocument();
372
350
  });
373
351
 
374
352
  it('should default branding link to /', () => {
@@ -381,7 +359,7 @@ describe('UnifiedHeader Component', () => {
381
359
  />
382
360
  );
383
361
 
384
- expect(screen.getByTestId('semiont-branding')).toBeInTheDocument();
362
+ expect(screen.getByText('Semiont')).toBeInTheDocument();
385
363
  });
386
364
 
387
365
  it('should pass admin status to NavigationMenu', () => {
@@ -403,7 +381,8 @@ describe('UnifiedHeader Component', () => {
403
381
  />
404
382
  );
405
383
 
406
- expect(screen.getByTestId('navigation-menu')).toBeInTheDocument();
384
+ // Real NavigationMenu renders admin link
385
+ expect(screen.getByText('nav.administer')).toBeInTheDocument();
407
386
  });
408
387
 
409
388
  it('should pass moderator status to NavigationMenu', () => {
@@ -425,7 +404,8 @@ describe('UnifiedHeader Component', () => {
425
404
  />
426
405
  );
427
406
 
428
- expect(screen.getByTestId('navigation-menu')).toBeInTheDocument();
407
+ // Real NavigationMenu renders moderate link
408
+ expect(screen.getByText('nav.moderate')).toBeInTheDocument();
429
409
  });
430
410
  });
431
411
 
@@ -567,7 +547,8 @@ describe('UnifiedHeader Component', () => {
567
547
  const standaloneContent = standaloneContainer.querySelector('.semiont-unified-header');
568
548
  expect(standaloneContent).toBeInTheDocument();
569
549
 
570
- const embeddedContent = embeddedContainer.querySelector('[data-testid="semiont-branding"]');
550
+ // Embedded variant has branding directly in container
551
+ const embeddedContent = embeddedContainer.querySelector('.semiont-branding');
571
552
  expect(embeddedContent).toBeInTheDocument();
572
553
  });
573
554
  });