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

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 (147) hide show
  1. package/dist/{ar-RNNSPLQB.mjs → ar-EMHEHPCJ.mjs} +2 -1
  2. package/dist/ar-EMHEHPCJ.mjs.map +1 -0
  3. package/dist/{bn-S2CDL7EC.mjs → bn-OVCI4F6X.mjs} +2 -1
  4. package/dist/bn-OVCI4F6X.mjs.map +1 -0
  5. package/dist/{chunk-35LLVRFK.mjs → chunk-JZIO2A3B.mjs} +31 -31
  6. package/dist/{chunk-UDX2Q35T.mjs → chunk-LIHZTECW.mjs} +2 -1
  7. package/dist/chunk-LIHZTECW.mjs.map +1 -0
  8. package/dist/{cs-RSV675WU.mjs → cs-FAN66Q2F.mjs} +2 -1
  9. package/dist/cs-FAN66Q2F.mjs.map +1 -0
  10. package/dist/{da-CHXNPWJC.mjs → da-YBBIHI2O.mjs} +2 -1
  11. package/dist/da-YBBIHI2O.mjs.map +1 -0
  12. package/dist/{de-KPEZ53D4.mjs → de-MAYU33LB.mjs} +2 -1
  13. package/dist/de-MAYU33LB.mjs.map +1 -0
  14. package/dist/{el-MW2BME5T.mjs → el-MKGSWN4O.mjs} +2 -1
  15. package/dist/el-MKGSWN4O.mjs.map +1 -0
  16. package/dist/{en-EVMIX24Y.mjs → en-DDLIXJCU.mjs} +2 -2
  17. package/dist/{es-HQ24NYS3.mjs → es-52LHUWJD.mjs} +2 -1
  18. package/dist/es-52LHUWJD.mjs.map +1 -0
  19. package/dist/{fa-W34LRLHG.mjs → fa-FJICRANB.mjs} +2 -1
  20. package/dist/fa-FJICRANB.mjs.map +1 -0
  21. package/dist/{fi-3U44IGOA.mjs → fi-O455XFCR.mjs} +2 -1
  22. package/dist/fi-O455XFCR.mjs.map +1 -0
  23. package/dist/{fr-N7DKX6NN.mjs → fr-TXIXHOOE.mjs} +2 -1
  24. package/dist/fr-TXIXHOOE.mjs.map +1 -0
  25. package/dist/{he-CS4WRXN3.mjs → he-JBSOX5IN.mjs} +2 -1
  26. package/dist/he-JBSOX5IN.mjs.map +1 -0
  27. package/dist/{hi-GJDY46KA.mjs → hi-KGHI3XVT.mjs} +2 -1
  28. package/dist/hi-KGHI3XVT.mjs.map +1 -0
  29. package/dist/{id-WAEZJK2Y.mjs → id-5OCPPZLO.mjs} +2 -1
  30. package/dist/id-5OCPPZLO.mjs.map +1 -0
  31. package/dist/index.d.mts +102 -106
  32. package/dist/index.mjs +1814 -1450
  33. package/dist/index.mjs.map +1 -1
  34. package/dist/{it-VDNDMZPU.mjs → it-PNBBZSM2.mjs} +2 -1
  35. package/dist/it-PNBBZSM2.mjs.map +1 -0
  36. package/dist/{ja-5PEH56J5.mjs → ja-LDD7R3TJ.mjs} +2 -1
  37. package/dist/ja-LDD7R3TJ.mjs.map +1 -0
  38. package/dist/{ko-JYPL3WVA.mjs → ko-F47ZDEY3.mjs} +2 -1
  39. package/dist/ko-F47ZDEY3.mjs.map +1 -0
  40. package/dist/{ms-5PZVW76T.mjs → ms-Z7LMXJWL.mjs} +2 -1
  41. package/dist/ms-Z7LMXJWL.mjs.map +1 -0
  42. package/dist/{nl-YXES36KM.mjs → nl-6SJFBPJ3.mjs} +2 -1
  43. package/dist/nl-6SJFBPJ3.mjs.map +1 -0
  44. package/dist/{no-XRA2UCQD.mjs → no-YXPBPSGF.mjs} +2 -1
  45. package/dist/no-YXPBPSGF.mjs.map +1 -0
  46. package/dist/{pl-WH6LJA5G.mjs → pl-P4AZ2QME.mjs} +2 -1
  47. package/dist/pl-P4AZ2QME.mjs.map +1 -0
  48. package/dist/{pt-7GAG57BM.mjs → pt-LHWUS6U6.mjs} +2 -1
  49. package/dist/pt-LHWUS6U6.mjs.map +1 -0
  50. package/dist/{ro-BTDDRB7N.mjs → ro-EA5J2ZON.mjs} +2 -1
  51. package/dist/ro-EA5J2ZON.mjs.map +1 -0
  52. package/dist/{sv-7V5C2IT4.mjs → sv-DATBS3UQ.mjs} +2 -1
  53. package/dist/sv-DATBS3UQ.mjs.map +1 -0
  54. package/dist/test-utils.mjs +2 -2
  55. package/dist/{th-LPKYLBX5.mjs → th-WTFJRWPT.mjs} +2 -1
  56. package/dist/th-WTFJRWPT.mjs.map +1 -0
  57. package/dist/{tr-DU4RQL4M.mjs → tr-IKO3RXOX.mjs} +2 -1
  58. package/dist/tr-IKO3RXOX.mjs.map +1 -0
  59. package/dist/{uk-36UHTDDI.mjs → uk-CF6CTTRK.mjs} +2 -1
  60. package/dist/uk-CF6CTTRK.mjs.map +1 -0
  61. package/dist/{vi-GDHOUZDH.mjs → vi-AJLTXPZQ.mjs} +2 -1
  62. package/dist/vi-AJLTXPZQ.mjs.map +1 -0
  63. package/dist/{zh-TYUID4XZ.mjs → zh-U3ORHHYH.mjs} +2 -1
  64. package/dist/zh-U3ORHHYH.mjs.map +1 -0
  65. package/package.json +6 -2
  66. package/src/components/resource/AnnotateView.tsx +0 -4
  67. package/src/components/resource/AnnotationHistory.tsx +12 -13
  68. package/src/components/resource/BrowseView.tsx +8 -16
  69. package/src/components/resource/HistoryEvent.tsx +3 -4
  70. package/src/components/resource/ResourceViewer.tsx +174 -201
  71. package/src/components/resource/event-formatting.ts +316 -0
  72. package/src/components/resource/panels/AssessmentPanel.tsx +37 -9
  73. package/src/components/resource/panels/CollaborationPanel.tsx +20 -13
  74. package/src/components/resource/panels/CommentsPanel.tsx +38 -9
  75. package/src/components/resource/panels/ReferencesPanel.tsx +39 -14
  76. package/src/components/resource/panels/StatisticsPanel.tsx +9 -19
  77. package/src/components/resource/panels/TaggingPanel.tsx +27 -0
  78. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +28 -21
  79. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +547 -0
  80. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +10 -0
  81. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +10 -0
  82. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +564 -0
  83. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +8 -15
  84. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +13 -6
  85. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +147 -78
  86. package/src/styles/motivations/motivation-assessment.css +28 -0
  87. package/src/styles/patterns/panel-helpers.css +26 -0
  88. package/translations/ar.json +1 -0
  89. package/translations/bn.json +1 -0
  90. package/translations/cs.json +1 -0
  91. package/translations/da.json +1 -0
  92. package/translations/de.json +1 -0
  93. package/translations/el.json +1 -0
  94. package/translations/en.json +1 -0
  95. package/translations/es.json +1 -0
  96. package/translations/fa.json +1 -0
  97. package/translations/fi.json +1 -0
  98. package/translations/fr.json +1 -0
  99. package/translations/he.json +1 -0
  100. package/translations/hi.json +1 -0
  101. package/translations/id.json +1 -0
  102. package/translations/it.json +1 -0
  103. package/translations/ja.json +1 -0
  104. package/translations/ko.json +1 -0
  105. package/translations/ms.json +1 -0
  106. package/translations/nl.json +1 -0
  107. package/translations/no.json +1 -0
  108. package/translations/pl.json +1 -0
  109. package/translations/pt.json +1 -0
  110. package/translations/ro.json +1 -0
  111. package/translations/sv.json +1 -0
  112. package/translations/th.json +1 -0
  113. package/translations/tr.json +1 -0
  114. package/translations/uk.json +1 -0
  115. package/translations/vi.json +1 -0
  116. package/translations/zh.json +1 -0
  117. package/dist/ar-RNNSPLQB.mjs.map +0 -1
  118. package/dist/bn-S2CDL7EC.mjs.map +0 -1
  119. package/dist/chunk-UDX2Q35T.mjs.map +0 -1
  120. package/dist/cs-RSV675WU.mjs.map +0 -1
  121. package/dist/da-CHXNPWJC.mjs.map +0 -1
  122. package/dist/de-KPEZ53D4.mjs.map +0 -1
  123. package/dist/el-MW2BME5T.mjs.map +0 -1
  124. package/dist/es-HQ24NYS3.mjs.map +0 -1
  125. package/dist/fa-W34LRLHG.mjs.map +0 -1
  126. package/dist/fi-3U44IGOA.mjs.map +0 -1
  127. package/dist/fr-N7DKX6NN.mjs.map +0 -1
  128. package/dist/he-CS4WRXN3.mjs.map +0 -1
  129. package/dist/hi-GJDY46KA.mjs.map +0 -1
  130. package/dist/id-WAEZJK2Y.mjs.map +0 -1
  131. package/dist/it-VDNDMZPU.mjs.map +0 -1
  132. package/dist/ja-5PEH56J5.mjs.map +0 -1
  133. package/dist/ko-JYPL3WVA.mjs.map +0 -1
  134. package/dist/ms-5PZVW76T.mjs.map +0 -1
  135. package/dist/nl-YXES36KM.mjs.map +0 -1
  136. package/dist/no-XRA2UCQD.mjs.map +0 -1
  137. package/dist/pl-WH6LJA5G.mjs.map +0 -1
  138. package/dist/pt-7GAG57BM.mjs.map +0 -1
  139. package/dist/ro-BTDDRB7N.mjs.map +0 -1
  140. package/dist/sv-7V5C2IT4.mjs.map +0 -1
  141. package/dist/th-LPKYLBX5.mjs.map +0 -1
  142. package/dist/tr-DU4RQL4M.mjs.map +0 -1
  143. package/dist/uk-36UHTDDI.mjs.map +0 -1
  144. package/dist/vi-GDHOUZDH.mjs.map +0 -1
  145. package/dist/zh-TYUID4XZ.mjs.map +0 -1
  146. /package/dist/{chunk-35LLVRFK.mjs.map → chunk-JZIO2A3B.mjs.map} +0 -0
  147. /package/dist/{en-EVMIX24Y.mjs.map → en-DDLIXJCU.mjs.map} +0 -0
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Event Formatting Utilities
3
+ *
4
+ * Display and formatting utilities for resource events.
5
+ * No React dependencies - safe to use in any JavaScript environment.
6
+ */
7
+
8
+ import type { StoredEvent, ResourceEventType } from '@semiont/core';
9
+ import type { components } from '@semiont/api-client';
10
+ import { getExactText, getTargetSelector } from '@semiont/api-client';
11
+ import { ANNOTATORS } from '../../lib/annotation-registry';
12
+
13
+ type Annotation = components['schemas']['Annotation'];
14
+ type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
15
+
16
+ // =============================================================================
17
+ // EVENT FORMATTING AND DISPLAY
18
+ // =============================================================================
19
+
20
+ /**
21
+ * Format event type for display with i18n support
22
+ */
23
+ export function formatEventType(type: ResourceEventType, t: TranslateFn, payload?: any): string {
24
+ switch (type) {
25
+ case 'resource.created':
26
+ return t('resourceCreated');
27
+ case 'resource.cloned':
28
+ return t('resourceCloned');
29
+ case 'resource.archived':
30
+ return t('resourceArchived');
31
+ case 'resource.unarchived':
32
+ return t('resourceUnarchived');
33
+
34
+ case 'annotation.added': {
35
+ const motivation = payload?.annotation?.motivation;
36
+ if (motivation === 'highlighting') return t('highlightAdded');
37
+ if (motivation === 'linking') return t('referenceCreated');
38
+ if (motivation === 'assessing') return t('assessmentAdded');
39
+ return t('annotationAdded');
40
+ }
41
+ case 'annotation.removed': {
42
+ return t('annotationRemoved');
43
+ }
44
+ case 'annotation.body.updated': {
45
+ return t('annotationBodyUpdated');
46
+ }
47
+
48
+ case 'entitytag.added':
49
+ return t('entitytagAdded');
50
+ case 'entitytag.removed':
51
+ return t('entitytagRemoved');
52
+
53
+ case 'job.completed':
54
+ case 'job.started':
55
+ case 'job.progress':
56
+ case 'job.failed':
57
+ return t('jobEvent');
58
+
59
+ case 'representation.added':
60
+ case 'representation.removed':
61
+ return t('representationEvent');
62
+
63
+ default:
64
+ return type;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Get emoji for event type
70
+ * For unified annotation events, pass the payload to determine motivation
71
+ */
72
+ export function getEventEmoji(type: ResourceEventType, payload?: any): string {
73
+ switch (type) {
74
+ case 'resource.created':
75
+ case 'resource.cloned':
76
+ case 'resource.archived':
77
+ case 'resource.unarchived':
78
+ return '📄';
79
+
80
+ case 'annotation.added': {
81
+ const motivation = payload?.annotation?.motivation;
82
+ // Use annotation registry as single source of truth for emojis
83
+ if (motivation === 'highlighting') return ANNOTATORS.highlight.iconEmoji || '📝';
84
+ if (motivation === 'linking') return ANNOTATORS.reference.iconEmoji || '📝';
85
+ if (motivation === 'assessing') return ANNOTATORS.assessment.iconEmoji || '📝';
86
+ return '📝';
87
+ }
88
+ case 'annotation.removed': {
89
+ return '🗑️';
90
+ }
91
+ case 'annotation.body.updated': {
92
+ return '✏️';
93
+ }
94
+
95
+ case 'entitytag.added':
96
+ case 'entitytag.removed':
97
+ return '🏷️';
98
+
99
+ case 'job.completed':
100
+ return '🔗'; // Link emoji for linked document creation
101
+ case 'job.started':
102
+ case 'job.progress':
103
+ return '⚙️'; // Gear for job processing
104
+ case 'job.failed':
105
+ return '❌'; // X mark for failed jobs
106
+
107
+ case 'representation.added':
108
+ case 'representation.removed':
109
+ return '📄';
110
+
111
+ default:
112
+ return '📝';
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Format timestamp as relative time with i18n support
118
+ */
119
+ export function formatRelativeTime(timestamp: string, t: TranslateFn): string {
120
+ const date = new Date(timestamp);
121
+ const now = new Date();
122
+ const diffMs = now.getTime() - date.getTime();
123
+ const diffMins = Math.floor(diffMs / 60000);
124
+ const diffHours = Math.floor(diffMs / 3600000);
125
+ const diffDays = Math.floor(diffMs / 86400000);
126
+
127
+ if (diffMins < 1) return t('justNow');
128
+ if (diffMins < 60) return t('minutesAgo', { count: diffMins });
129
+ if (diffHours < 24) return t('hoursAgo', { count: diffHours });
130
+ if (diffDays < 7) return t('daysAgo', { count: diffDays });
131
+
132
+ return date.toLocaleDateString();
133
+ }
134
+
135
+ /**
136
+ * Helper to truncate text for display
137
+ */
138
+ function truncateText(text: string, maxLength = 50): string {
139
+ const trimmed = text.trim();
140
+ return trimmed.length > maxLength ? trimmed.substring(0, maxLength) + '...' : trimmed;
141
+ }
142
+
143
+ /**
144
+ * Get display content from event payload - complete implementation
145
+ */
146
+ export function getEventDisplayContent(
147
+ event: StoredEvent,
148
+ annotations: Annotation[], // Unified annotations array (all types)
149
+ allEvents: StoredEvent[]
150
+ ): { exact: string; isQuoted: boolean; isTag: boolean } | null {
151
+ const eventData = event.event;
152
+
153
+ // Use type discriminators for proper narrowing
154
+ switch (eventData.type) {
155
+ case 'resource.created':
156
+ case 'resource.cloned': {
157
+ return { exact: eventData.payload.name, isQuoted: false, isTag: false };
158
+ }
159
+
160
+ // Unified annotation events
161
+ case 'annotation.body.updated': {
162
+ // Find current annotation to get its text
163
+ // payload.annotationId is just the UUID, but annotation.id is the full URI
164
+ const annotation = annotations.find(a =>
165
+ a.id.endsWith(`/annotations/${eventData.payload.annotationId}`)
166
+ );
167
+
168
+ if (annotation?.target) {
169
+ try {
170
+ const targetSelector = getTargetSelector(annotation.target);
171
+ const exact = getExactText(targetSelector);
172
+ if (exact) {
173
+ return { exact: truncateText(exact), isQuoted: true, isTag: false };
174
+ }
175
+ } catch {
176
+ // If selector parsing fails, continue to return null
177
+ }
178
+ }
179
+ return null;
180
+ }
181
+
182
+ case 'annotation.removed': {
183
+ // Find the original annotation.added event to get the text
184
+ // payload.annotationId is just the UUID, but annotation.id in the added event is the full URI
185
+ const addedEvent = allEvents.find(e =>
186
+ e.event.type === 'annotation.added' &&
187
+ e.event.payload.annotation.id.endsWith(`/annotations/${eventData.payload.annotationId}`)
188
+ );
189
+ if (addedEvent && addedEvent.event.type === 'annotation.added') {
190
+ try {
191
+ const target = addedEvent.event.payload.annotation.target;
192
+ if (typeof target !== 'string' && target.selector) {
193
+ const exact = getExactText(target.selector);
194
+ if (exact) {
195
+ return { exact: truncateText(exact), isQuoted: true, isTag: false };
196
+ }
197
+ }
198
+ } catch {
199
+ // If selector parsing fails, return null
200
+ }
201
+ }
202
+ return null;
203
+ }
204
+
205
+ case 'annotation.added': {
206
+ // New unified event structure - annotation is in payload
207
+ try {
208
+ const target = eventData.payload.annotation.target;
209
+ if (typeof target !== 'string' && target.selector) {
210
+ const exact = getExactText(target.selector);
211
+ if (exact) {
212
+ return { exact: truncateText(exact), isQuoted: true, isTag: false };
213
+ }
214
+ }
215
+ } catch {
216
+ // If selector parsing fails, return null
217
+ }
218
+ return null;
219
+ }
220
+
221
+ case 'entitytag.added':
222
+ case 'entitytag.removed': {
223
+ return { exact: eventData.payload.entityType, isQuoted: false, isTag: true };
224
+ }
225
+
226
+ case 'job.completed': {
227
+ // Find the annotation that was used to generate the resource
228
+ if (eventData.payload.annotationUri) {
229
+ const annotation = annotations.find(a =>
230
+ a.id === eventData.payload.annotationUri
231
+ );
232
+
233
+ if (annotation?.target) {
234
+ try {
235
+ const targetSelector = getTargetSelector(annotation.target);
236
+ const exact = getExactText(targetSelector);
237
+ if (exact) {
238
+ return { exact: truncateText(exact), isQuoted: true, isTag: false };
239
+ }
240
+ } catch {
241
+ // If selector parsing fails, continue to return null
242
+ }
243
+ }
244
+ }
245
+ return null;
246
+ }
247
+
248
+ case 'job.started':
249
+ case 'job.progress':
250
+ case 'job.failed':
251
+ case 'representation.added':
252
+ case 'representation.removed':
253
+ return null;
254
+
255
+ default:
256
+ return null;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Get entity types from event payload
262
+ */
263
+ export function getEventEntityTypes(event: StoredEvent): string[] {
264
+ const eventData = event.event;
265
+
266
+ if (eventData.type === 'annotation.added') {
267
+ const motivation = eventData.payload.annotation.motivation;
268
+ const body = eventData.payload.annotation.body;
269
+ if (motivation === 'linking' && body && 'entityTypes' in body) {
270
+ return (body as any).entityTypes ?? [];
271
+ }
272
+ }
273
+
274
+ return [];
275
+ }
276
+
277
+ /**
278
+ * Resource creation details
279
+ */
280
+ export interface ResourceCreationDetails {
281
+ type: 'created' | 'cloned';
282
+ method: string;
283
+ userId?: string;
284
+ sourceDocId?: string; // For cloned resources
285
+ parentResourceId?: string;
286
+ metadata?: Record<string, any>;
287
+ }
288
+
289
+ /**
290
+ * Get resource creation details from event
291
+ */
292
+ export function getResourceCreationDetails(event: StoredEvent): ResourceCreationDetails | null {
293
+ const eventData = event.event;
294
+
295
+ if (eventData.type === 'resource.created') {
296
+ return {
297
+ type: 'created',
298
+ method: eventData.payload.creationMethod || 'unknown',
299
+ userId: eventData.userId,
300
+ metadata: undefined,
301
+ };
302
+ }
303
+
304
+ if (eventData.type === 'resource.cloned') {
305
+ return {
306
+ type: 'cloned',
307
+ method: eventData.payload.creationMethod || 'clone',
308
+ userId: eventData.userId,
309
+ sourceDocId: eventData.payload.parentResourceId,
310
+ parentResourceId: eventData.payload.parentResourceId,
311
+ metadata: undefined,
312
+ };
313
+ }
314
+
315
+ return null;
316
+ }
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
3
+ import { useState, useEffect } from 'react';
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
+ import { useMakeMeaningEvents } from '../../../contexts/MakeMeaningEventBusContext';
5
6
  import type { components, Selector } from '@semiont/api-client';
6
7
  import { AssessmentEntry } from './AssessmentEntry';
7
8
  import { useAnnotationPanel } from '../../../hooks/useAnnotationPanel';
@@ -67,6 +68,7 @@ export function AssessmentPanel({
67
68
  annotateMode = true,
68
69
  }: AssessmentPanelProps) {
69
70
  const t = useTranslations('AssessmentPanel');
71
+ const eventBus = useMakeMeaningEvents();
70
72
  const [newAssessmentText, setNewAssessmentText] = useState('');
71
73
 
72
74
  const { sortedAnnotations, containerRef, handleAnnotationRef } =
@@ -79,6 +81,21 @@ export function AssessmentPanel({
79
81
  }
80
82
  };
81
83
 
84
+ // Escape key handler for cancelling pending annotation
85
+ useEffect(() => {
86
+ if (!pendingAnnotation) return;
87
+
88
+ const handleEscape = (e: KeyboardEvent) => {
89
+ if (e.key === 'Escape') {
90
+ eventBus.emit('ui:annotation:cancel-pending');
91
+ setNewAssessmentText('');
92
+ }
93
+ };
94
+
95
+ document.addEventListener('keydown', handleEscape);
96
+ return () => document.removeEventListener('keydown', handleEscape);
97
+ }, [pendingAnnotation, eventBus]);
98
+
82
99
  return (
83
100
  <div className="semiont-panel">
84
101
  <PanelHeader annotationType="assessment" count={annotations.length} title={t('title')} />
@@ -109,14 +126,25 @@ export function AssessmentPanel({
109
126
  <span className="semiont-annotation-prompt__char-count">
110
127
  {newAssessmentText.length}/2000
111
128
  </span>
112
- <button
113
- onClick={handleSaveNewAssessment}
114
- disabled={!newAssessmentText.trim()}
115
- className="semiont-button semiont-button--primary"
116
- data-type="assessment"
117
- >
118
- {t('save')}
119
- </button>
129
+ <div className="semiont-annotation-prompt__actions">
130
+ <button
131
+ onClick={() => {
132
+ eventBus.emit('ui:annotation:cancel-pending');
133
+ setNewAssessmentText('');
134
+ }}
135
+ className="semiont-button semiont-button--secondary"
136
+ data-type="assessment"
137
+ >
138
+ {t('cancel')}
139
+ </button>
140
+ <button
141
+ onClick={handleSaveNewAssessment}
142
+ className="semiont-button semiont-button--primary"
143
+ data-type="assessment"
144
+ >
145
+ {t('save')}
146
+ </button>
147
+ </div>
120
148
  </div>
121
149
  </div>
122
150
  )}
@@ -1,6 +1,5 @@
1
1
  'use client';
2
2
 
3
- import { useMemo } from 'react';
4
3
  import { useTranslations } from '../../../contexts/TranslationContext';
5
4
  import './CollaborationPanel.css';
6
5
 
@@ -18,9 +17,10 @@ export function CollaborationPanel({
18
17
  const t = useTranslations('CollaborationPanel');
19
18
 
20
19
  // Format last sync time
21
- const lastSyncText = useMemo(() => {
22
- if (!lastEventTimestamp) return t('noActivity');
23
-
20
+ let lastSyncText: string;
21
+ if (!lastEventTimestamp) {
22
+ lastSyncText = t('noActivity');
23
+ } else {
24
24
  const now = new Date();
25
25
  const eventTime = new Date(lastEventTimestamp);
26
26
  const diffMs = now.getTime() - eventTime.getTime();
@@ -28,15 +28,22 @@ export function CollaborationPanel({
28
28
  const diffMins = Math.floor(diffSecs / 60);
29
29
  const diffHours = Math.floor(diffMins / 60);
30
30
 
31
- if (diffSecs < 10) return t('justNow');
32
- if (diffSecs < 60) return t('secondsAgo', { count: diffSecs });
33
- if (diffMins === 1) return t('minuteAgo');
34
- if (diffMins < 60) return t('minutesAgo', { count: diffMins });
35
- if (diffHours === 1) return t('hourAgo');
36
- if (diffHours < 24) return t('hoursAgo', { count: diffHours });
37
-
38
- return eventTime.toLocaleDateString();
39
- }, [lastEventTimestamp, t]);
31
+ if (diffSecs < 10) {
32
+ lastSyncText = t('justNow');
33
+ } else if (diffSecs < 60) {
34
+ lastSyncText = t('secondsAgo', { count: diffSecs });
35
+ } else if (diffMins === 1) {
36
+ lastSyncText = t('minuteAgo');
37
+ } else if (diffMins < 60) {
38
+ lastSyncText = t('minutesAgo', { count: diffMins });
39
+ } else if (diffHours === 1) {
40
+ lastSyncText = t('hourAgo');
41
+ } else if (diffHours < 24) {
42
+ lastSyncText = t('hoursAgo', { count: diffHours });
43
+ } else {
44
+ lastSyncText = eventTime.toLocaleDateString();
45
+ }
46
+ }
40
47
 
41
48
  return (
42
49
  <div className="semiont-collaboration-panel">
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
3
+ import { useState, useEffect } from 'react';
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
+ import { useMakeMeaningEvents } from '../../../contexts/MakeMeaningEventBusContext';
5
6
  import type { components, Selector } from '@semiont/api-client';
6
7
  import { CommentEntry } from './CommentEntry';
7
8
  import { useAnnotationPanel } from '../../../hooks/useAnnotationPanel';
@@ -67,6 +68,7 @@ export function CommentsPanel({
67
68
  detectionProgress,
68
69
  }: CommentsPanelProps) {
69
70
  const t = useTranslations('CommentsPanel');
71
+ const eventBus = useMakeMeaningEvents();
70
72
  const [newCommentText, setNewCommentText] = useState('');
71
73
 
72
74
  const { sortedAnnotations, containerRef, handleAnnotationRef } =
@@ -79,6 +81,21 @@ export function CommentsPanel({
79
81
  }
80
82
  };
81
83
 
84
+ // Escape key handler for cancelling pending annotation
85
+ useEffect(() => {
86
+ if (!pendingAnnotation) return;
87
+
88
+ const handleEscape = (e: KeyboardEvent) => {
89
+ if (e.key === 'Escape') {
90
+ eventBus.emit('ui:annotation:cancel-pending');
91
+ setNewCommentText('');
92
+ }
93
+ };
94
+
95
+ document.addEventListener('keydown', handleEscape);
96
+ return () => document.removeEventListener('keydown', handleEscape);
97
+ }, [pendingAnnotation, eventBus]);
98
+
82
99
  return (
83
100
  <div className="semiont-panel">
84
101
  <PanelHeader annotationType="comment" count={annotations.length} title={t('title')} />
@@ -109,14 +126,26 @@ export function CommentsPanel({
109
126
  <span className="semiont-annotation-prompt__char-count">
110
127
  {newCommentText.length}/2000
111
128
  </span>
112
- <button
113
- onClick={handleSaveNewComment}
114
- disabled={!newCommentText.trim()}
115
- className="semiont-button semiont-button--primary"
116
- data-type="comment"
117
- >
118
- {t('save')}
119
- </button>
129
+ <div className="semiont-annotation-prompt__actions">
130
+ <button
131
+ onClick={() => {
132
+ eventBus.emit('ui:annotation:cancel-pending');
133
+ setNewCommentText('');
134
+ }}
135
+ className="semiont-button semiont-button--secondary"
136
+ data-type="comment"
137
+ >
138
+ {t('cancel')}
139
+ </button>
140
+ <button
141
+ onClick={handleSaveNewComment}
142
+ disabled={!newCommentText.trim()}
143
+ className="semiont-button semiont-button--primary"
144
+ data-type="comment"
145
+ >
146
+ {t('save')}
147
+ </button>
148
+ </div>
120
149
  </div>
121
150
  </div>
122
151
  )}
@@ -2,13 +2,13 @@
2
2
 
3
3
  import React, { useState, useRef, useEffect } from 'react';
4
4
  import { useTranslations } from '../../../contexts/TranslationContext';
5
+ import { useMakeMeaningEvents } from '../../../contexts/MakeMeaningEventBusContext';
5
6
  import type { RouteBuilder, LinkComponentProps } from '../../../contexts/RoutingContext';
6
7
  import { DetectionProgressWidget } from '../../DetectionProgressWidget';
7
8
  import { ReferenceEntry } from './ReferenceEntry';
8
9
  import type { components, paths, Selector } from '@semiont/api-client';
9
10
  import { useAnnotationPanel } from '../../../hooks/useAnnotationPanel';
10
11
  import { PanelHeader } from './PanelHeader';
11
- import { supportsDetection } from '../../../lib/resource-utils';
12
12
  import './ReferencesPanel.css';
13
13
 
14
14
  type Annotation = components['schemas']['Annotation'];
@@ -66,7 +66,6 @@ interface Props {
66
66
  onGenerateDocument?: (referenceId: string, options: { title: string; prompt?: string }) => void;
67
67
  onCreateDocument?: (annotationUri: string, title: string, entityTypes: string[]) => void;
68
68
  generatingReferenceId?: string | null;
69
- mediaType?: string | undefined;
70
69
  referencedBy?: ReferencedBy[];
71
70
  referencedByLoading?: boolean;
72
71
  pendingAnnotation: PendingAnnotation | null;
@@ -91,13 +90,13 @@ export function ReferencesPanel({
91
90
  onGenerateDocument,
92
91
  onCreateDocument,
93
92
  generatingReferenceId,
94
- mediaType,
95
93
  referencedBy = [],
96
94
  referencedByLoading = false,
97
95
  pendingAnnotation,
98
96
  }: Props) {
99
97
  const t = useTranslations('DetectPanel');
100
98
  const tRef = useTranslations('ReferencesPanel');
99
+ const eventBus = useMakeMeaningEvents();
101
100
  const [selectedEntityTypes, setSelectedEntityTypes] = useState<string[]>([]);
102
101
  const [lastDetectionLog, setLastDetectionLog] = useState<DetectionLog[] | null>(null);
103
102
  const [pendingEntityTypes, setPendingEntityTypes] = useState<string[]>([]);
@@ -119,9 +118,6 @@ export function ReferencesPanel({
119
118
  const { sortedAnnotations, containerRef, handleAnnotationRef } =
120
119
  useAnnotationPanel(annotations, hoveredAnnotationId);
121
120
 
122
- // Check if detection is supported for this media type
123
- const isTextResource = supportsDetection(mediaType);
124
-
125
121
  // Clear log when starting new detection
126
122
  const handleDetect = () => {
127
123
  if (!onDetect) return;
@@ -166,6 +162,21 @@ export function ReferencesPanel({
166
162
  setPendingEntityTypes([]);
167
163
  };
168
164
 
165
+ // Escape key handler for cancelling pending annotation
166
+ useEffect(() => {
167
+ if (!pendingAnnotation) return;
168
+
169
+ const handleEscape = (e: KeyboardEvent) => {
170
+ if (e.key === 'Escape') {
171
+ eventBus.emit('ui:annotation:cancel-pending');
172
+ setPendingEntityTypes([]);
173
+ }
174
+ };
175
+
176
+ document.addEventListener('keydown', handleEscape);
177
+ return () => document.removeEventListener('keydown', handleEscape);
178
+ }, [pendingAnnotation, eventBus]);
179
+
169
180
  return (
170
181
  <div className="semiont-panel">
171
182
  <PanelHeader annotationType="reference" count={annotations.length} title={tRef('referencesTitle')} />
@@ -205,20 +216,34 @@ export function ReferencesPanel({
205
216
  </div>
206
217
  )}
207
218
 
208
- <button
209
- onClick={handleCreateReference}
210
- className="semiont-button semiont-button--primary"
211
- data-type="reference"
212
- >
213
- 🔗 {tRef('createReference')}
214
- </button>
219
+ <div className="semiont-annotation-prompt__footer">
220
+ <div className="semiont-annotation-prompt__actions">
221
+ <button
222
+ onClick={() => {
223
+ eventBus.emit('ui:annotation:cancel-pending');
224
+ setPendingEntityTypes([]);
225
+ }}
226
+ className="semiont-button semiont-button--secondary"
227
+ data-type="reference"
228
+ >
229
+ {tRef('cancel')}
230
+ </button>
231
+ <button
232
+ onClick={handleCreateReference}
233
+ className="semiont-button semiont-button--primary"
234
+ data-type="reference"
235
+ >
236
+ 🔗 {tRef('createReference')}
237
+ </button>
238
+ </div>
239
+ </div>
215
240
  </div>
216
241
  )}
217
242
 
218
243
  {/* Scrollable content area */}
219
244
  <div ref={containerRef} className="semiont-panel__content">
220
245
  {/* Detection Section - only in Annotate mode and for text resources */}
221
- {annotateMode && isTextResource && (
246
+ {annotateMode && onDetect && (
222
247
  <div className="semiont-panel__section">
223
248
  <button
224
249
  onClick={() => setIsDetectExpanded(!isDetectExpanded)}
@@ -1,6 +1,5 @@
1
1
  'use client';
2
2
 
3
- import { useMemo } from 'react';
4
3
  import { useTranslations } from '../../../contexts/TranslationContext';
5
4
  import type { components } from '@semiont/api-client';
6
5
  import { isBodyResolved } from '@semiont/api-client';
@@ -27,28 +26,19 @@ export function StatisticsPanel({
27
26
  const t = useTranslations('StatisticsPanel');
28
27
 
29
28
  // Count stub vs resolved references
30
- const stubCount = useMemo(
31
- () => references.filter((r) => !isBodyResolved(r.body)).length,
32
- [references]
33
- );
34
-
35
- const resolvedCount = useMemo(
36
- () => references.filter((r) => isBodyResolved(r.body)).length,
37
- [references]
38
- );
29
+ const stubCount = references.filter((r) => !isBodyResolved(r.body)).length;
30
+ const resolvedCount = references.filter((r) => isBodyResolved(r.body)).length;
39
31
 
40
32
  // Count entity types from references (at annotation level)
41
- const entityTypesList = useMemo(() => {
42
- const entityTypeCounts = new Map<string, number>();
43
- references.forEach((ref) => {
44
- const entityTypes = getEntityTypes(ref);
45
- entityTypes.forEach((type: string) => {
46
- entityTypeCounts.set(type, (entityTypeCounts.get(type) || 0) + 1);
47
- });
33
+ const entityTypeCounts = new Map<string, number>();
34
+ references.forEach((ref) => {
35
+ const entityTypes = getEntityTypes(ref);
36
+ entityTypes.forEach((type: string) => {
37
+ entityTypeCounts.set(type, (entityTypeCounts.get(type) || 0) + 1);
48
38
  });
39
+ });
49
40
 
50
- return Array.from(entityTypeCounts.entries()).sort((a, b) => b[1] - a[1]); // Sort by count descending
51
- }, [references]);
41
+ const entityTypesList = Array.from(entityTypeCounts.entries()).sort((a, b) => b[1] - a[1]); // Sort by count descending
52
42
 
53
43
  return (
54
44
  <div className="semiont-statistics-panel">