@semiont/react-ui 0.2.33-build.79 → 0.2.33-build.81

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 (213) hide show
  1. package/dist/EventBusContext-CJjL_cCf.d.mts +462 -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-EMHEHPCJ.mjs → ar-4ZEORRW2.mjs} +7 -4
  5. package/dist/ar-4ZEORRW2.mjs.map +1 -0
  6. package/dist/{bn-OVCI4F6X.mjs → bn-SEDE5BQJ.mjs} +7 -4
  7. package/dist/bn-SEDE5BQJ.mjs.map +1 -0
  8. package/dist/{chunk-LIHZTECW.mjs → chunk-D7NBW4RV.mjs} +7 -4
  9. package/dist/chunk-D7NBW4RV.mjs.map +1 -0
  10. package/dist/{chunk-JZIO2A3B.mjs → chunk-QB52Q7EQ.mjs} +206 -146
  11. package/dist/chunk-QB52Q7EQ.mjs.map +1 -0
  12. package/dist/{cs-FAN66Q2F.mjs → cs-7W4WF5WD.mjs} +7 -4
  13. package/dist/cs-7W4WF5WD.mjs.map +1 -0
  14. package/dist/{da-YBBIHI2O.mjs → da-75XGBCBK.mjs} +7 -4
  15. package/dist/da-75XGBCBK.mjs.map +1 -0
  16. package/dist/{de-MAYU33LB.mjs → de-ODJVFLHM.mjs} +7 -4
  17. package/dist/de-ODJVFLHM.mjs.map +1 -0
  18. package/dist/{el-MKGSWN4O.mjs → el-C4PM4WB3.mjs} +7 -4
  19. package/dist/el-C4PM4WB3.mjs.map +1 -0
  20. package/dist/{en-DDLIXJCU.mjs → en-KJCJQ4OO.mjs} +2 -2
  21. package/dist/{es-52LHUWJD.mjs → es-WD33R7QL.mjs} +7 -4
  22. package/dist/es-WD33R7QL.mjs.map +1 -0
  23. package/dist/{fa-FJICRANB.mjs → fa-2BP6V56P.mjs} +7 -4
  24. package/dist/fa-2BP6V56P.mjs.map +1 -0
  25. package/dist/{fi-O455XFCR.mjs → fi-USRRW24J.mjs} +7 -4
  26. package/dist/fi-USRRW24J.mjs.map +1 -0
  27. package/dist/{fr-TXIXHOOE.mjs → fr-EC5S6WVF.mjs} +7 -4
  28. package/dist/fr-EC5S6WVF.mjs.map +1 -0
  29. package/dist/{he-JBSOX5IN.mjs → he-7TBVIKAA.mjs} +7 -4
  30. package/dist/he-7TBVIKAA.mjs.map +1 -0
  31. package/dist/{hi-KGHI3XVT.mjs → hi-FO4VIZLA.mjs} +7 -4
  32. package/dist/hi-FO4VIZLA.mjs.map +1 -0
  33. package/dist/{id-5OCPPZLO.mjs → id-7U7GGVWY.mjs} +7 -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 +715 -574
  38. package/dist/index.mjs +3898 -3575
  39. package/dist/index.mjs.map +1 -1
  40. package/dist/{it-PNBBZSM2.mjs → it-Y4OPL6I2.mjs} +7 -4
  41. package/dist/it-Y4OPL6I2.mjs.map +1 -0
  42. package/dist/{ja-LDD7R3TJ.mjs → ja-PK7SQL55.mjs} +7 -4
  43. package/dist/ja-PK7SQL55.mjs.map +1 -0
  44. package/dist/{ko-F47ZDEY3.mjs → ko-L25PXMYD.mjs} +7 -4
  45. package/dist/ko-L25PXMYD.mjs.map +1 -0
  46. package/dist/{ms-Z7LMXJWL.mjs → ms-STH777QM.mjs} +7 -4
  47. package/dist/ms-STH777QM.mjs.map +1 -0
  48. package/dist/{nl-6SJFBPJ3.mjs → nl-Y7LECDDR.mjs} +7 -4
  49. package/dist/nl-Y7LECDDR.mjs.map +1 -0
  50. package/dist/{no-YXPBPSGF.mjs → no-KEKCEWU6.mjs} +7 -4
  51. package/dist/no-KEKCEWU6.mjs.map +1 -0
  52. package/dist/{pl-P4AZ2QME.mjs → pl-7A7OC75O.mjs} +7 -4
  53. package/dist/pl-7A7OC75O.mjs.map +1 -0
  54. package/dist/{pt-LHWUS6U6.mjs → pt-35HTM7RA.mjs} +7 -4
  55. package/dist/pt-35HTM7RA.mjs.map +1 -0
  56. package/dist/{ro-EA5J2ZON.mjs → ro-VAWL5KQA.mjs} +7 -4
  57. package/dist/ro-VAWL5KQA.mjs.map +1 -0
  58. package/dist/{sv-DATBS3UQ.mjs → sv-7ZK5EQEB.mjs} +7 -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-WTFJRWPT.mjs → th-UDWZ4X34.mjs} +7 -4
  64. package/dist/th-UDWZ4X34.mjs.map +1 -0
  65. package/dist/{tr-IKO3RXOX.mjs → tr-4WMPK3UX.mjs} +7 -4
  66. package/dist/tr-4WMPK3UX.mjs.map +1 -0
  67. package/dist/{uk-CF6CTTRK.mjs → uk-SSLASQYJ.mjs} +7 -4
  68. package/dist/uk-SSLASQYJ.mjs.map +1 -0
  69. package/dist/{vi-AJLTXPZQ.mjs → vi-IF42Z5PU.mjs} +7 -4
  70. package/dist/vi-IF42Z5PU.mjs.map +1 -0
  71. package/dist/{zh-U3ORHHYH.mjs → zh-HRQTNTAI.mjs} +7 -4
  72. package/dist/zh-HRQTNTAI.mjs.map +1 -0
  73. package/package.json +3 -1
  74. package/src/components/CodeMirrorRenderer.tsx +66 -93
  75. package/src/components/DetectionProgressWidget.tsx +16 -5
  76. package/src/components/ResizeHandle.tsx +10 -4
  77. package/src/components/SessionExpiryBanner.tsx +2 -3
  78. package/src/components/SessionTimer.tsx +3 -3
  79. package/src/components/Toolbar.tsx +18 -9
  80. package/src/components/__tests__/SessionTimer.test.tsx +33 -33
  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 -134
  100. package/src/components/resource/BrowseView.tsx +86 -166
  101. package/src/components/resource/HistoryEvent.tsx +13 -7
  102. package/src/components/resource/ResourceViewer.tsx +122 -264
  103. package/src/components/resource/__tests__/BrowseView.test.tsx +631 -0
  104. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +231 -0
  105. package/src/components/resource/panels/AssessmentEntry.tsx +25 -33
  106. package/src/components/resource/panels/AssessmentPanel.tsx +106 -28
  107. package/src/components/resource/panels/CommentEntry.tsx +38 -32
  108. package/src/components/resource/panels/CommentsPanel.tsx +121 -28
  109. package/src/components/resource/panels/DetectSection.css +36 -1
  110. package/src/components/resource/panels/DetectSection.tsx +49 -15
  111. package/src/components/resource/panels/HighlightEntry.tsx +25 -33
  112. package/src/components/resource/panels/HighlightPanel.tsx +100 -25
  113. package/src/components/resource/panels/ReferenceEntry.tsx +61 -75
  114. package/src/components/resource/panels/ReferencesPanel.tsx +134 -42
  115. package/src/components/resource/panels/ResourceInfoPanel.tsx +47 -48
  116. package/src/components/resource/panels/TagEntry.tsx +25 -33
  117. package/src/components/resource/panels/TaggingPanel.tsx +118 -30
  118. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +30 -92
  119. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +129 -110
  120. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +86 -78
  121. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +144 -149
  122. package/src/components/resource/panels/__tests__/DetectSection.test.tsx +480 -0
  123. package/src/components/resource/panels/__tests__/HighlightPanel.detectionProgress.test.tsx +362 -0
  124. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +226 -111
  125. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +117 -61
  126. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +128 -106
  127. package/src/components/settings/SettingsPanel.tsx +15 -12
  128. package/src/features/admin-devops/__tests__/AdminDevOpsPage.test.tsx +1 -46
  129. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +0 -9
  130. package/src/features/admin-security/__tests__/AdminSecurityPage.test.tsx +0 -3
  131. package/src/features/admin-security/components/AdminSecurityPage.tsx +0 -9
  132. package/src/features/admin-users/__tests__/AdminUsersPage.test.tsx +0 -3
  133. package/src/features/admin-users/components/AdminUsersPage.tsx +0 -9
  134. package/src/features/moderate-entity-tags/__tests__/EntityTagsPage.test.tsx +0 -3
  135. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +1 -9
  136. package/src/features/moderate-recent/__tests__/RecentDocumentsPage.test.tsx +0 -32
  137. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +1 -9
  138. package/src/features/moderate-tag-schemas/__tests__/TagSchemasPage.test.tsx +0 -32
  139. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -9
  140. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +51 -54
  141. package/src/features/resource-compose/components/ResourceComposePage.tsx +3 -13
  142. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +39 -45
  143. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +9 -13
  144. package/src/features/resource-viewer/__tests__/AnnotationDeletionIntegration.test.tsx +234 -0
  145. package/src/features/resource-viewer/__tests__/DetectionFlowBug.test.tsx +234 -0
  146. package/src/features/resource-viewer/__tests__/DetectionFlowIntegration.test.tsx +388 -0
  147. package/src/features/resource-viewer/__tests__/DetectionProgressDismissal.test.tsx +318 -0
  148. package/src/features/resource-viewer/__tests__/GenerationFlowIntegration.test.tsx +503 -0
  149. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +139 -93
  150. package/src/features/resource-viewer/__tests__/detection-progress-flow.test.tsx +322 -0
  151. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +341 -524
  152. package/translations/ar.json +6 -3
  153. package/translations/bn.json +6 -3
  154. package/translations/cs.json +6 -3
  155. package/translations/da.json +6 -3
  156. package/translations/de.json +6 -3
  157. package/translations/el.json +6 -3
  158. package/translations/en.json +6 -3
  159. package/translations/es.json +6 -3
  160. package/translations/fa.json +6 -3
  161. package/translations/fi.json +6 -3
  162. package/translations/fr.json +6 -3
  163. package/translations/he.json +6 -3
  164. package/translations/hi.json +6 -3
  165. package/translations/id.json +6 -3
  166. package/translations/it.json +6 -3
  167. package/translations/ja.json +6 -3
  168. package/translations/ko.json +6 -3
  169. package/translations/ms.json +6 -3
  170. package/translations/nl.json +6 -3
  171. package/translations/no.json +6 -3
  172. package/translations/pl.json +6 -3
  173. package/translations/pt.json +6 -3
  174. package/translations/ro.json +6 -3
  175. package/translations/sv.json +6 -3
  176. package/translations/th.json +6 -3
  177. package/translations/tr.json +6 -3
  178. package/translations/uk.json +6 -3
  179. package/translations/vi.json +6 -3
  180. package/translations/zh.json +6 -3
  181. package/dist/PdfAnnotationCanvas.client-ADC4FFSE.mjs.map +0 -1
  182. package/dist/TranslationManager-Co_5fSxl.d.mts +0 -118
  183. package/dist/ar-EMHEHPCJ.mjs.map +0 -1
  184. package/dist/bn-OVCI4F6X.mjs.map +0 -1
  185. package/dist/chunk-JZIO2A3B.mjs.map +0 -1
  186. package/dist/chunk-LIHZTECW.mjs.map +0 -1
  187. package/dist/cs-FAN66Q2F.mjs.map +0 -1
  188. package/dist/da-YBBIHI2O.mjs.map +0 -1
  189. package/dist/de-MAYU33LB.mjs.map +0 -1
  190. package/dist/el-MKGSWN4O.mjs.map +0 -1
  191. package/dist/es-52LHUWJD.mjs.map +0 -1
  192. package/dist/fa-FJICRANB.mjs.map +0 -1
  193. package/dist/fi-O455XFCR.mjs.map +0 -1
  194. package/dist/fr-TXIXHOOE.mjs.map +0 -1
  195. package/dist/he-JBSOX5IN.mjs.map +0 -1
  196. package/dist/hi-KGHI3XVT.mjs.map +0 -1
  197. package/dist/id-5OCPPZLO.mjs.map +0 -1
  198. package/dist/it-PNBBZSM2.mjs.map +0 -1
  199. package/dist/ja-LDD7R3TJ.mjs.map +0 -1
  200. package/dist/ko-F47ZDEY3.mjs.map +0 -1
  201. package/dist/ms-Z7LMXJWL.mjs.map +0 -1
  202. package/dist/nl-6SJFBPJ3.mjs.map +0 -1
  203. package/dist/no-YXPBPSGF.mjs.map +0 -1
  204. package/dist/pl-P4AZ2QME.mjs.map +0 -1
  205. package/dist/pt-LHWUS6U6.mjs.map +0 -1
  206. package/dist/ro-EA5J2ZON.mjs.map +0 -1
  207. package/dist/sv-DATBS3UQ.mjs.map +0 -1
  208. package/dist/th-WTFJRWPT.mjs.map +0 -1
  209. package/dist/tr-IKO3RXOX.mjs.map +0 -1
  210. package/dist/uk-CF6CTTRK.mjs.map +0 -1
  211. package/dist/vi-AJLTXPZQ.mjs.map +0 -1
  212. package/dist/zh-U3ORHHYH.mjs.map +0 -1
  213. /package/dist/{en-DDLIXJCU.mjs.map → en-KJCJQ4OO.mjs.map} +0 -0
