@semiont/react-ui 0.2.36 → 0.2.37
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.
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +252 -166
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/CodeMirrorRenderer.tsx +71 -203
- package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +142 -0
- package/src/components/__tests__/LiveRegion.hooks.test.tsx +79 -0
- package/src/components/__tests__/ResizeHandle.test.tsx +165 -0
- package/src/components/__tests__/SessionExpiryBanner.test.tsx +123 -0
- package/src/components/__tests__/StatusDisplay.test.tsx +160 -0
- package/src/components/__tests__/Toolbar.test.tsx +110 -0
- package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +285 -0
- package/src/components/annotation-popups/__tests__/SharedPopupElements.test.tsx +273 -0
- package/src/components/modals/__tests__/KeyboardShortcutsHelpModal.test.tsx +90 -0
- package/src/components/modals/__tests__/ProposeEntitiesModal.test.tsx +129 -0
- package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +180 -0
- package/src/components/navigation/__tests__/ObservableLink.test.tsx +90 -0
- package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +169 -0
- package/src/components/navigation/__tests__/SortableResourceTab.test.tsx +371 -0
- package/src/components/resource/AnnotateView.tsx +27 -153
- package/src/components/resource/__tests__/AnnotationHistory.test.tsx +349 -0
- package/src/components/resource/__tests__/HistoryEvent.test.tsx +492 -0
- package/src/components/resource/__tests__/event-formatting.test.ts +273 -0
- package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +226 -0
- package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +188 -0
- package/src/components/resource/panels/__tests__/PanelHeader.test.tsx +69 -0
- package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +445 -0
- package/src/components/resource/panels/__tests__/StatisticsPanel.test.tsx +271 -0
- package/src/components/resource/panels/__tests__/TagEntry.test.tsx +210 -0
- package/src/components/settings/__tests__/SettingsPanel.test.tsx +190 -0
- package/src/components/viewers/__tests__/ImageViewer.test.tsx +63 -0
- package/src/integrations/__tests__/css-modules-helper.test.tsx +225 -0
- package/src/integrations/__tests__/styled-components-theme.test.ts +179 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import '@testing-library/jest-dom';
|
|
5
|
+
import { renderWithProviders } from '../../../test-utils';
|
|
6
|
+
import type { SortableResourceTabProps } from '../../../types/collapsible-navigation';
|
|
7
|
+
|
|
8
|
+
// Mock @dnd-kit/sortable
|
|
9
|
+
const mockSetNodeRef = vi.fn();
|
|
10
|
+
const mockSortableReturn = {
|
|
11
|
+
attributes: { role: 'button', tabIndex: 0 },
|
|
12
|
+
listeners: {},
|
|
13
|
+
setNodeRef: mockSetNodeRef,
|
|
14
|
+
transform: null,
|
|
15
|
+
transition: null,
|
|
16
|
+
isDragging: false,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
vi.mock('@dnd-kit/sortable', () => ({
|
|
20
|
+
useSortable: vi.fn(() => mockSortableReturn),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('@dnd-kit/utilities', () => ({
|
|
24
|
+
CSS: {
|
|
25
|
+
Transform: {
|
|
26
|
+
toString: vi.fn((transform: any) => (transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined)),
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock('../../../lib/resource-utils', () => ({
|
|
32
|
+
getResourceIcon: vi.fn((mediaType: string | undefined) => {
|
|
33
|
+
if (!mediaType) return '\u{1F4C4}';
|
|
34
|
+
if (mediaType.startsWith('image/')) return '\u{1F5BC}\uFE0F';
|
|
35
|
+
if (mediaType === 'text/markdown') return '\u{1F4DD}';
|
|
36
|
+
return '\u{1F4C4}';
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import { useSortable } from '@dnd-kit/sortable';
|
|
41
|
+
import { SortableResourceTab } from '../SortableResourceTab';
|
|
42
|
+
|
|
43
|
+
describe('SortableResourceTab', () => {
|
|
44
|
+
const MockLink = ({ href, children, ...props }: any) => (
|
|
45
|
+
<a href={href} {...props}>
|
|
46
|
+
{children}
|
|
47
|
+
</a>
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const defaultProps: SortableResourceTabProps = {
|
|
51
|
+
resource: {
|
|
52
|
+
id: 'resource-1',
|
|
53
|
+
name: 'Test Document',
|
|
54
|
+
openedAt: Date.now(),
|
|
55
|
+
mediaType: 'text/plain',
|
|
56
|
+
},
|
|
57
|
+
isCollapsed: false,
|
|
58
|
+
isActive: false,
|
|
59
|
+
href: '/resources/resource-1',
|
|
60
|
+
onClose: vi.fn(),
|
|
61
|
+
LinkComponent: MockLink,
|
|
62
|
+
translations: {},
|
|
63
|
+
index: 0,
|
|
64
|
+
totalCount: 3,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.clearAllMocks();
|
|
69
|
+
vi.mocked(useSortable).mockReturnValue(mockSortableReturn as any);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('Rendering', () => {
|
|
73
|
+
it('should render the resource name', () => {
|
|
74
|
+
renderWithProviders(<SortableResourceTab {...defaultProps} />);
|
|
75
|
+
|
|
76
|
+
expect(screen.getByText('Test Document')).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should render the resource icon', () => {
|
|
80
|
+
renderWithProviders(<SortableResourceTab {...defaultProps} />);
|
|
81
|
+
|
|
82
|
+
const icon = screen.getByText('\u{1F4C4}');
|
|
83
|
+
expect(icon).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should render link with correct href', () => {
|
|
87
|
+
renderWithProviders(<SortableResourceTab {...defaultProps} />);
|
|
88
|
+
|
|
89
|
+
const link = screen.getByTitle('Test Document');
|
|
90
|
+
expect(link).toHaveAttribute('href', '/resources/resource-1');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should render close button when not collapsed', () => {
|
|
94
|
+
renderWithProviders(<SortableResourceTab {...defaultProps} />);
|
|
95
|
+
|
|
96
|
+
const closeButton = screen.getByLabelText('Close Test Document');
|
|
97
|
+
expect(closeButton).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should not render close button when collapsed', () => {
|
|
101
|
+
renderWithProviders(
|
|
102
|
+
<SortableResourceTab {...defaultProps} isCollapsed={true} />
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(screen.queryByLabelText('Close Test Document')).not.toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should not render resource name text when collapsed', () => {
|
|
109
|
+
renderWithProviders(
|
|
110
|
+
<SortableResourceTab {...defaultProps} isCollapsed={true} />
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(screen.queryByText('Test Document')).not.toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should render icon when collapsed', () => {
|
|
117
|
+
renderWithProviders(
|
|
118
|
+
<SortableResourceTab {...defaultProps} isCollapsed={true} />
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect(screen.getByText('\u{1F4C4}')).toBeInTheDocument();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('Active state', () => {
|
|
126
|
+
it('should have active class when isActive is true', () => {
|
|
127
|
+
const { container } = renderWithProviders(
|
|
128
|
+
<SortableResourceTab {...defaultProps} isActive={true} />
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const tab = container.querySelector('.semiont-resource-tab');
|
|
132
|
+
expect(tab).toHaveClass('semiont-resource-tab--active');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should not have active class when isActive is false', () => {
|
|
136
|
+
const { container } = renderWithProviders(
|
|
137
|
+
<SortableResourceTab {...defaultProps} isActive={false} />
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const tab = container.querySelector('.semiont-resource-tab');
|
|
141
|
+
expect(tab).not.toHaveClass('semiont-resource-tab--active');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should set aria-selected based on isActive', () => {
|
|
145
|
+
renderWithProviders(
|
|
146
|
+
<SortableResourceTab {...defaultProps} isActive={true} />
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const tab = screen.getByRole('tab');
|
|
150
|
+
expect(tab).toHaveAttribute('aria-selected', 'true');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('Dragging state', () => {
|
|
155
|
+
it('should have dragging class when isDragging prop is true', () => {
|
|
156
|
+
const { container } = renderWithProviders(
|
|
157
|
+
<SortableResourceTab {...defaultProps} isDragging={true} />
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const tab = container.querySelector('.semiont-resource-tab');
|
|
161
|
+
expect(tab).toHaveClass('semiont-resource-tab--dragging');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should have dragging class when useSortable reports dragging', () => {
|
|
165
|
+
vi.mocked(useSortable).mockReturnValue({
|
|
166
|
+
...mockSortableReturn,
|
|
167
|
+
isDragging: true,
|
|
168
|
+
} as any);
|
|
169
|
+
|
|
170
|
+
const { container } = renderWithProviders(
|
|
171
|
+
<SortableResourceTab {...defaultProps} />
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const tab = container.querySelector('.semiont-resource-tab');
|
|
175
|
+
expect(tab).toHaveClass('semiont-resource-tab--dragging');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should not have dragging class when not dragging', () => {
|
|
179
|
+
const { container } = renderWithProviders(
|
|
180
|
+
<SortableResourceTab {...defaultProps} isDragging={false} />
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const tab = container.querySelector('.semiont-resource-tab');
|
|
184
|
+
expect(tab).not.toHaveClass('semiont-resource-tab--dragging');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('Close button', () => {
|
|
189
|
+
it('should call onClose with resource id and event when clicked', () => {
|
|
190
|
+
const onClose = vi.fn();
|
|
191
|
+
renderWithProviders(
|
|
192
|
+
<SortableResourceTab {...defaultProps} onClose={onClose} />
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const closeButton = screen.getByLabelText('Close Test Document');
|
|
196
|
+
fireEvent.click(closeButton);
|
|
197
|
+
|
|
198
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
199
|
+
expect(onClose).toHaveBeenCalledWith('resource-1', expect.any(Object));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should use custom translation for close button title', () => {
|
|
203
|
+
renderWithProviders(
|
|
204
|
+
<SortableResourceTab
|
|
205
|
+
{...defaultProps}
|
|
206
|
+
translations={{ closeResource: 'Fermer la ressource' }}
|
|
207
|
+
/>
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const closeButton = screen.getByTitle('Fermer la ressource');
|
|
211
|
+
expect(closeButton).toBeInTheDocument();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should default to "Close resource" title', () => {
|
|
215
|
+
renderWithProviders(<SortableResourceTab {...defaultProps} />);
|
|
216
|
+
|
|
217
|
+
const closeButton = screen.getByTitle('Close resource');
|
|
218
|
+
expect(closeButton).toBeInTheDocument();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('Keyboard reordering', () => {
|
|
223
|
+
it('should call onReorder with "up" on Alt+ArrowUp', () => {
|
|
224
|
+
const onReorder = vi.fn();
|
|
225
|
+
renderWithProviders(
|
|
226
|
+
<SortableResourceTab {...defaultProps} onReorder={onReorder} />
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const tab = screen.getByRole('tab');
|
|
230
|
+
fireEvent.keyDown(tab, { key: 'ArrowUp', altKey: true });
|
|
231
|
+
|
|
232
|
+
expect(onReorder).toHaveBeenCalledWith('resource-1', 'up');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should call onReorder with "down" on Alt+ArrowDown', () => {
|
|
236
|
+
const onReorder = vi.fn();
|
|
237
|
+
renderWithProviders(
|
|
238
|
+
<SortableResourceTab {...defaultProps} onReorder={onReorder} />
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const tab = screen.getByRole('tab');
|
|
242
|
+
fireEvent.keyDown(tab, { key: 'ArrowDown', altKey: true });
|
|
243
|
+
|
|
244
|
+
expect(onReorder).toHaveBeenCalledWith('resource-1', 'down');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('should not call onReorder without Alt key', () => {
|
|
248
|
+
const onReorder = vi.fn();
|
|
249
|
+
renderWithProviders(
|
|
250
|
+
<SortableResourceTab {...defaultProps} onReorder={onReorder} />
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const tab = screen.getByRole('tab');
|
|
254
|
+
fireEvent.keyDown(tab, { key: 'ArrowUp' });
|
|
255
|
+
|
|
256
|
+
expect(onReorder).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should not call onReorder for non-arrow keys with Alt', () => {
|
|
260
|
+
const onReorder = vi.fn();
|
|
261
|
+
renderWithProviders(
|
|
262
|
+
<SortableResourceTab {...defaultProps} onReorder={onReorder} />
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const tab = screen.getByRole('tab');
|
|
266
|
+
fireEvent.keyDown(tab, { key: 'Enter', altKey: true });
|
|
267
|
+
|
|
268
|
+
expect(onReorder).not.toHaveBeenCalled();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should not error when onReorder is not provided', () => {
|
|
272
|
+
renderWithProviders(
|
|
273
|
+
<SortableResourceTab {...defaultProps} onReorder={undefined} />
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const tab = screen.getByRole('tab');
|
|
277
|
+
expect(() => {
|
|
278
|
+
fireEvent.keyDown(tab, { key: 'ArrowUp', altKey: true });
|
|
279
|
+
}).not.toThrow();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('Accessibility', () => {
|
|
284
|
+
it('should have tab role', () => {
|
|
285
|
+
renderWithProviders(<SortableResourceTab {...defaultProps} />);
|
|
286
|
+
|
|
287
|
+
expect(screen.getByRole('tab')).toBeInTheDocument();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should have aria-label with position info', () => {
|
|
291
|
+
renderWithProviders(
|
|
292
|
+
<SortableResourceTab {...defaultProps} index={1} totalCount={5} />
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const tab = screen.getByRole('tab');
|
|
296
|
+
expect(tab).toHaveAttribute('aria-label', 'Test Document, position 2 of 5');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should have aria-hidden on icon', () => {
|
|
300
|
+
const { container } = renderWithProviders(
|
|
301
|
+
<SortableResourceTab {...defaultProps} />
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const icon = container.querySelector('.semiont-resource-tab__icon');
|
|
305
|
+
expect(icon).toHaveAttribute('aria-hidden', 'true');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should have close button with aria-label', () => {
|
|
309
|
+
renderWithProviders(<SortableResourceTab {...defaultProps} />);
|
|
310
|
+
|
|
311
|
+
const closeButton = screen.getByLabelText('Close Test Document');
|
|
312
|
+
expect(closeButton).toBeInTheDocument();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('Styling', () => {
|
|
317
|
+
it('should have base tab class', () => {
|
|
318
|
+
const { container } = renderWithProviders(
|
|
319
|
+
<SortableResourceTab {...defaultProps} />
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(container.querySelector('.semiont-resource-tab')).toBeInTheDocument();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should have proper link class', () => {
|
|
326
|
+
const { container } = renderWithProviders(
|
|
327
|
+
<SortableResourceTab {...defaultProps} />
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
expect(container.querySelector('.semiont-resource-tab__link')).toBeInTheDocument();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should have proper icon class', () => {
|
|
334
|
+
const { container } = renderWithProviders(
|
|
335
|
+
<SortableResourceTab {...defaultProps} />
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
expect(container.querySelector('.semiont-resource-tab__icon')).toBeInTheDocument();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should have proper text class', () => {
|
|
342
|
+
const { container } = renderWithProviders(
|
|
343
|
+
<SortableResourceTab {...defaultProps} />
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
expect(container.querySelector('.semiont-resource-tab__text')).toBeInTheDocument();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('should have proper close button class', () => {
|
|
350
|
+
const { container } = renderWithProviders(
|
|
351
|
+
<SortableResourceTab {...defaultProps} />
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
expect(container.querySelector('.semiont-resource-tab__close')).toBeInTheDocument();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('useSortable integration', () => {
|
|
359
|
+
it('should call useSortable with resource id', () => {
|
|
360
|
+
renderWithProviders(<SortableResourceTab {...defaultProps} />);
|
|
361
|
+
|
|
362
|
+
expect(useSortable).toHaveBeenCalledWith({ id: 'resource-1' });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should pass setNodeRef to container', () => {
|
|
366
|
+
renderWithProviders(<SortableResourceTab {...defaultProps} />);
|
|
367
|
+
|
|
368
|
+
expect(mockSetNodeRef).toHaveBeenCalled();
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useRef, useEffect, useCallback, lazy, Suspense } from 'react';
|
|
4
|
-
import type { components } from '@semiont/core';
|
|
5
4
|
import { resourceUri as toResourceUri } from '@semiont/core';
|
|
6
|
-
import {
|
|
5
|
+
import { getMimeCategory, isPdfMimeType } from '@semiont/api-client';
|
|
7
6
|
import { ANNOTATORS } from '../../lib/annotation-registry';
|
|
7
|
+
import { segmentTextWithAnnotations } from '../../lib/text-segmentation';
|
|
8
|
+
import { buildTextSelectors, fallbackTextPosition } from '../../lib/text-selection-handler';
|
|
8
9
|
import { SvgDrawingCanvas } from '../image-annotation/SvgDrawingCanvas';
|
|
10
|
+
|
|
9
11
|
import { useResourceAnnotations } from '../../contexts/ResourceAnnotationsContext';
|
|
10
12
|
|
|
11
13
|
// Lazy load PDF component to avoid SSR issues with browser PDF.js loading
|
|
12
14
|
const PdfAnnotationCanvas = lazy(() => import('../pdf-annotation/PdfAnnotationCanvas.client').then(mod => ({ default: mod.PdfAnnotationCanvas })));
|
|
13
15
|
|
|
14
|
-
type Annotation = components['schemas']['Annotation'];
|
|
15
|
-
|
|
16
16
|
import { CodeMirrorRenderer } from '../CodeMirrorRenderer';
|
|
17
|
-
import type { TextSegment } from '../CodeMirrorRenderer';
|
|
18
17
|
import type { EditorView } from '@codemirror/view';
|
|
19
18
|
import { useEventBus } from '../../contexts/EventBusContext';
|
|
20
19
|
import { useEventSubscriptions } from '../../contexts/useEventSubscription';
|
|
@@ -45,90 +44,6 @@ interface Props {
|
|
|
45
44
|
annotateMode: boolean;
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
// Segment text with annotations - uses fuzzy anchoring when available!
|
|
49
|
-
function segmentTextWithAnnotations(content: string, annotations: Annotation[]): TextSegment[] {
|
|
50
|
-
if (!content) {
|
|
51
|
-
return [{ exact: '', start: 0, end: 0 }];
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Pre-compute normalized/lowered content once for all annotations
|
|
55
|
-
const cache = buildContentCache(content);
|
|
56
|
-
|
|
57
|
-
const normalizedAnnotations = annotations
|
|
58
|
-
.map(ann => {
|
|
59
|
-
const targetSelector = getTargetSelector(ann.target);
|
|
60
|
-
const posSelector = getTextPositionSelector(targetSelector);
|
|
61
|
-
const quoteSelector = targetSelector ? getTextQuoteSelector(targetSelector) : null;
|
|
62
|
-
|
|
63
|
-
// Try fuzzy anchoring if TextQuoteSelector is available
|
|
64
|
-
// Pass TextPositionSelector as position hint for better fuzzy search
|
|
65
|
-
let position;
|
|
66
|
-
if (quoteSelector) {
|
|
67
|
-
position = findTextWithContext(
|
|
68
|
-
content,
|
|
69
|
-
quoteSelector.exact,
|
|
70
|
-
quoteSelector.prefix,
|
|
71
|
-
quoteSelector.suffix,
|
|
72
|
-
posSelector?.start,
|
|
73
|
-
cache
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Fallback to TextPositionSelector or fuzzy position
|
|
78
|
-
const start = position?.start ?? posSelector?.start ?? 0;
|
|
79
|
-
const end = position?.end ?? posSelector?.end ?? 0;
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
annotation: ann,
|
|
83
|
-
start,
|
|
84
|
-
end
|
|
85
|
-
};
|
|
86
|
-
})
|
|
87
|
-
.filter(a => a.start >= 0 && a.end <= content.length && a.start < a.end)
|
|
88
|
-
.sort((a, b) => a.start - b.start);
|
|
89
|
-
|
|
90
|
-
if (normalizedAnnotations.length === 0) {
|
|
91
|
-
return [{ exact: content, start: 0, end: content.length }];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const segments: TextSegment[] = [];
|
|
95
|
-
let position = 0;
|
|
96
|
-
|
|
97
|
-
for (const { annotation, start, end } of normalizedAnnotations) {
|
|
98
|
-
if (start < position) continue; // Skip overlapping annotations
|
|
99
|
-
|
|
100
|
-
// Add text before annotation
|
|
101
|
-
if (start > position) {
|
|
102
|
-
segments.push({
|
|
103
|
-
exact: content.slice(position, start),
|
|
104
|
-
start: position,
|
|
105
|
-
end: start
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Add annotated segment
|
|
110
|
-
segments.push({
|
|
111
|
-
exact: content.slice(start, end),
|
|
112
|
-
annotation,
|
|
113
|
-
start,
|
|
114
|
-
end
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
position = end;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Add remaining text
|
|
121
|
-
if (position < content.length) {
|
|
122
|
-
segments.push({
|
|
123
|
-
exact: content.slice(position),
|
|
124
|
-
start: position,
|
|
125
|
-
end: content.length
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return segments;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
47
|
/**
|
|
133
48
|
* View component for annotating resources with text selection and drawing
|
|
134
49
|
*
|
|
@@ -241,74 +156,33 @@ export function AnnotateView({
|
|
|
241
156
|
// Get the CodeMirror EditorView instance stored on the CodeMirror container
|
|
242
157
|
const cmContainer = container.querySelector('.codemirror-renderer');
|
|
243
158
|
const view = (cmContainer as EnrichedHTMLElement | null)?.__cmView;
|
|
159
|
+
|
|
160
|
+
let start: number;
|
|
161
|
+
let end: number;
|
|
162
|
+
|
|
244
163
|
if (!view || !view.posAtDOM) {
|
|
245
164
|
// Fallback: try to find text in source (won't work for duplicates)
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
// Unified flow: all text annotations use BOTH TextPositionSelector and TextQuoteSelector
|
|
256
|
-
if (selectedMotivation) {
|
|
257
|
-
eventBus.get('mark:requested').next({
|
|
258
|
-
selector: [
|
|
259
|
-
{
|
|
260
|
-
type: 'TextPositionSelector',
|
|
261
|
-
start,
|
|
262
|
-
end
|
|
263
|
-
},
|
|
264
|
-
{
|
|
265
|
-
type: 'TextQuoteSelector',
|
|
266
|
-
exact: text,
|
|
267
|
-
...(context.prefix && { prefix: context.prefix }),
|
|
268
|
-
...(context.suffix && { suffix: context.suffix })
|
|
269
|
-
}
|
|
270
|
-
],
|
|
271
|
-
motivation: selectedMotivation
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
// Clear selection after creating annotation
|
|
275
|
-
selection.removeAllRanges();
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
return;
|
|
165
|
+
const pos = fallbackTextPosition(content, text);
|
|
166
|
+
if (!pos) return;
|
|
167
|
+
start = pos.start;
|
|
168
|
+
end = pos.end;
|
|
169
|
+
} else {
|
|
170
|
+
// CodeMirror's posAtDOM gives us the position in the document from a DOM node/offset
|
|
171
|
+
start = view.posAtDOM(range.startContainer, range.startOffset);
|
|
172
|
+
end = start + text.length;
|
|
279
173
|
}
|
|
280
174
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
selector: [
|
|
293
|
-
{
|
|
294
|
-
type: 'TextPositionSelector',
|
|
295
|
-
start,
|
|
296
|
-
end
|
|
297
|
-
},
|
|
298
|
-
{
|
|
299
|
-
type: 'TextQuoteSelector',
|
|
300
|
-
exact: text,
|
|
301
|
-
...(context.prefix && { prefix: context.prefix }),
|
|
302
|
-
...(context.suffix && { suffix: context.suffix })
|
|
303
|
-
}
|
|
304
|
-
],
|
|
305
|
-
motivation: selectedMotivation
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
// Clear selection after creating annotation
|
|
309
|
-
selection.removeAllRanges();
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
175
|
+
if (start >= 0 && selectedMotivation) {
|
|
176
|
+
const selectors = buildTextSelectors(content, text, start, end);
|
|
177
|
+
if (!selectors) return;
|
|
178
|
+
|
|
179
|
+
eventBus.get('mark:requested').next({
|
|
180
|
+
selector: selectors,
|
|
181
|
+
motivation: selectedMotivation
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Clear selection after creating annotation
|
|
185
|
+
selection.removeAllRanges();
|
|
312
186
|
}
|
|
313
187
|
};
|
|
314
188
|
|