ngx-annotate-text
Version:
This Angular component library is perfect for tasks like visualizing named entity recognition, part of speech tagging, or annotating text datasets. It allows for interactively highlighting and annotating parts of text.
242 lines (234 loc) • 19.4 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, Input, Component, inject, ElementRef, EventEmitter, Output } from '@angular/core';
import { NgComponentOutlet } from '@angular/common';
class Annotation {
startIndex;
endIndex;
text;
label;
color;
/**
* Represents an annotated part of the referenced text.
*
* @param startIndex The zero-based index number indicating the beginning of this annotation.
* @param endIndex The zero-based index number indicating the end of the annotation. The annotation
* includes the characters up to, but not including, the character indicated by the end.
* @param label Arbitrary string displayed as label below the annotation, e. g. `City`.
* @param color The color of the box which is displayed around the annotation, e. g. 'red' or 'rgb(220, 53, 69)'.
*/
constructor(startIndex, endIndex, label, color) {
this.startIndex = startIndex;
this.endIndex = endIndex;
this.label = label;
this.color = color;
}
toString() {
return `Annotation(startIndex=${this.startIndex}, endIndex=${this.endIndex}, label=${this.label}, color=${this.color}, text=${this.text})`;
}
}
class TokenizerService {
splitTextIntoTokens(text, annotations) {
if (annotations.length === 0) {
return [text];
}
const annotationStartingIndices = new Map();
const tokens = [];
// Creates a map which contains the starting indices for each annotation
// as keys. This way, we know the positions / indices in the text where
// we need to display an annotation instead of the plaintext.
annotations.forEach((a) => {
annotationStartingIndices.set(a.startIndex, a);
a.text = text.substring(a.startIndex, a.endIndex);
});
let currentIndex = 0;
let isAnnotationActive = false;
let annotationActiveUntilIndex = 0;
let buffer = '';
text.split('').forEach((char) => {
if (annotationActiveUntilIndex === currentIndex) {
isAnnotationActive = false;
}
if (!annotationStartingIndices.has(currentIndex) && !isAnnotationActive) {
buffer += char;
}
else if (annotationStartingIndices.has(currentIndex)) {
if (buffer.length > 0) {
tokens.push(buffer);
}
tokens.push(annotationStartingIndices.get(currentIndex));
annotationActiveUntilIndex = annotationStartingIndices.get(currentIndex).endIndex;
buffer = '';
isAnnotationActive = true;
}
currentIndex++;
});
if (buffer.length > 0) {
tokens.push(buffer);
}
return tokens;
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: TokenizerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: TokenizerService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: TokenizerService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}] });
class NgxAnnotationRendererComponent {
annotation;
removable = true;
clickAnnotation;
removeAnnotation;
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: NgxAnnotationRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.1", type: NgxAnnotationRendererComponent, isStandalone: true, selector: "ngx-annotation-renderer", inputs: { annotation: "annotation", removable: "removable", clickAnnotation: "clickAnnotation", removeAnnotation: "removeAnnotation" }, ngImport: i0, template: "@if (annotation) {\n <button\n class=\"annotation-parent\"\n [style.border-color]=\"annotation.color\"\n [style.background-color]=\"annotation.color\"\n (click)=\"clickAnnotation(annotation)\"\n >\n <span class=\"annotation-content\">\n <pre>{{ annotation.text }}</pre>\n </span>\n\n <!-- Instead of setting the property \"innerText\" of this HTML element, we set \"data-label\". In CSS, we can then\n reference the content of this property and can use the CSS pseudo- element \"::after\" to insert the content\n of \"data-label\" as text into the DOM. What's the advantage of this? At first, I tried to use the \"innerText\"\n property of this HTML element to visualize the annotation's label. Whenever the user selected a range of the\n original text, then the labels of the annotations were counted into the range, too. This made it difficult to\n extract which text range the user actually selected. By using the CSS pseudo-class \"::after\", we can prevent\n the annotations' labels from being included into the selected text range. -->\n\n <span class=\"annotation-label\" [attr.data-label]=\"annotation.label\" [style.background-color]=\"annotation.color\">\n </span>\n\n @if (removable) {\n <span class=\"annotation-button\">\n <span>\n <button\n class=\"remove-annotation\"\n (click)=\"removeAnnotation(annotation)\"\n aria-label=\"remove annotation\"\n ></button>\n </span>\n </span>\n }\n </button>\n}\n", styles: ["button.annotation-parent{border:2px solid;border-radius:4px;box-shadow:2px 4px 20px #0000001a;color:#0009;display:block;font-weight:500;line-height:1.3;margin:0 0 .5rem;min-width:26px;padding:0;white-space:normal;word-break:break-word;word-wrap:break-word}span.annotation-content{align-items:center;background-color:#fff;border-top-left-radius:2px;border-top-right-radius:2px;display:flex;flex-wrap:wrap;justify-content:center;padding:0 2px;text-align:center;white-space:normal}span.annotation-content pre{background-color:#0000;color:inherit;font-family:inherit;font-size:inherit;display:block;margin-top:0;margin-bottom:0;overflow:auto;box-sizing:border-box;padding:0}span.annotation-label{color:#fff;align-items:center;justify-content:center;text-align:center;padding:2px 8px 0;display:flex;margin:0;line-height:.8rem;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}span.annotation-label:after{content:attr(data-label)}span.annotation-button{position:relative;display:block;height:0;width:100%}span.annotation-button span{position:absolute;top:-2.9rem;right:.47rem}button.remove-annotation{position:absolute;height:16px;width:16px;min-width:16px;max-width:16px;min-height:16px;max-height:16px;border:none;border-radius:1000px;appearance:none;background-color:#0a0a0a33;display:inline-block;outline:0;flex-shrink:0;flex-grow:0;-webkit-user-select:none;user-select:none;cursor:pointer}button.remove-annotation:hover{background-color:#0a0a0a80}button.remove-annotation:before,button.remove-annotation:after{background-color:#fff;content:\"\";display:block;left:50%;position:absolute;top:50%;transform:translate(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}button.remove-annotation:after{height:50%;width:2px}button.remove-annotation:before{height:2px;width:50%}\n"] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: NgxAnnotationRendererComponent, decorators: [{
type: Component,
args: [{ selector: 'ngx-annotation-renderer', standalone: true, template: "@if (annotation) {\n <button\n class=\"annotation-parent\"\n [style.border-color]=\"annotation.color\"\n [style.background-color]=\"annotation.color\"\n (click)=\"clickAnnotation(annotation)\"\n >\n <span class=\"annotation-content\">\n <pre>{{ annotation.text }}</pre>\n </span>\n\n <!-- Instead of setting the property \"innerText\" of this HTML element, we set \"data-label\". In CSS, we can then\n reference the content of this property and can use the CSS pseudo- element \"::after\" to insert the content\n of \"data-label\" as text into the DOM. What's the advantage of this? At first, I tried to use the \"innerText\"\n property of this HTML element to visualize the annotation's label. Whenever the user selected a range of the\n original text, then the labels of the annotations were counted into the range, too. This made it difficult to\n extract which text range the user actually selected. By using the CSS pseudo-class \"::after\", we can prevent\n the annotations' labels from being included into the selected text range. -->\n\n <span class=\"annotation-label\" [attr.data-label]=\"annotation.label\" [style.background-color]=\"annotation.color\">\n </span>\n\n @if (removable) {\n <span class=\"annotation-button\">\n <span>\n <button\n class=\"remove-annotation\"\n (click)=\"removeAnnotation(annotation)\"\n aria-label=\"remove annotation\"\n ></button>\n </span>\n </span>\n }\n </button>\n}\n", styles: ["button.annotation-parent{border:2px solid;border-radius:4px;box-shadow:2px 4px 20px #0000001a;color:#0009;display:block;font-weight:500;line-height:1.3;margin:0 0 .5rem;min-width:26px;padding:0;white-space:normal;word-break:break-word;word-wrap:break-word}span.annotation-content{align-items:center;background-color:#fff;border-top-left-radius:2px;border-top-right-radius:2px;display:flex;flex-wrap:wrap;justify-content:center;padding:0 2px;text-align:center;white-space:normal}span.annotation-content pre{background-color:#0000;color:inherit;font-family:inherit;font-size:inherit;display:block;margin-top:0;margin-bottom:0;overflow:auto;box-sizing:border-box;padding:0}span.annotation-label{color:#fff;align-items:center;justify-content:center;text-align:center;padding:2px 8px 0;display:flex;margin:0;line-height:.8rem;-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}span.annotation-label:after{content:attr(data-label)}span.annotation-button{position:relative;display:block;height:0;width:100%}span.annotation-button span{position:absolute;top:-2.9rem;right:.47rem}button.remove-annotation{position:absolute;height:16px;width:16px;min-width:16px;max-width:16px;min-height:16px;max-height:16px;border:none;border-radius:1000px;appearance:none;background-color:#0a0a0a33;display:inline-block;outline:0;flex-shrink:0;flex-grow:0;-webkit-user-select:none;user-select:none;cursor:pointer}button.remove-annotation:hover{background-color:#0a0a0a80}button.remove-annotation:before,button.remove-annotation:after{background-color:#fff;content:\"\";display:block;left:50%;position:absolute;top:50%;transform:translate(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}button.remove-annotation:after{height:50%;width:2px}button.remove-annotation:before{height:2px;width:50%}\n"] }]
}], propDecorators: { annotation: [{
type: Input,
args: [{ required: true }]
}], removable: [{
type: Input
}], clickAnnotation: [{
type: Input
}], removeAnnotation: [{
type: Input
}] } });
class NgxAnnotateTextComponent {
elementRef = inject(ElementRef);
tokenService = inject(TokenizerService);
/** Represents the parts of the given text which shall be annotated. */
annotations = [];
/** An optional CSS class applied to all elements which wrap the annotated parts of the given text. */
annotationClass;
/**
* An optional Angular component that shall be used for rendering the annotation. By default, it uses the provided `NgxAnnotationRendererComponent`.
* You can implement your own annotation rendering component to customize the visualization of annotations. The custom component must implement the
* interface `NgxAnnotationRendererComponentInterface`.
*/
annotationRendererComponent = NgxAnnotationRendererComponent;
/**
* Determines whether annotations shall have a small button in the top right corner so that the user can
* remove an annotation.
*/
removable = true;
/** The text which shall be displayed and annotated. */
text = '';
/** Emits the list of existing annotations after an element has been removed. */
annotationsChange = new EventEmitter();
/** Emits the selected annotation when the user clicks on an annotation's box, the label or text. */
clickAnnotation = new EventEmitter();
/** Emits the selected annotation when the user removes it by clicking the annotation's X button in the upper right corner. */
removeAnnotation = new EventEmitter();
/** @internal */
tokens = [];
selectionStart;
selectionEnd;
ngOnInit() {
this.tokens = this.tokenService.splitTextIntoTokens(this.text, this.annotations);
}
ngOnChanges(changes) {
if ('annotations' in changes || 'text' in changes) {
this.tokens = this.tokenService.splitTextIntoTokens(this.text, this.annotations);
}
}
/**
* Provides start and end index of the currently selected text.
*
* @returns Returns the start index and end index of the currently selected text range. Returns `undefined`
* if no text is currently selected.
*/
getCurrentTextSelection() {
this.updateTextSelection();
if (this.selectionStart === undefined ||
this.selectionEnd === undefined ||
this.selectionStart >= this.selectionEnd) {
return undefined;
}
return { startIndex: this.selectionStart, endIndex: this.selectionEnd };
}
/**
* Checks whether the given text selection is overlapping with existing annotations.
*
* @param selection The current text selection.
* @returns Returns `true` if the given text selection is (partially) overlapping with
* an existing annotation. Returns `false` otherwise.
*/
isOverlappingWithExistingAnnotations(selection) {
const overlappingAnnotation = this.annotations.find((annotation) => {
return Math.max(annotation.startIndex, selection.startIndex) < Math.min(annotation.endIndex, selection.endIndex);
});
return overlappingAnnotation != undefined;
}
/** @internal */
isAnnotation(annotation) {
return annotation instanceof Annotation;
}
/** @internal */
toRecord(token) {
if (token instanceof Annotation) {
return {
annotation: token,
removable: this.removable,
clickAnnotation: (annotation) => this.clickAnnotation.emit(annotation),
removeAnnotation: (annotation) => this.onRemoveAnnotation(annotation),
};
}
throw new TypeError(`Cannot convert token of type ${typeof token} to type Record`);
}
/** @internal */
onRemoveAnnotation(annotation) {
this.annotations = this.annotations.filter((a) => a !== annotation);
this.removeAnnotation.emit(annotation);
this.annotationsChange.emit(this.annotations);
this.tokens = this.tokenService.splitTextIntoTokens(this.text, this.annotations);
}
updateTextSelection() {
const selection = window.getSelection();
if (selection != null && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const preSelectionRange = range.cloneRange();
preSelectionRange.selectNodeContents(this.elementRef.nativeElement);
preSelectionRange.setEnd(range.startContainer, range.startOffset);
this.selectionStart = [...preSelectionRange.toString()].length;
this.selectionEnd = this.selectionStart + [...range.toString()].length;
}
else {
this.selectionStart = undefined;
this.selectionEnd = undefined;
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: NgxAnnotateTextComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.1", type: NgxAnnotateTextComponent, isStandalone: true, selector: "ngx-annotate-text", inputs: { annotations: "annotations", annotationClass: "annotationClass", annotationRendererComponent: "annotationRendererComponent", removable: "removable", text: "text" }, outputs: { annotationsChange: "annotationsChange", clickAnnotation: "clickAnnotation", removeAnnotation: "removeAnnotation" }, usesOnChanges: true, ngImport: i0, template: "@for (token of tokens; track token) {\n <span>\n @if (isAnnotation(token)) {\n <ng-container\n *ngComponentOutlet=\"annotationRendererComponent; inputs: toRecord(token)\"\n [class]=\"annotationClass || ''\"\n ></ng-container>\n } @else {\n <span class=\"unlabeled\">{{ token }}</span>\n }\n </span>\n}\n", styles: [":host{align-items:flex-start;display:flex;flex-wrap:wrap;white-space:pre-wrap}span.unlabeled{line-height:1.5rem}\n"], dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: NgxAnnotateTextComponent, decorators: [{
type: Component,
args: [{ selector: 'ngx-annotate-text', imports: [NgComponentOutlet], standalone: true, template: "@for (token of tokens; track token) {\n <span>\n @if (isAnnotation(token)) {\n <ng-container\n *ngComponentOutlet=\"annotationRendererComponent; inputs: toRecord(token)\"\n [class]=\"annotationClass || ''\"\n ></ng-container>\n } @else {\n <span class=\"unlabeled\">{{ token }}</span>\n }\n </span>\n}\n", styles: [":host{align-items:flex-start;display:flex;flex-wrap:wrap;white-space:pre-wrap}span.unlabeled{line-height:1.5rem}\n"] }]
}], propDecorators: { annotations: [{
type: Input
}], annotationClass: [{
type: Input
}], annotationRendererComponent: [{
type: Input
}], removable: [{
type: Input
}], text: [{
type: Input
}], annotationsChange: [{
type: Output
}], clickAnnotation: [{
type: Output
}], removeAnnotation: [{
type: Output
}] } });
/*
* Public API Surface of ngx-annotate-text
*/
/**
* Generated bundle index. Do not edit.
*/
export { Annotation, NgxAnnotateTextComponent, NgxAnnotationRendererComponent };
//# sourceMappingURL=ngx-annotate-text.mjs.map