@@ -0,0 +1,231 @@
1
+ /**
2
+ * ResourceViewer Mode Switching Tests
3
+ *
4
+ * These tests ensure that switching between Browse and Annotate modes
5
+ * doesn't cause React Hook ordering violations.
6
+ *
7
+ * Bug: Previously had 3 separate useEventSubscriptions() calls causing
8
+ * "Rendered more hooks than during the previous render" error.
9
+ *
10
+ * Fix: Combined all event subscriptions into a single useEventSubscriptions() call.
11
+ */
12
+
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
14
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
15
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
16
+ import { ResourceViewer } from '../ResourceViewer';
17
+ import { EventBusProvider } from '../../../contexts/EventBusContext';
18
+ import { CacheProvider } from '../../../contexts/CacheContext';
19
+ import { TranslationProvider } from '../../../contexts/TranslationContext';
20
+ import { ResourceAnnotationsProvider } from '../../../contexts/ResourceAnnotationsContext';
21
+ import { ApiClientProvider } from '../../../contexts/ApiClientContext';
22
+ import { AuthTokenProvider } from '../../../contexts/AuthTokenContext';
23
+ import type { components } from '@semiont/api-client';
24
+
25
+ type SemiontResource = components['schemas']['ResourceDescriptor'];
26
+
27
+ // Mock dependencies
28
+ vi.mock('../../../hooks/useObservableNavigation', () => ({
29
+ useObservableExternalNavigation: () => vi.fn(),
30
+ }));
31
+
32
+ const mockResource: SemiontResource & { content: string } = {
33
+ '@context': 'https://www.w3.org/ns/activitystreams',
34
+ '@id': 'http://example.com/resources/test-123',
35
+ name: 'Test Document',
36
+ created: '2024-01-01T00:00:00Z',
37
+ entityTypes: [],
38
+ archived: false,
39
+ representations: [
40
+ {
41
+ mediaType: 'text/plain',
42
+ byteSize: 100,
43
+ },
44
+ ],
45
+ content: 'This is test content for mode switching.',
46
+ };
47
+
48
+ const mockAnnotations = {
49
+ highlights: [],
50
+ references: [],
51
+ assessments: [],
52
+ comments: [],
53
+ tags: [],
54
+ };
55
+
56
+ const mockCacheManager = {
57
+ invalidateAnnotations: vi.fn(),
58
+ invalidate: vi.fn(),
59
+ invalidateEvents: vi.fn(),
60
+ };
61
+
62
+ const queryClient = new QueryClient({
63
+ defaultOptions: {
64
+ queries: { retry: false },
65
+ mutations: { retry: false },
66
+ },
67
+ });
68
+
69
+ const mockTranslationManager = {
70
+ t: (namespace: string, key: string, params?: Record<string, any>) => {
71
+ return `${namespace}.${key}`;
72
+ },
73
+ };
74
+
75
+ function TestWrapper({ children }: { children: React.ReactNode }) {
76
+ return (
77
+ <TranslationProvider translationManager={mockTranslationManager}>
78
+ <AuthTokenProvider token="test-token">
79
+ <ApiClientProvider baseUrl="http://localhost:4000">
80
+ <QueryClientProvider client={queryClient}>
81
+ <EventBusProvider>
82
+ <ResourceAnnotationsProvider>
83
+ <CacheProvider cacheManager={mockCacheManager}>
84
+ {children}
85
+ </CacheProvider>
86
+ </ResourceAnnotationsProvider>
87
+ </EventBusProvider>
88
+ </QueryClientProvider>
89
+ </ApiClientProvider>
90
+ </AuthTokenProvider>
91
+ </TranslationProvider>
92
+ );
93
+ }
94
+
95
+ describe('ResourceViewer - Mode Switching', () => {
96
+ beforeEach(() => {
97
+ vi.clearAllMocks();
98
+ // Clear localStorage before each test
99
+ if (typeof localStorage !== 'undefined') {
100
+ localStorage.clear();
101
+ }
102
+ });
103
+
104
+ it('should render without crashing in browse mode', () => {
105
+ render(
106
+ <TestWrapper>
107
+ <ResourceViewer
108
+ resource={mockResource}
109
+ annotations={mockAnnotations}
110
+ />
111
+ </TestWrapper>
112
+ );
113
+
114
+ expect(screen.getByText('This is test content for mode switching.')).toBeInTheDocument();
115
+ });
116
+
117
+ it('should switch to annotate mode without hook ordering errors', async () => {
118
+ const { rerender } = render(
119
+ <TestWrapper>
120
+ <ResourceViewer
121
+ resource={mockResource}
122
+ annotations={mockAnnotations}
123
+ />
124
+ </TestWrapper>
125
+ );
126
+
127
+ // Initial render in browse mode
128
+ expect(screen.getByText('This is test content for mode switching.')).toBeInTheDocument();
129
+
130
+ // Simulate mode toggle by setting localStorage and triggering re-render
131
+ localStorage.setItem('annotateMode', 'true');
132
+
133
+ // Re-render (simulating what would happen when the event fires)
134
+ rerender(
135
+ <TestWrapper>
136
+ <ResourceViewer
137
+ resource={mockResource}
138
+ annotations={mockAnnotations}
139
+ />
140
+ </TestWrapper>
141
+ );
142
+
143
+ // Should still render without errors
144
+ expect(screen.getByText('This is test content for mode switching.')).toBeInTheDocument();
145
+ });
146
+
147
+ it('should toggle between browse and annotate modes multiple times without errors', async () => {
148
+ const { rerender } = render(
149
+ <TestWrapper>
150
+ <ResourceViewer
151
+ resource={mockResource}
152
+ annotations={mockAnnotations}
153
+ />
154
+ </TestWrapper>
155
+ );
156
+
157
+ // Toggle to annotate
158
+ localStorage.setItem('annotateMode', 'true');
159
+ rerender(
160
+ <TestWrapper>
161
+ <ResourceViewer
162
+ resource={mockResource}
163
+ annotations={mockAnnotations}
164
+ />
165
+ </TestWrapper>
166
+ );
167
+ expect(screen.getByText('This is test content for mode switching.')).toBeInTheDocument();
168
+
169
+ // Toggle back to browse
170
+ localStorage.setItem('annotateMode', 'false');
171
+ rerender(
172
+ <TestWrapper>
173
+ <ResourceViewer
174
+ resource={mockResource}
175
+ annotations={mockAnnotations}
176
+ />
177
+ </TestWrapper>
178
+ );
179
+ expect(screen.getByText('This is test content for mode switching.')).toBeInTheDocument();
180
+
181
+ // Toggle to annotate again
182
+ localStorage.setItem('annotateMode', 'true');
183
+ rerender(
184
+ <TestWrapper>
185
+ <ResourceViewer
186
+ resource={mockResource}
187
+ annotations={mockAnnotations}
188
+ />
189
+ </TestWrapper>
190
+ );
191
+ expect(screen.getByText('This is test content for mode switching.')).toBeInTheDocument();
192
+
193
+ // No React Hook ordering errors should occur
194
+ });
195
+
196
+ it('should maintain consistent hook calls across mode switches', async () => {
197
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
198
+
199
+ const { rerender } = render(
200
+ <TestWrapper>
201
+ <ResourceViewer
202
+ resource={mockResource}
203
+ annotations={mockAnnotations}
204
+ />
205
+ </TestWrapper>
206
+ );
207
+
208
+ // Multiple rapid mode switches
209
+ for (let i = 0; i < 5; i++) {
210
+ localStorage.setItem('annotateMode', i % 2 === 0 ? 'true' : 'false');
211
+ rerender(
212
+ <TestWrapper>
213
+ <ResourceViewer
214
+ resource={mockResource}
215
+ annotations={mockAnnotations}
216
+ />
217
+ </TestWrapper>
218
+ );
219
+ }
220
+
221
+ // Should not have any React Hook warnings
222
+ const hookErrors = consoleError.mock.calls.filter(call =>
223
+ call[0]?.toString().includes('Rendered more hooks') ||
224
+ call[0]?.toString().includes('Rendered fewer hooks')
225
+ );
226
+
227
+ expect(hookErrors).toHaveLength(0);
228
+
229
+ consoleError.mockRestore();
230
+ });
231
+ });
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef } from 'react';
3
+ import { forwardRef } from 'react';
4
4
  import type { components } from '@semiont/api-client';
