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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/CODE_OF_CONDUCT.md +31 -0
- data/CONTRIBUTING.md +29 -0
- data/GOVERNANCE.md +29 -0
- data/LICENSE.md +21 -0
- data/README.md +321 -0
- data/SECURITY.md +27 -0
- data/activeadmin_annotations.gemspec +68 -0
- data/admin/annotation_reviews.rb +102 -0
- data/admin/annotations.rb +146 -0
- data/app/assets/controllers/activeadmin_annotations/annotator_controller.js +483 -0
- data/app/assets/controllers/activeadmin_annotations/clipboard_controller.js +73 -0
- data/app/assets/stylesheets/activeadmin_annotations.css +362 -0
- data/app/helpers/active_admin/annotations/panel_helper.rb +95 -0
- data/app/models/active_admin/annotations/annotation.rb +37 -0
- data/app/models/active_admin/annotations/application_record.rb +13 -0
- data/app/models/active_admin/annotations/review.rb +54 -0
- data/app/services/active_admin/annotations/review_service.rb +133 -0
- data/app/views/active_admin/annotations/_copy_details_action.html.erb +21 -0
- data/app/views/active_admin/annotations/_panel.html.erb +98 -0
- data/app/views/active_admin/annotations/_read_only_content.html.erb +3 -0
- data/config/polyrun_coverage.yml +8 -0
- data/db/migrate/20260617120000_create_activeadmin_annotations_tables.rb +41 -0
- data/db/migrate/20260706150000_add_metadata_json_gin_index_to_annotation_reviews.rb +9 -0
- data/db/migrate/20260710120000_add_panel_query_index_to_annotation_spans.rb +10 -0
- data/lib/activeadmin/annotations/annotatable.rb +15 -0
- data/lib/activeadmin/annotations/categories.rb +13 -0
- data/lib/activeadmin/annotations/content_revision.rb +101 -0
- data/lib/activeadmin/annotations/context.rb +11 -0
- data/lib/activeadmin/annotations/copy_text.rb +98 -0
- data/lib/activeadmin/annotations/engine.rb +49 -0
- data/lib/activeadmin/annotations/exporter.rb +99 -0
- data/lib/activeadmin/annotations/review_access.rb +29 -0
- data/lib/activeadmin/annotations/review_panel.rb +145 -0
- data/lib/activeadmin/annotations/version.rb +7 -0
- data/lib/activeadmin/annotations.rb +37 -0
- data/lib/activeadmin_annotations.rb +13 -0
- data/sig/activeadmin/annotations.rbs +5 -0
- 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("&", "&")
|
|
479
|
+
.replaceAll("<", "<")
|
|
480
|
+
.replaceAll(">", ">")
|
|
481
|
+
.replaceAll('"', """);
|
|
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
|
+
}
|