activeadmin_annotations 0.1.1 → 0.1.2

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/CODE_OF_CONDUCT.md +14 -19
  4. data/CONTRIBUTING.md +9 -15
  5. data/GOVERNANCE.md +10 -14
  6. data/README.md +24 -7
  7. data/SECURITY.md +14 -16
  8. data/activeadmin_annotations.gemspec +2 -2
  9. data/admin/annotation_reviews.rb +14 -6
  10. data/admin/annotations.rb +33 -13
  11. data/app/assets/controllers/activeadmin_annotations/annotation_client.js +44 -0
  12. data/app/assets/controllers/activeadmin_annotations/annotation_list.js +29 -0
  13. data/app/assets/controllers/activeadmin_annotations/annotator_controller.js +45 -370
  14. data/app/assets/controllers/activeadmin_annotations/composer.js +179 -0
  15. data/app/assets/controllers/activeadmin_annotations/highlights.js +76 -0
  16. data/app/assets/controllers/activeadmin_annotations/selection.js +73 -0
  17. data/app/assets/javascripts/activeadmin_annotations/annotator_logic.mjs +73 -0
  18. data/app/assets/stylesheets/activeadmin_annotations.css +15 -0
  19. data/app/helpers/active_admin/annotations/panel_helper.rb +15 -8
  20. data/app/models/active_admin/annotations/review.rb +7 -2
  21. data/app/services/active_admin/annotations/review_service.rb +34 -27
  22. data/app/views/active_admin/annotations/_panel.html.erb +15 -2
  23. data/db/migrate/20260617120000_create_activeadmin_annotations_tables.rb +27 -10
  24. data/db/migrate/20260706150000_add_metadata_json_gin_index_to_annotation_reviews.rb +1 -1
  25. data/db/migrate/20260710120000_add_panel_query_index_to_annotation_spans.rb +1 -1
  26. data/lib/activeadmin/annotations/copy_text.rb +0 -1
  27. data/lib/activeadmin/annotations/engine.rb +16 -1
  28. data/lib/activeadmin/annotations/exporter.rb +1 -1
  29. data/lib/activeadmin/annotations/panel_html.rb +21 -0
  30. data/lib/activeadmin/annotations/review_access.rb +13 -1
  31. data/lib/activeadmin/annotations/review_panel.rb +9 -6
  32. data/lib/activeadmin/annotations/span_create.rb +70 -0
  33. data/lib/activeadmin/annotations/version.rb +1 -1
  34. data/lib/activeadmin_annotations.rb +2 -0
  35. metadata +12 -4
@@ -1,7 +1,14 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
-
3
- const PENDING_HIGHLIGHT_NAME = "aa-annotations-pending";
4
- const SAVED_HIGHLIGHT_NAME = "aa-annotations-saved";
2
+ import { currentSelection } from "activeadmin_annotations/selection";
3
+ import { savedHighlightOffsetPairs } from "activeadmin_annotations/annotator_logic";
4
+ import {
5
+ HighlightLayer,
6
+ PENDING_HIGHLIGHT_NAME,
7
+ SAVED_HIGHLIGHT_NAME,
8
+ } from "activeadmin_annotations/highlights";
9
+ import { emptyAnnotationListItem, annotationListItem } from "activeadmin_annotations/annotation_list";
10
+ import { AnnotationClient, csrfToken } from "activeadmin_annotations/annotation_client";
11
+ import { Composer } from "activeadmin_annotations/composer";
5
12
 
