activeadmin_annotations 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/CODE_OF_CONDUCT.md +31 -0
  4. data/CONTRIBUTING.md +29 -0
  5. data/GOVERNANCE.md +29 -0
  6. data/LICENSE.md +21 -0
  7. data/README.md +321 -0
  8. data/SECURITY.md +27 -0
  9. data/activeadmin_annotations.gemspec +68 -0
  10. data/admin/annotation_reviews.rb +102 -0
  11. data/admin/annotations.rb +146 -0
  12. data/app/assets/controllers/activeadmin_annotations/annotator_controller.js +483 -0
  13. data/app/assets/controllers/activeadmin_annotations/clipboard_controller.js +73 -0
  14. data/app/assets/stylesheets/activeadmin_annotations.css +362 -0
  15. data/app/helpers/active_admin/annotations/panel_helper.rb +95 -0
  16. data/app/models/active_admin/annotations/annotation.rb +37 -0
  17. data/app/models/active_admin/annotations/application_record.rb +13 -0
  18. data/app/models/active_admin/annotations/review.rb +54 -0
  19. data/app/services/active_admin/annotations/review_service.rb +133 -0
  20. data/app/views/active_admin/annotations/_copy_details_action.html.erb +21 -0
  21. data/app/views/active_admin/annotations/_panel.html.erb +98 -0
  22. data/app/views/active_admin/annotations/_read_only_content.html.erb +3 -0
  23. data/config/polyrun_coverage.yml +8 -0
  24. data/db/migrate/20260617120000_create_activeadmin_annotations_tables.rb +41 -0
  25. data/db/migrate/20260706150000_add_metadata_json_gin_index_to_annotation_reviews.rb +9 -0
  26. data/db/migrate/20260710120000_add_panel_query_index_to_annotation_spans.rb +10 -0
  27. data/lib/activeadmin/annotations/annotatable.rb +15 -0
  28. data/lib/activeadmin/annotations/categories.rb +13 -0
  29. data/lib/activeadmin/annotations/content_revision.rb +101 -0
  30. data/lib/activeadmin/annotations/context.rb +11 -0
  31. data/lib/activeadmin/annotations/copy_text.rb +98 -0
  32. data/lib/activeadmin/annotations/engine.rb +49 -0
  33. data/lib/activeadmin/annotations/exporter.rb +99 -0
  34. data/lib/activeadmin/annotations/review_access.rb +29 -0
  35. data/lib/activeadmin/annotations/review_panel.rb +145 -0
  36. data/lib/activeadmin/annotations/version.rb +7 -0
  37. data/lib/activeadmin/annotations.rb +37 -0
  38. data/lib/activeadmin_annotations.rb +13 -0
  39. data/sig/activeadmin/annotations.rbs +5 -0
  40. metadata +393 -0
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveAdmin.register ActiveAdmin::Annotations::Annotation, as: "annotation_spans" do
4
+ menu false
5
+
6
+ actions :index, :show, :create, :update, :destroy
7
+
8
+ permit_params :annotation_review_id,
9
+ :field_name,
10
+ :selected_text,
11
+ :start_offset,
12
+ :end_offset,
13
+ :comment,
14
+ :category,
15
+ :content_revision_version,
16
+ context_paths_json: []
17
+
18
+ filter :annotation_review
19
+ filter :field_name
20
+ filter :category
21
+ filter :comment
22
+ filter :created_at
23
+
24
+ index do
25
+ selectable_column
26
+ id_column
27
+ column :annotation_review
28
+ column :field_name
29
+ column :selected_text
30
+ column :category
31
+ column :created_at
32
+ actions
33
+ end
34
+
35
+ member_action :copy_text, method: :get do
36
+ render plain: ActiveAdmin::Annotations::CopyText.for(resource)
37
+ end
38
+
39
+ action_item :copy_details, only: :show do
40
+ render partial: "active_admin/annotations/copy_details_action",
41
+ locals: {copy_text_url: copy_text_admin_annotation_span_path(resource)}
42
+ end
43
+
44
+ show do
45
+ attributes_table_for(resource) do
46
+ row :id
47
+ row :annotation_review
48
+ row :field_name
49
+ row :selected_text
50
+ row :start_offset
51
+ row :end_offset
52
+ row :comment
53
+ row :category
54
+ row :content_revision_version
55
+ row :context_paths_json
56
+ row :created_at
57
+ row :updated_at
58
+ end
59
+ end
60
+
61
+ controller do
62
+ before_action :authorize_annotation_access!, only: %i[update destroy copy_text]
63
+
64
+ def create
65
+ review = authorize_review_for_create!
66
+ return if performed?
67
+
68
+ assign_content_revision_version_from_review(review)
69
+ super do |success, failure|
70
+ success.html { redirect_to request.referer || resource_path, notice: "Annotation saved." }
71
+ success.json { render json: annotation_json(resource), status: :created }
72
+ failure.html { redirect_to request.referer || admin_root_path, alert: resource.errors.full_messages.to_sentence }
73
+ failure.json { render json: {errors: resource.errors.full_messages}, status: :unprocessable_entity }
74
+ end
75
+ end
76
+
77
+ def update
78
+ super do |success, failure|
79
+ success.html { redirect_to request.referer || resource_path, notice: "Annotation updated." }
80
+ success.json { render json: annotation_json(resource), status: :ok }
81
+ failure.html { redirect_to request.referer || admin_root_path, alert: resource.errors.full_messages.to_sentence }
82
+ failure.json { render json: {errors: resource.errors.full_messages}, status: :unprocessable_entity }
83
+ end
84
+ end
85
+
86
+ def destroy
87
+ super do |success, failure|
88
+ success.html { redirect_to request.referer || admin_root_path, notice: "Annotation deleted." }
89
+ success.json { head :no_content }
90
+ failure.html { redirect_to request.referer || admin_root_path, alert: resource.errors.full_messages.to_sentence }
91
+ failure.json { render json: {errors: resource.errors.full_messages}, status: :unprocessable_entity }
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def authorize_review_for_create!
98
+ ActiveAdmin::Annotations::ReviewAccess.review_for_span_create!(
99
+ review_id: params.dig(:annotation, :annotation_review_id),
100
+ reviewer: current_annotation_reviewer
101
+ )
102
+ rescue ActiveAdmin::Annotations::ReviewAccess::Forbidden, ActiveAdmin::Annotations::ReviewAccess::NotFound
103
+ respond_to_denied_review_access
104
+ nil
105
+ end
106
+
107
+ def authorize_annotation_access!
108
+ ActiveAdmin::Annotations::ReviewAccess.authorize_annotation!(
109
+ annotation: resource,
110
+ reviewer: current_annotation_reviewer
111
+ )
112
+ rescue ActiveAdmin::Annotations::ReviewAccess::Forbidden
113
+ respond_to_denied_review_access
114
+ end
115
+
116
+ def current_annotation_reviewer
117
+ return current_user if respond_to?(:current_user, true)
118
+
119
+ nil
120
+ end
121
+
122
+ def respond_to_denied_review_access
123
+ if request.format.json?
124
+ render json: {errors: ["Review not found"]}, status: :forbidden
125
+ else
126
+ redirect_to request.referer || admin_root_path, alert: "Review not found."
127
+ end
128
+ end
129
+
130
+ def assign_content_revision_version_from_review(review)
131
+ params[:annotation][:content_revision_version] = review.content_revision_version
132
+ end
133
+
134
+ def annotation_json(annotation)
135
+ {
136
+ id: annotation.id,
137
+ field_name: annotation.field_name,
138
+ selected_text: annotation.selected_text,
139
+ start_offset: annotation.start_offset,
140
+ end_offset: annotation.end_offset,
141
+ comment: annotation.comment,
142
+ category: annotation.category
143
+ }
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,483 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const PENDING_HIGHLIGHT_NAME = "aa-annotations-pending";
4
+ const SAVED_HIGHLIGHT_NAME = "aa-annotations-saved";
5
+
6
+ export default class extends Controller {
7
+ static targets = [
8
+ "annotatable",
9
+ "sidebar",
10
+ "annotationList",
11
+ "composer",
12
+ "composerHeading",
13
+ "selectedPreview",
14
+ "commentInput",
15
+ "categoryInput",
16
+ "errorMessage",
17
+ ];
18
+
19
+ static values = {
20
+ reviewId: String,
21
+ field: String,
22
+ createUrl: String,
23
+ updateUrlTemplate: String,
24
+ categoryLabel: { type: String, default: "Category" },
25
+ categories: { type: Object, default: {} },
26
+ annotations: { type: Array, default: [] },
27
+ readonly: { type: Boolean, default: false },
28
+ };
29
+
30
+ connect() {
31
+ this.pendingSelection = null;
32
+ this.editingAnnotationId = null;
33
+ this.highlightsSupported = typeof CSS !== "undefined" && CSS.highlights;
34
+ this.renderAnnotations();
35
+ this.annotatableTarget.addEventListener("mouseup", this.commitAnnotatableSelection);
36
+ this.annotatableTarget.addEventListener("keyup", this.commitAnnotatableSelection);
37
+ document.addEventListener("selectionchange", this.handleSelectionChange);
38
+ document.addEventListener("keydown", this.handleDocumentKeydown);
39
+ }
40
+
41
+ disconnect() {
42
+ this.annotatableTarget.removeEventListener("mouseup", this.commitAnnotatableSelection);
43
+ this.annotatableTarget.removeEventListener("keyup", this.commitAnnotatableSelection);
44
+ document.removeEventListener("selectionchange", this.handleSelectionChange);
45
+ document.removeEventListener("keydown", this.handleDocumentKeydown);
46
+ this.clearPendingHighlight();
47
+ this.clearSavedHighlights();
48
+ }
49
+
50
+ commitAnnotatableSelection = () => {
51
+ if (this.readonlyValue) return;
52
+
53
+ requestAnimationFrame(() => {
54
+ const selection = this.currentSelection();
55
+ if (selection) this.activateComposer(selection);
56
+ });
57
+ };
58
+
59
+ handleSelectionChange = () => {
60
+ if (this.readonlyValue) return;
61
+ if (this.composerIsOpen()) this.ensurePendingHighlight();
62
+ };
63
+
64
+ activateComposer(selection) {
65
+ this.editingAnnotationId = null;
66
+
67
+ const selectionChanged =
68
+ !this.pendingSelection ||
69
+ this.pendingSelection.startOffset !== selection.startOffset ||
70
+ this.pendingSelection.endOffset !== selection.endOffset ||
71
+ this.pendingSelection.selectedText !== selection.selectedText;
72
+
73
+ this.pendingSelection = selection;
74
+ this.selectedPreviewTarget.textContent = selection.selectedText;
75
+
76
+ if (selectionChanged) {
77
+ this.commentInputTarget.value = "";
78
+ this.categoryInputTarget.value = "";
79
+ }
80
+
81
+ this.hideError();
82
+ this.setComposerHeading("New annotation");
83
+ this.composerTarget.classList.remove("aa-annotations-composer-hidden");
84
+ this.applyPendingHighlight(selection);
85
+ window.getSelection()?.removeAllRanges();
86
+ }
87
+
88
+ editAnnotation(event) {
89
+ if (this.readonlyValue) return;
90
+
91
+ const annotationId = event.currentTarget.dataset.annotationId;
92
+ const annotation = this.annotationsValue.find((entry) => String(entry.id) === String(annotationId));
93
+ if (!annotation) return;
94
+
95
+ this.editingAnnotationId = annotation.id;
96
+ this.pendingSelection = {
97
+ selectedText: annotation.selected_text,
98
+ startOffset: Number(annotation.start_offset),
99
+ endOffset: Number(annotation.end_offset),
100
+ };
101
+ this.selectedPreviewTarget.textContent = annotation.selected_text;
102
+ this.commentInputTarget.value = annotation.comment;
103
+ this.categoryInputTarget.value = annotation.category || "";
104
+ this.hideError();
105
+ this.setComposerHeading("Edit annotation");
106
+ this.composerTarget.classList.remove("aa-annotations-composer-hidden");
107
+ this.applyPendingHighlight(this.pendingSelection);
108
+ window.getSelection()?.removeAllRanges();
109
+ this.commentInputTarget.focus();
110
+ }
111
+
112
+ async deleteAnnotation(event) {
113
+ if (this.readonlyValue) return;
114
+
115
+ const annotationId = event.currentTarget.dataset.annotationId;
116
+ const annotation = this.annotationsValue.find((entry) => String(entry.id) === String(annotationId));
117
+ if (!annotation) return;
118
+
119
+ if (!window.confirm("Delete this annotation?")) return;
120
+
121
+ try {
122
+ const response = await fetch(this.annotationUrl(annotationId), {
123
+ method: "DELETE",
124
+ credentials: "same-origin",
125
+ headers: {
126
+ Accept: "application/json",
127
+ "X-CSRF-Token": this.csrfToken(),
128
+ },
129
+ });
130
+
131
+ if (!response.ok) {
132
+ window.alert("Could not delete annotation.");
133
+ return;
134
+ }
135
+
136
+ if (String(this.editingAnnotationId) === String(annotationId)) this.resetComposer();
137
+
138
+ this.annotationsValue = this.annotationsValue.filter(
139
+ (entry) => String(entry.id) !== String(annotationId)
140
+ );
141
+ this.renderAnnotations();
142
+ } catch (_error) {
143
+ window.alert("Could not delete annotation.");
144
+ }
145
+ }
146
+
147
+ resetComposer() {
148
+ this.pendingSelection = null;
149
+ this.editingAnnotationId = null;
150
+ this.clearPendingHighlight();
151
+ this.selectedPreviewTarget.textContent = "";
152
+ this.commentInputTarget.value = "";
153
+ this.categoryInputTarget.value = "";
154
+ this.hideError();
155
+ this.setComposerHeading("New annotation");
156
+ this.composerTarget.classList.add("aa-annotations-composer-hidden");
157
+ }
158
+
159
+ composerIsOpen() {
160
+ return !this.composerTarget.classList.contains("aa-annotations-composer-hidden");
161
+ }
162
+
163
+ cancelComposer() {
164
+ this.resetComposer();
165
+ window.getSelection()?.removeAllRanges();
166
+ }
167
+
168
+ ensurePendingHighlight() {
169
+ if (!this.pendingSelection) return;
170
+
171
+ this.applyPendingHighlight(this.pendingSelection);
172
+ }
173
+
174
+ async submitComposer() {
175
+ if (!this.pendingSelection) return;
176
+
177
+ const comment = this.commentInputTarget.value.trim();
178
+ if (!comment) {
179
+ this.showError("Comment is required.");
180
+ return;
181
+ }
182
+
183
+ if (this.editingAnnotationId) {
184
+ await this.updateAnnotation(comment);
185
+ return;
186
+ }
187
+
188
+ await this.createAnnotation(comment);
189
+ }
190
+
191
+ async createAnnotation(comment) {
192
+ const payload = {
193
+ annotation: {
194
+ annotation_review_id: this.reviewIdValue,
195
+ field_name: this.fieldValue,
196
+ selected_text: this.pendingSelection.selectedText,
197
+ start_offset: this.pendingSelection.startOffset,
198
+ end_offset: this.pendingSelection.endOffset,
199
+ comment: comment,
200
+ category: this.categoryInputTarget.value || null,
201
+ context_paths_json: [],
202
+ },
203
+ };
204
+
205
+ try {
206
+ const response = await fetch(this.createUrlValue, {
207
+ method: "POST",
208
+ credentials: "same-origin",
209
+ headers: {
210
+ Accept: "application/json",
211
+ "Content-Type": "application/json",
212
+ "X-CSRF-Token": this.csrfToken(),
213
+ },
214
+ body: JSON.stringify(payload),
215
+ });
216
+
217
+ if (!response.ok) {
218
+ this.showError("Could not save annotation.");
219
+ return;
220
+ }
221
+
222
+ const annotation = await response.json();
223
+ this.resetComposer();
224
+ this.annotationsValue = [...this.annotationsValue, this.normalizeAnnotation(annotation)];
225
+ this.renderAnnotations();
226
+ window.getSelection()?.removeAllRanges();
227
+ } catch (_error) {
228
+ this.showError("Could not save annotation.");
229
+ }
230
+ }
231
+
232
+ async updateAnnotation(comment) {
233
+ const payload = {
234
+ annotation: {
235
+ comment: comment,
236
+ category: this.categoryInputTarget.value || null,
237
+ },
238
+ };
239
+
240
+ try {
241
+ const response = await fetch(this.annotationUrl(this.editingAnnotationId), {
242
+ method: "PATCH",
243
+ credentials: "same-origin",
244
+ headers: {
245
+ Accept: "application/json",
246
+ "Content-Type": "application/json",
247
+ "X-CSRF-Token": this.csrfToken(),
248
+ },
249
+ body: JSON.stringify(payload),
250
+ });
251
+
252
+ if (!response.ok) {
253
+ this.showError("Could not update annotation.");
254
+ return;
255
+ }
256
+
257
+ const annotation = await response.json();
258
+ this.resetComposer();
259
+ this.annotationsValue = this.annotationsValue.map((entry) =>
260
+ String(entry.id) === String(annotation.id) ? this.normalizeAnnotation(annotation) : entry
261
+ );
262
+ this.renderAnnotations();
263
+ } catch (_error) {
264
+ this.showError("Could not update annotation.");
265
+ }
266
+ }
267
+
268
+ handleDocumentKeydown = (event) => {
269
+ if (event.key !== "Escape") return;
270
+ if (!this.composerIsOpen()) return;
271
+
272
+ this.cancelComposer();
273
+ };
274
+
275
+ currentSelection() {
276
+ const selection = window.getSelection();
277
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
278
+
279
+ const range = selection.getRangeAt(0);
280
+ if (!this.selectionInsideAnnotatable(range)) return null;
281
+
282
+ const selectedText = selection.toString().trim();
283
+ if (!selectedText) return null;
284
+
285
+ const startOffset = this.offsetWithinAnnotatable(range.startContainer, range.startOffset);
286
+ const endOffset = this.offsetWithinAnnotatable(range.endContainer, range.endOffset);
287
+ if (startOffset === null || endOffset === null || endOffset <= startOffset) return null;
288
+
289
+ return { selectedText, startOffset, endOffset };
290
+ }
291
+
292
+ selectionInsideAnnotatable(range) {
293
+ const startElement = this.elementForNode(range.startContainer);
294
+ const endElement = this.elementForNode(range.endContainer);
295
+ return this.annotatableTarget.contains(startElement) && this.annotatableTarget.contains(endElement);
296
+ }
297
+
298
+ elementForNode(node) {
299
+ return node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
300
+ }
301
+
302
+ offsetWithinAnnotatable(node, offset) {
303
+ const walker = document.createTreeWalker(this.annotatableTarget, NodeFilter.SHOW_TEXT);
304
+ let position = 0;
305
+
306
+ while (walker.nextNode()) {
307
+ const current = walker.currentNode;
308
+ if (current === node) return position + offset;
309
+ position += current.textContent.length;
310
+ }
311
+
312
+ return null;
313
+ }
314
+
315
+ renderAnnotations() {
316
+ if (!this.composerIsOpen()) this.clearPendingHighlight();
317
+ this.renderSavedHighlights();
318
+ this.annotationListTarget.innerHTML = "";
319
+
320
+ if (this.annotationsValue.length === 0) {
321
+ this.annotationListTarget.appendChild(this.emptyAnnotationListItem());
322
+ return;
323
+ }
324
+
325
+ this.annotationsValue.forEach((annotation) => {
326
+ this.annotationListTarget.appendChild(this.annotationListItem(annotation));
327
+ });
328
+ }
329
+
330
+ applyPendingHighlight(selection) {
331
+ const range = this.rangeForOffsets(selection.startOffset, selection.endOffset);
332
+ if (!range) return;
333
+
334
+ this.setHighlight(PENDING_HIGHLIGHT_NAME, [range]);
335
+ }
336
+
337
+ renderSavedHighlights() {
338
+ const ranges = this.annotationsValue
339
+ .map((annotation) => {
340
+ if (this.editingAnnotationId && String(annotation.id) === String(this.editingAnnotationId)) return null;
341
+
342
+ const startOffset = Number(annotation.start_offset);
343
+ const endOffset = Number(annotation.end_offset);
344
+ if (!Number.isFinite(startOffset) || !Number.isFinite(endOffset) || endOffset <= startOffset) return null;
345
+
346
+ return this.rangeForOffsets(startOffset, endOffset);
347
+ })
348
+ .filter((range) => range !== null);
349
+
350
+ this.setHighlight(SAVED_HIGHLIGHT_NAME, ranges);
351
+ }
352
+
353
+ setHighlight(name, ranges) {
354
+ if (!this.highlightsSupported) return;
355
+
356
+ this.clearHighlight(name);
357
+ if (ranges.length === 0) return;
358
+
359
+ const highlight = new Highlight();
360
+ ranges.forEach((range) => highlight.add(range));
361
+ CSS.highlights.set(name, highlight);
362
+ }
363
+
364
+ clearPendingHighlight() {
365
+ this.clearHighlight(PENDING_HIGHLIGHT_NAME);
366
+ }
367
+
368
+ clearSavedHighlights() {
369
+ this.clearHighlight(SAVED_HIGHLIGHT_NAME);
370
+ }
371
+
372
+ clearHighlight(name) {
373
+ if (!this.highlightsSupported) return;
374
+
375
+ CSS.highlights.delete(name);
376
+ }
377
+
378
+ rangeForOffsets(startOffset, endOffset) {
379
+ const walker = document.createTreeWalker(this.annotatableTarget, NodeFilter.SHOW_TEXT);
380
+ let position = 0;
381
+ let startNode = null;
382
+ let startPosition = 0;
383
+ let endNode = null;
384
+ let endPosition = 0;
385
+
386
+ while (walker.nextNode()) {
387
+ const node = walker.currentNode;
388
+ const length = node.textContent.length;
389
+ const nextPosition = position + length;
390
+
391
+ if (!startNode && startOffset <= nextPosition) {
392
+ startNode = node;
393
+ startPosition = startOffset - position;
394
+ }
395
+
396
+ if (!endNode && endOffset <= nextPosition) {
397
+ endNode = node;
398
+ endPosition = endOffset - position;
399
+ break;
400
+ }
401
+
402
+ position = nextPosition;
403
+ }
404
+
405
+ if (!startNode || !endNode) return null;
406
+
407
+ const range = document.createRange();
408
+ range.setStart(startNode, startPosition);
409
+ range.setEnd(endNode, endPosition);
410
+ return range;
411
+ }
412
+
413
+ emptyAnnotationListItem() {
414
+ const item = document.createElement("p");
415
+ item.className = "aa-annotations-empty";
416
+ item.textContent = "No annotations yet.";
417
+ return item;
418
+ }
419
+
420
+ annotationListItem(annotation) {
421
+ const item = document.createElement("article");
422
+ item.className = "aa-annotations-annotation-item";
423
+ const categoryLabel = this.categoryLabelFor(annotation.category);
424
+ item.innerHTML = `
425
+ <blockquote>${this.escapeHtml(annotation.selected_text)}</blockquote>
426
+ <p>${this.escapeHtml(annotation.comment)}</p>
427
+ ${annotation.category ? `<p><strong>${this.escapeHtml(this.categoryLabelValue)}:</strong> ${this.escapeHtml(categoryLabel)}</p>` : ""}
428
+ <div class="aa-annotations-annotation-actions">
429
+ <button type="button" class="action-item-button" data-annotation-id="${this.escapeHtml(String(annotation.id))}" data-action="activeadmin-annotations--annotator#editAnnotation">Edit</button>
430
+ <button type="button" class="action-item-button aa-annotations-delete-button" data-annotation-id="${this.escapeHtml(String(annotation.id))}" data-action="activeadmin-annotations--annotator#deleteAnnotation">Delete</button>
431
+ </div>
432
+ `;
433
+ return item;
434
+ }
435
+
436
+ setComposerHeading(title) {
437
+ if (this.hasComposerHeadingTarget) this.composerHeadingTarget.textContent = title;
438
+ }
439
+
440
+ categoryLabelFor(value) {
441
+ if (!value) return "";
442
+ return this.categoriesValue[value] || value;
443
+ }
444
+
445
+ annotationUrl(annotationId) {
446
+ return this.updateUrlTemplateValue.replace(":id", annotationId);
447
+ }
448
+
449
+ normalizeAnnotation(annotation) {
450
+ return {
451
+ id: annotation.id,
452
+ field_name: annotation.field_name,
453
+ selected_text: annotation.selected_text,
454
+ start_offset: annotation.start_offset,
455
+ end_offset: annotation.end_offset,
456
+ comment: annotation.comment,
457
+ category: annotation.category,
458
+ };
459
+ }
460
+
461
+ showError(message) {
462
+ this.errorMessageTarget.textContent = message;
463
+ this.errorMessageTarget.classList.remove("aa-annotations-hidden");
464
+ }
465
+
466
+ hideError() {
467
+ this.errorMessageTarget.textContent = "";
468
+ this.errorMessageTarget.classList.add("aa-annotations-hidden");
469
+ }
470
+
471
+ csrfToken() {
472
+ const meta = document.querySelector("meta[name='csrf-token']");
473
+ return meta ? meta.getAttribute("content") : "";
474
+ }
475
+
476
+ escapeHtml(value) {
477
+ return value
478
+ .replaceAll("&", "&amp;")
479
+ .replaceAll("<", "&lt;")
480
+ .replaceAll(">", "&gt;")
481
+ .replaceAll('"', "&quot;");
482
+ }
483
+ }
@@ -0,0 +1,73 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ export default class extends Controller {
4
+ static targets = ["status", "manualCopy"];
5
+
6
+ static values = {
7
+ sourceUrl: String,
8
+ successMessage: { type: String, default: "Copied annotation details" },
9
+ };
10
+
11
+ async copyFromUrl() {
12
+ if (!this.hasSourceUrlValue || !this.sourceUrlValue) {
13
+ this.announce("Annotation details are not available to copy.");
14
+ return;
15
+ }
16
+
17
+ try {
18
+ const response = await fetch(this.sourceUrlValue, {
19
+ credentials: "same-origin",
20
+ headers: { Accept: "text/plain" },
21
+ });
22
+ if (!response.ok) {
23
+ this.announce("Annotation details could not be loaded.");
24
+ return;
25
+ }
26
+
27
+ const text = await response.text();
28
+ await this.copyText(text, this.successMessageValue);
29
+ } catch (_error) {
30
+ this.announce("Annotation details could not be copied.");
31
+ }
32
+ }
33
+
34
+ async copyText(text, successMessage) {
35
+ if (!text) {
36
+ this.announce("There is nothing to copy for this annotation.");
37
+ return;
38
+ }
39
+
40
+ if (navigator.clipboard && navigator.clipboard.writeText) {
41
+ try {
42
+ await navigator.clipboard.writeText(text);
43
+ this.hideManualCopy();
44
+ this.announce(successMessage);
45
+ return;
46
+ } catch (_error) {}
47
+ }
48
+
49
+ if (!this.hasManualCopyTarget) {
50
+ this.announce("Clipboard access failed.");
51
+ return;
52
+ }
53
+
54
+ this.manualCopyTarget.hidden = false;
55
+ this.manualCopyTarget.value = text;
56
+ this.manualCopyTarget.focus();
57
+ this.manualCopyTarget.select();
58
+ this.announce("Clipboard access failed. The annotation details are selected for manual copy.");
59
+ }
60
+
61
+ hideManualCopy() {
62
+ if (!this.hasManualCopyTarget) return;
63
+
64
+ this.manualCopyTarget.hidden = true;
65
+ this.manualCopyTarget.value = "";
66
+ }
67
+
68
+ announce(message) {
69
+ if (!this.hasStatusTarget) return;
70
+
71
+ this.statusTarget.textContent = message;
72
+ }
73
+ }