5
5
  import { getAnnotationExactText } from '@semiont/api-client';
6
+ import { useEventBus } from '../../../contexts/EventBusContext';
6
7
 
7
8
  type Annotation = components['schemas']['Annotation'];
8
9
 
@@ -17,9 +18,7 @@ interface TextualBody {
17
18
  interface AssessmentEntryProps {
18
19
  assessment: Annotation;
19
20
  isFocused: boolean;
20
- onClick: () => void;
21
- onAssessmentRef: (assessmentId: string, el: HTMLElement | null) => void;
22
- onAssessmentHover?: (assessmentId: string | null) => void;
21
+ isHovered?: boolean;
23
22
  }
24
23
 
25
24
  function formatRelativeTime(isoString: string): string {
@@ -67,42 +66,35 @@ function getAssessmentText(annotation: Annotation): string | null {
67
66
  return null;
68
67
  }
69
68
 
70
- export function AssessmentEntry({
71
- assessment,
72
- isFocused,
73
- onClick,
74
- onAssessmentRef,
75
- onAssessmentHover,
76
- }: AssessmentEntryProps) {
77
- const assessmentRef = useRef<HTMLDivElement>(null);
78
-
79
- // Register ref with parent
80
- useEffect(() => {
81
- onAssessmentRef(assessment.id, assessmentRef.current);
82
- return () => {
83
- onAssessmentRef(assessment.id, null);
84
- };
85
- }, [assessment.id, onAssessmentRef]);
86
-
87
- // Scroll to assessment when focused
88
- useEffect(() => {
89
- if (isFocused && assessmentRef.current) {
90
- assessmentRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
91
- }
92
- }, [isFocused]);
69
+ export const AssessmentEntry = forwardRef<HTMLDivElement, AssessmentEntryProps>(
70
+ function AssessmentEntry(
71
+ {
72
+ assessment,
73
+ isFocused,
74
+ isHovered = false,
75
+ },
76
+ ref
77
+ ) {
78
+ const eventBus = useEventBus();
93
79
 
94
80
  const selectedText = getAnnotationExactText(assessment);
95
81
  const assessmentText = getAssessmentText(assessment);
96
82
 
97
83
  return (
98
84
  <div
99
- ref={assessmentRef}
100
- className="semiont-annotation-entry"
85
+ ref={ref}
86
+ className={`semiont-annotation-entry${isHovered ? ' semiont-annotation-pulse' : ''}`}
101
87
  data-type="assessment"
102
88
  data-focused={isFocused ? 'true' : 'false'}
103
- onClick={onClick}
104
- onMouseEnter={() => onAssessmentHover?.(assessment.id)}
105
- onMouseLeave={() => onAssessmentHover?.(null)}
89
+ onClick={() => {
90
+ eventBus.emit('annotation:click', { annotationId: assessment.id, motivation: assessment.motivation });
91
+ }}
92
+ onMouseEnter={() => {
93
+ eventBus.emit('annotation:hover', { annotationId: assessment.id });
94
+ }}
95
+ onMouseLeave={() => {
96
+ eventBus.emit('annotation:hover', { annotationId: null });
97
+ }}
106
98
  >
107
99
  {/* Selected text quote */}
108
100
  {selectedText && (
@@ -124,4 +116,4 @@ export function AssessmentEntry({
124
116
  </div>
125
117
  </div>
126
118
  );
127
- }
119
+ });
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect } from 'react';
3
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
- import { useMakeMeaningEvents } from '../../../contexts/MakeMeaningEventBusContext';
5
+ import { useEventBus } from '../../../contexts/EventBusContext';
6
+ import { useEventSubscriptions } from '../../../contexts/useEventSubscription';
6
7
  import type { components, Selector } from '@semiont/api-client';
8
+ import { getTextPositionSelector, getTargetSelector } from '@semiont/api-client';
7
9
  import { AssessmentEntry } from './AssessmentEntry';
8
- import { useAnnotationPanel } from '../../../hooks/useAnnotationPanel';
9
10
  import { DetectSection } from './DetectSection';
10
11
  import { PanelHeader } from './PanelHeader';
11
12
  import './AssessmentPanel.css';
@@ -38,13 +39,7 @@ function getSelectorDisplayText(selector: Selector | Selector[]): string | null
38
39
 
39
40
  interface AssessmentPanelProps {
40
41
  annotations: Annotation[];
41
- onAnnotationClick: (annotation: Annotation) => void;
42
- focusedAnnotationId: string | null;
43
- hoveredAnnotationId?: string | null;
44
- onAnnotationHover?: (annotationId: string | null) => void;
45
- onCreate: (assessmentText: string) => void;
46
42
  pendingAnnotation: PendingAnnotation | null;
47
- onDetect?: (instructions?: string) => void | Promise<void>;
48
43
  isDetecting?: boolean;
49
44
  detectionProgress?: {
50
45
  status: string;
@@ -52,31 +47,105 @@ interface AssessmentPanelProps {
52
47
  message?: string;
53
48
  } | null;
54
49
  annotateMode?: boolean;
50
+ scrollToAnnotationId?: string | null;
51
+ onScrollCompleted?: () => void;
52
+ hoveredAnnotationId?: string | null;
55
53
  }
56
54
 
55
+ /**
56
+ * Panel for managing assessment annotations with text input
57
+ *
58
+ * @emits annotation:create - Create new assessment annotation. Payload: { motivation: 'assessing', selector: Selector | Selector[], body: Body[] }
59
+ * @emits annotation:cancel-pending - Cancel pending assessment annotation. Payload: undefined
60
+ * @subscribes annotation:click - Annotation clicked. Payload: { annotationId: string }
61
+ */
57
62
  export function AssessmentPanel({
58
63
  annotations,
59
- onAnnotationClick,
60
- focusedAnnotationId,
61
- hoveredAnnotationId,
62
- onAnnotationHover,
63
- onCreate,
64
64
  pendingAnnotation,
65
- onDetect,
66
65
  isDetecting = false,
67
66
  detectionProgress,
68
67
  annotateMode = true,
68
+ scrollToAnnotationId,
69
+ onScrollCompleted,
70
+ hoveredAnnotationId,
69
71
  }: AssessmentPanelProps) {
70
72
  const t = useTranslations('AssessmentPanel');
71
- const eventBus = useMakeMeaningEvents();
73
+ const eventBus = useEventBus();
72
74
  const [newAssessmentText, setNewAssessmentText] = useState('');
75
+ const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
76
+ const containerRef = useRef<HTMLDivElement>(null);
77
+
78
+ // Direct ref management
79
+ const entryRefs = useRef<Map<string, HTMLDivElement>>(new Map());
80
+
81
+ // Sort annotations by their position in the resource
82
+ const sortedAnnotations = useMemo(() => {
83
+ return [...annotations].sort((a, b) => {
84
+ const aSelector = getTextPositionSelector(getTargetSelector(a.target));
85
+ const bSelector = getTextPositionSelector(getTargetSelector(b.target));
86
+ if (!aSelector || !bSelector) return 0;
87
+ return aSelector.start - bSelector.start;
88
+ });
89
+ }, [annotations]);
90
+
91
+ // Ref callback for entry components
92
+ const setEntryRef = useCallback((id: string, element: HTMLDivElement | null) => {
93
+ if (element) {
94
+ entryRefs.current.set(id, element);
95
+ } else {
96
+ entryRefs.current.delete(id);
97
+ }
98
+ }, []);
99
+
100
+ // Handle scrollToAnnotationId (click scroll)
101
+ useEffect(() => {
102
+ if (!scrollToAnnotationId) return;
103
+ const element = entryRefs.current.get(scrollToAnnotationId);
104
+ if (element && containerRef.current) {
105
+ const elementTop = element.offsetTop;
106
+ const containerHeight = containerRef.current.clientHeight;
107
+ const elementHeight = element.offsetHeight;
108
+ const scrollTo = elementTop - (containerHeight / 2) + (elementHeight / 2);
109
+ containerRef.current.scrollTo({ top: scrollTo, behavior: 'smooth' });
110
+ element.classList.remove('semiont-annotation-pulse');
111
+ void element.offsetWidth;
112
+ element.classList.add('semiont-annotation-pulse');
113
+ if (onScrollCompleted) onScrollCompleted();
114
+ }
115
+ }, [scrollToAnnotationId]);
73
116
 
74
- const { sortedAnnotations, containerRef, handleAnnotationRef } =
75
- useAnnotationPanel(annotations, hoveredAnnotationId);
117
+ // Handle hoveredAnnotationId (hover scroll only - pulse is handled by isHovered prop)
118
+ useEffect(() => {
119
+ if (!hoveredAnnotationId) return;
120
+ const element = entryRefs.current.get(hoveredAnnotationId);
121
+ if (!element || !containerRef.current) return;
122
+
123
+ const container = containerRef.current;
124
+ const elementRect = element.getBoundingClientRect();
125
+ const containerRect = container.getBoundingClientRect();
126
+ const isVisible = elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom;
127
+ if (!isVisible) {
128
+ const elementTop = element.offsetTop;
129
+ const containerHeight = container.clientHeight;
130
+ const elementHeight = element.offsetHeight;
131
+ const scrollTo = elementTop - (containerHeight / 2) + (elementHeight / 2);
132
+ container.scrollTo({ top: scrollTo, behavior: 'smooth' });
133
+ }
134
+
135
+ // Pulse effect is handled by isHovered prop on AssessmentEntry
136
+ }, [hoveredAnnotationId]);
76
137
 
77
138
  const handleSaveNewAssessment = () => {
78
- if (newAssessmentText.trim()) {
79
- onCreate(newAssessmentText);
139
+ if (pendingAnnotation) {
140
+ const body: components['schemas']['AnnotationBody'][] = newAssessmentText.trim()
141
+ ? [{ type: 'TextualBody' as const, value: newAssessmentText, purpose: 'assessing' as const }]
142
+ : [];
143
+
144
+ eventBus.emit('annotation:create', {
145
+ motivation: 'assessing',
146
+ selector: pendingAnnotation.selector,
147
+ body,
148
+ });
80
149
  setNewAssessmentText('');
81
150
  }
82
151
  };
@@ -87,14 +156,25 @@ export function AssessmentPanel({
87
156
 
88
157
  const handleEscape = (e: KeyboardEvent) => {
89
158
  if (e.key === 'Escape') {
90
- eventBus.emit('ui:annotation:cancel-pending');
159
+ eventBus.emit('annotation:cancel-pending', undefined);
91
160
  setNewAssessmentText('');
92
161
  }
93
162
  };
94
163
 
95
164
  document.addEventListener('keydown', handleEscape);
96
165
  return () => document.removeEventListener('keydown', handleEscape);
97
- }, [pendingAnnotation, eventBus]);
166
+ }, [pendingAnnotation]);
167
+
168
+ // Event handler for annotation clicks (extracted to avoid inline arrow function)
169
+ const handleAnnotationClick = useCallback(({ annotationId }: { annotationId: string }) => {
170
+ setFocusedAnnotationId(annotationId);
171
+ setTimeout(() => setFocusedAnnotationId(null), 3000);
172
+ }, []);
173
+
174
+ // Subscribe to click events - update focused state
175
+ useEventSubscriptions({
176
+ 'annotation:click': handleAnnotationClick,
177
+ });
98
178
 
99
179
  return (
100
180
  <div className="semiont-panel">
@@ -129,7 +209,7 @@ export function AssessmentPanel({
129
209
  <div className="semiont-annotation-prompt__actions">
130
210
  <button
131
211
  onClick={() => {
132
- eventBus.emit('ui:annotation:cancel-pending');
212
+ eventBus.emit('annotation:cancel-pending', undefined);
133
213
  setNewAssessmentText('');
134
214
  }}
135
215
  className="semiont-button semiont-button--secondary"
@@ -152,12 +232,11 @@ export function AssessmentPanel({
152
232
  {/* Scrollable content area */}
153
233
  <div ref={containerRef} className="semiont-panel__content">
154
234
  {/* Detection Section - only in Annotate mode and for text resources */}
155
- {annotateMode && onDetect && (
235
+ {annotateMode && (
156
236
  <DetectSection
157
237
  annotationType="assessment"
158
238
  isDetecting={isDetecting}
159
239
  detectionProgress={detectionProgress}
160
- onDetect={onDetect}
161
240
  />
162
241
  )}
163
242
 
@@ -173,9 +252,8 @@ export function AssessmentPanel({
173
252
  key={assessment.id}
174
253
  assessment={assessment}
175
254
  isFocused={assessment.id === focusedAnnotationId}
176
- onClick={() => onAnnotationClick(assessment)}
177
- onAssessmentRef={handleAnnotationRef}
178
- {...(onAnnotationHover && { onAssessmentHover: onAnnotationHover })}
255
+ isHovered={assessment.id === hoveredAnnotationId}
256
+ ref={(el) => setEntryRef(assessment.id, el)}
179
257
  />
180
258
  ))
181
259
  )}
@@ -1,18 +1,17 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useRef } from 'react';
3
+ import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
5
  import type { components } from '@semiont/api-client';
6
6
  import { getAnnotationExactText, getCommentText } from '@semiont/api-client';
7
+ import { useEventBus } from '../../../contexts/EventBusContext';
7
8
 
8
9
  type Annotation = components['schemas']['Annotation'];
9
10
 
10
11
  interface CommentEntryProps {
11
12
  comment: Annotation;
12
13
  isFocused: boolean;
13
- onClick: () => void;
14
- onCommentRef: (commentId: string, el: HTMLElement | null) => void;
15
- onCommentHover?: (commentId: string | null) => void;
14
+ isHovered?: boolean;
16
15
  annotateMode?: boolean;
17
16
  }
18
17
 
@@ -33,37 +32,38 @@ function formatRelativeTime(isoString: string): string {
33
32
  return date.toLocaleDateString();
34
33
  }
35
34
 
36
- export function CommentEntry({
37
- comment,
38
- isFocused,
39
- onClick,
40
- onCommentRef,
41
- onCommentHover,
42
- annotateMode = true,
43
- }: CommentEntryProps) {
35
+ export const CommentEntry = forwardRef<HTMLDivElement, CommentEntryProps>(
36
+ function CommentEntry(
37
+ {
38
+ comment,
39
+ isFocused,
40
+ isHovered = false,
41
+ annotateMode = true,
42
+ },
43
+ ref
44
+ ) {
44
45
  const t = useTranslations('CommentsPanel');
46
+ const eventBus = useEventBus();
45
47
  const [isEditing, setIsEditing] = useState(false);
46
48
  const [editText, setEditText] = useState('');
47
- const commentRef = useRef<HTMLDivElement>(null);
49
+ const internalRef = useRef<HTMLDivElement>(null);
48
50
 
49
- // Register ref with parent
50
- useEffect(() => {
51
- onCommentRef(comment.id, commentRef.current);
52
- return () => {
53
- onCommentRef(comment.id, null);
54
- };
55
- }, [comment.id, onCommentRef]);
51
+ // Combine external ref with internal ref
52
+ useImperativeHandle(ref, () => internalRef.current!);
53
+
54
+ const commentText = getCommentText(comment) || '';
55
+ const selectedText = getAnnotationExactText(comment);
56
56
 
57
- // Scroll to comment when focused
57
+ // Scroll into view when focused
58
58
  useEffect(() => {
59
- if (isFocused && commentRef.current) {
60
- commentRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
59
+ if (isFocused && internalRef.current) {
60
+ internalRef.current.scrollIntoView({
61
+ behavior: 'smooth',
62
+ block: 'center',
63
+ });
61
64
  }
62
65
  }, [isFocused]);
63
66
 
64
- const commentText = getCommentText(comment) || '';
65
- const selectedText = getAnnotationExactText(comment);
66
-
67
67
  const handleEditClick = () => {
68
68
  setEditText(commentText);
69
69
  setIsEditing(true);
@@ -81,13 +81,19 @@ export function CommentEntry({
81
81
 
82
82
  return (
83
83
  <div
84
- ref={commentRef}
85
- className="semiont-annotation-entry"
84
+ ref={internalRef}
85
+ className={`semiont-annotation-entry${isHovered ? ' semiont-annotation-pulse' : ''}`}
86
86
  data-type="comment"
87
87
  data-focused={isFocused ? 'true' : 'false'}
88
- onClick={onClick}
89
- onMouseEnter={() => onCommentHover?.(comment.id)}
90
- onMouseLeave={() => onCommentHover?.(null)}
88
+ onClick={() => {
89
+ eventBus.emit('annotation:click', { annotationId: comment.id, motivation: comment.motivation });
90
+ }}
91
+ onMouseEnter={() => {
92
+ eventBus.emit('annotation:hover', { annotationId: comment.id });
93
+ }}
94
+ onMouseLeave={() => {
95
+ eventBus.emit('annotation:hover', { annotationId: null });
96
+ }}
91
97
  >
92
98
  {/* Selected text quote - only for text annotations */}
93
99
  {selectedText && (
@@ -153,4 +159,4 @@ export function CommentEntry({
153
159
  )}
154
160
  </div>
155
161
  );
156
- }
162
+ });