6
13
  export default class extends Controller {
7
14
  static targets = [
@@ -19,6 +26,11 @@ export default class extends Controller {
19
26
  static values = {
20
27
  reviewId: String,
21
28
  field: String,
29
+ subjectType: String,
30
+ subjectId: String,
31
+ context: { type: Object, default: {} },
32
+ contextDigest: String,
33
+ contentRevisionVersion: Number,
22
34
  createUrl: String,
23
35
  updateUrlTemplate: String,
24
36
  categoryLabel: { type: String, default: "Category" },
@@ -30,7 +42,9 @@ export default class extends Controller {
30
42
  connect() {
31
43
  this.pendingSelection = null;
32
44
  this.editingAnnotationId = null;
33
- this.highlightsSupported = typeof CSS !== "undefined" && CSS.highlights;
45
+ this.highlightLayer = new HighlightLayer(this.annotatableTarget);
46
+ this.annotationClient = new AnnotationClient({ csrfToken: csrfToken() });
47
+ this.composer = new Composer(this);
34
48
  this.renderAnnotations();
35
49
  this.annotatableTarget.addEventListener("mouseup", this.commitAnnotatableSelection);
36
50
  this.annotatableTarget.addEventListener("keyup", this.commitAnnotatableSelection);
@@ -43,8 +57,7 @@ export default class extends Controller {
43
57
  this.annotatableTarget.removeEventListener("keyup", this.commitAnnotatableSelection);
44
58
  document.removeEventListener("selectionchange", this.handleSelectionChange);
45
59
  document.removeEventListener("keydown", this.handleDocumentKeydown);
46
- this.clearPendingHighlight();
47
- this.clearSavedHighlights();
60
+ this.highlightLayer.disconnect();
48
61
  }
49
62
 
50
63
  commitAnnotatableSelection = () => {
@@ -52,39 +65,15 @@ export default class extends Controller {
52
65
 
53
66
  requestAnimationFrame(() => {
54
67
  const selection = this.currentSelection();
55
- if (selection) this.activateComposer(selection);
68
+ if (selection) this.composer.activate(selection);
56
69
  });
57
70
  };
58
71
 
59
72
  handleSelectionChange = () => {
60
73
  if (this.readonlyValue) return;
61
- if (this.composerIsOpen()) this.ensurePendingHighlight();
74
+ if (this.composer.isOpen()) this.composer.ensurePendingHighlight();
62
75
  };
63
76
 
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
77
  editAnnotation(event) {
89
78
  if (this.readonlyValue) return;
90
79
 
@@ -92,21 +81,7 @@ export default class extends Controller {
92
81
  const annotation = this.annotationsValue.find((entry) => String(entry.id) === String(annotationId));
93
82
  if (!annotation) return;
94
83
 
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();
84
+ this.composer.edit(annotation);
110
85
  }
111
86
 
112
87
  async deleteAnnotation(event) {
@@ -115,369 +90,69 @@ export default class extends Controller {
115
90
  const annotationId = event.currentTarget.dataset.annotationId;
116
91
  const annotation = this.annotationsValue.find((entry) => String(entry.id) === String(annotationId));
117
92
  if (!annotation) return;
118
-
119
93
  if (!window.confirm("Delete this annotation?")) return;
120
94
 
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");
95
+ await this.composer.destroy(annotationId);
161
96
  }
162
97
 
163
98
  cancelComposer() {
164
- this.resetComposer();
165
- window.getSelection()?.removeAllRanges();
166
- }
167
-
168
- ensurePendingHighlight() {
169
- if (!this.pendingSelection) return;
170
-
171
- this.applyPendingHighlight(this.pendingSelection);
99
+ this.composer.cancel();
172
100
  }
173
101
 
174
102
  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
- }
103
+ await this.composer.submit();
266
104
  }
267
105
 
268
106
  handleDocumentKeydown = (event) => {
269
107
  if (event.key !== "Escape") return;
270
- if (!this.composerIsOpen()) return;
108
+ if (!this.composer.isOpen()) return;
271
109
 
272
- this.cancelComposer();
110
+ this.composer.cancel();
273
111
  };
274
112
 
275
113
  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;
114
+ return currentSelection(window.getSelection(), this.annotatableTarget);
313
115
  }
314
116
 
315
117
  renderAnnotations() {
316
- if (!this.composerIsOpen()) this.clearPendingHighlight();
118
+ if (!this.composer.isOpen()) this.clearPendingHighlight();
317
119
  this.renderSavedHighlights();
318
120
  this.annotationListTarget.innerHTML = "";
319
121
 
320
122
  if (this.annotationsValue.length === 0) {
321
- this.annotationListTarget.appendChild(this.emptyAnnotationListItem());
123
+ this.annotationListTarget.appendChild(emptyAnnotationListItem());
322
124
  return;
323
125
  }
324
126
 
325
127
  this.annotationsValue.forEach((annotation) => {
326
- this.annotationListTarget.appendChild(this.annotationListItem(annotation));
128
+ this.annotationListTarget.appendChild(
129
+ annotationListItem(annotation, {
130
+ readonly: this.readonlyValue,
131
+ categoryLabel: this.categoryLabelValue,
132
+ categories: this.categoriesValue,
133
+ })
134
+ );
327
135
  });
328
136
  }
329
137
 
330
138
  applyPendingHighlight(selection) {
331
- const range = this.rangeForOffsets(selection.startOffset, selection.endOffset);
332
- if (!range) return;
333
-
334
- this.setHighlight(PENDING_HIGHLIGHT_NAME, [range]);
139
+ this.highlightLayer.set(PENDING_HIGHLIGHT_NAME, [
140
+ [selection.startOffset, selection.endOffset],
141
+ ]);
335
142
  }
336
143
 
337
144
  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);
145
+ this.highlightLayer.set(
146
+ SAVED_HIGHLIGHT_NAME,
147
+ savedHighlightOffsetPairs(this.annotationsValue, this.editingAnnotationId)
148
+ );
362
149
  }
363
150
 
364
151
  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;
152
+ this.highlightLayer.clear(PENDING_HIGHLIGHT_NAME);
443
153
  }
444
154
 
445
155
  annotationUrl(annotationId) {
446
156
  return this.updateUrlTemplateValue.replace(":id", annotationId);
447
157
  }
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
158
  }
@@ -0,0 +1,179 @@
1
+ import { createAnnotationPayload, normalizeAnnotation } from "activeadmin_annotations/annotator_logic";
2
+
3
+ export class Composer {
4
+ constructor(controller) {
5
+ this.controller = controller;
6
+ }
7
+
8
+ activate(selection) {
9
+ const panel = this.controller;
10
+ panel.editingAnnotationId = null;
11
+
12
+ const selectionChanged =
13
+ !panel.pendingSelection ||
14
+ panel.pendingSelection.startOffset !== selection.startOffset ||
15
+ panel.pendingSelection.endOffset !== selection.endOffset ||
16
+ panel.pendingSelection.selectedText !== selection.selectedText;
17
+
18
+ panel.pendingSelection = selection;
19
+ panel.selectedPreviewTarget.textContent = selection.selectedText;
20
+
21
+ if (selectionChanged) {
22
+ panel.commentInputTarget.value = "";
23
+ panel.categoryInputTarget.value = "";
24
+ }
25
+
26
+ this.hideError();
27
+ this.setHeading("New annotation");
28
+ panel.composerTarget.classList.remove("aa-annotations-composer-hidden");
29
+ panel.applyPendingHighlight(selection);
30
+ window.getSelection()?.removeAllRanges();
31
+ }
32
+
33
+ edit(annotation) {
34
+ const panel = this.controller;
35
+ panel.editingAnnotationId = annotation.id;
36
+ panel.pendingSelection = {
37
+ selectedText: annotation.selected_text,
38
+ startOffset: Number(annotation.start_offset),
39
+ endOffset: Number(annotation.end_offset),
40
+ };
41
+ panel.selectedPreviewTarget.textContent = annotation.selected_text;
42
+ panel.commentInputTarget.value = annotation.comment;
43
+ panel.categoryInputTarget.value = annotation.category || "";
44
+ this.hideError();
45
+ this.setHeading("Edit annotation");
46
+ panel.composerTarget.classList.remove("aa-annotations-composer-hidden");
47
+ panel.applyPendingHighlight(panel.pendingSelection);
48
+ window.getSelection()?.removeAllRanges();
49
+ panel.commentInputTarget.focus();
50
+ }
51
+
52
+ reset() {
53
+ const panel = this.controller;
54
+ panel.pendingSelection = null;
55
+ panel.editingAnnotationId = null;
56
+ panel.clearPendingHighlight();
57
+ panel.selectedPreviewTarget.textContent = "";
58
+ panel.commentInputTarget.value = "";
59
+ panel.categoryInputTarget.value = "";
60
+ this.hideError();
61
+ this.setHeading("New annotation");
62
+ panel.composerTarget.classList.add("aa-annotations-composer-hidden");
63
+ }
64
+
65
+ cancel() {
66
+ this.reset();
67
+ window.getSelection()?.removeAllRanges();
68
+ }
69
+
70
+ isOpen() {
71
+ return !this.controller.composerTarget.classList.contains("aa-annotations-composer-hidden");
72
+ }
73
+
74
+ ensurePendingHighlight() {
75
+ if (!this.controller.pendingSelection) return;
76
+ this.controller.applyPendingHighlight(this.controller.pendingSelection);
77
+ }
78
+
79
+ async submit() {
80
+ const panel = this.controller;
81
+ if (panel.readonlyValue) return;
82
+ if (!panel.pendingSelection) return;
83
+
84
+ const comment = panel.commentInputTarget.value.trim();
85
+ if (!comment) {
86
+ this.showError("Comment is required.");
87
+ return;
88
+ }
89
+
90
+ if (panel.editingAnnotationId) {
91
+ await this.update(comment);
92
+ return;
93
+ }
94
+
95
+ await this.create(comment);
96
+ }
97
+
98
+ async create(comment) {
99
+ const panel = this.controller;
100
+
101
+ try {
102
+ const annotation = await panel.annotationClient.create(
103
+ panel.createUrlValue,
104
+ this.createPayload(comment)
105
+ );
106
+ if (annotation.annotation_review_id) panel.reviewIdValue = annotation.annotation_review_id;
107
+ this.reset();
108
+ panel.annotationsValue = [...panel.annotationsValue, this.normalize(annotation)];
109
+ panel.renderAnnotations();
110
+ window.getSelection()?.removeAllRanges();
111
+ } catch (_error) {
112
+ this.showError("Could not save annotation.");
113
+ }
114
+ }
115
+
116
+ async update(comment) {
117
+ const panel = this.controller;
118
+ const payload = {
119
+ annotation: {
120
+ comment: comment,
121
+ category: panel.categoryInputTarget.value || null,
122
+ },
123
+ };
124
+
125
+ try {
126
+ const annotation = await panel.annotationClient.update(
127
+ panel.annotationUrl(panel.editingAnnotationId),
128
+ payload
129
+ );
130
+ this.reset();
131
+ panel.annotationsValue = panel.annotationsValue.map((entry) =>
132
+ String(entry.id) === String(annotation.id) ? this.normalize(annotation) : entry
133
+ );
134
+ panel.renderAnnotations();
135
+ } catch (_error) {
136
+ this.showError("Could not update annotation.");
137
+ }
138
+ }
139
+
140
+ async destroy(annotationId) {
141
+ const panel = this.controller;
142
+
143
+ try {
144
+ await panel.annotationClient.destroy(panel.annotationUrl(annotationId));
145
+ if (String(panel.editingAnnotationId) === String(annotationId)) this.reset();
146
+ panel.annotationsValue = panel.annotationsValue.filter(
147
+ (entry) => String(entry.id) !== String(annotationId)
148
+ );
149
+ panel.renderAnnotations();
150
+ } catch (_error) {
151
+ window.alert("Could not delete annotation.");
152
+ }
153
+ }
154
+
155
+ createPayload(comment) {
156
+ return createAnnotationPayload(this.controller, comment);
157
+ }
158
+
159
+ normalize(annotation) {
160
+ return normalizeAnnotation(annotation);
161
+ }
162
+
163
+ setHeading(title) {
164
+ const panel = this.controller;
165
+ if (panel.hasComposerHeadingTarget) panel.composerHeadingTarget.textContent = title;
166
+ }
167
+
168
+ showError(message) {
169
+ const panel = this.controller;
170
+ panel.errorMessageTarget.textContent = message;
171
+ panel.errorMessageTarget.classList.remove("aa-annotations-hidden");
172
+ }
173
+
174
+ hideError() {
175
+ const panel = this.controller;
176
+ panel.errorMessageTarget.textContent = "";
177
+ panel.errorMessageTarget.classList.add("aa-annotations-hidden");
178
+ }
179
+ }