Create Annotation now has the ability to render a preview of note and change highlight color. Styling and mobile tweaks are still needed.

Updated all switch instances to have the "switch" attribute, which will render as a native switch on some iOS devices.
This commit is contained in:
Joseph Milazzo 2025-07-13 08:50:35 -05:00
parent a3d999b7b9
commit 5950a9b711
24 changed files with 1696 additions and 394 deletions

1025
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -45,12 +45,14 @@
"charts.css": "^1.1.0",
"file-saver": "^2.0.5",
"luxon": "^3.7.1",
"marked": "^15.0.12",
"ng-circle-progress": "^1.7.1",
"ng-lazyload-image": "^9.1.3",
"ng-select2-component": "^17.2.4",
"ngx-color-picker": "^20.0.0",
"ngx-extended-pdf-viewer": "^24.1.0",
"ngx-file-drop": "^16.0.0",
"ngx-markdown": "^20.0.0",
"ngx-stars": "^1.6.5",
"ngx-toastr": "^19.0.0",
"nosleep.js": "^0.12.0",
@ -72,6 +74,7 @@
"@types/d3": "^7.4.3",
"@types/file-saver": "^2.0.7",
"@types/luxon": "^3.6.2",
"@types/marked": "^5.0.2",
"@types/node": "^24.0.13",
"@typescript-eslint/eslint-plugin": "^8.36.0",
"@typescript-eslint/parser": "^8.36.0",

View File

@ -0,0 +1,18 @@
import {Pipe, PipeTransform} from '@angular/core';
import {HighlightColor} from "../book-reader/_models/annotation";
@Pipe({
name: 'highlightColor'
})
export class HighlightColorPipe implements PipeTransform {
transform(value: HighlightColor): string {
switch (value) {
case HighlightColor.Blue:
return 'blue';
case HighlightColor.Green:
return 'green';
}
}
}

View File

@ -0,0 +1,53 @@
import {inject, Injectable, ViewContainerRef} from '@angular/core';
import {Annotation} from "../book-reader/_models/annotation";
import {EpubHighlightComponent} from "../book-reader/_components/_annotations/epub-highlight/epub-highlight.component";
import {DOCUMENT} from "@angular/common";
@Injectable({
providedIn: 'root'
})
export class EpubHighlightService {
private readonly document = inject(DOCUMENT);
initializeHighlightElements(annotations: Annotation[], container: ViewContainerRef, selectFromElement?: Element | null | undefined,
configOptions: {showHighlight: boolean, showIcon: boolean} | null = null) {
const annotationsMap: {[key: number]: Annotation} = annotations.reduce((map, obj) => {
// @ts-ignore
map[obj.id] = obj;
return map;
}, {});
// Make the highlight components "real"
const selector = selectFromElement ?? this.document;
const highlightElems = selector.querySelectorAll('app-epub-highlight');
for (let i = 0; i < highlightElems.length; i++) {
const highlight = highlightElems[i];
const idAttr = highlight.getAttribute('id');
// Don't allow highlight injection unless the id is present
if (!idAttr) continue;
const annotationId = parseInt(idAttr.replace('epub-highlight-', ''), 10);
const componentRef = container.createComponent<EpubHighlightComponent>(EpubHighlightComponent,
{
projectableNodes: [
[document.createTextNode(highlight.innerHTML)]
]
});
if (highlight.parentNode != null) {
highlight.parentNode.replaceChild(componentRef.location.nativeElement, highlight);
}
componentRef.instance.annotation.set(annotationsMap[annotationId]);
if (configOptions != null) {
componentRef.instance.showHighlight.set(configOptions.showHighlight);
componentRef.instance.showIcon.set(configOptions.showIcon);
}
}
}
}

View File

@ -33,7 +33,7 @@
<app-setting-switch [title]="t('dont-match-label')" [subtitle]="t('dont-match-tooltip')">
<ng-template #switch>
<div class="form-check form-switch">
<input id="dont-match" type="checkbox" class="form-check-input" formControlName="dontMatch" role="switch">
<input id="dont-match" type="checkbox" class="form-check-input" formControlName="dontMatch" role="switch" switch>
</div>
</ng-template>
</app-setting-switch>

View File

@ -16,7 +16,7 @@
{{t('bulk-copy-to', {libraryName: sourceCopyToLibrary.name})}}
<form [formGroup]="bulkForm">
<div class="form-check form-switch">
<input id="bulk-action-type" type="checkbox" class="form-check-input" formControlName="includeType" aria-describedby="include-type-help">
<input id="bulk-action-type" type="checkbox" class="form-check-input" formControlName="includeType" aria-describedby="include-type-help" switch>
<label class="form-check-label" for="bulk-action-type">{{t('include-type-label')}}</label>
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="left" [ngbTooltip]="includeTypeTooltip" role="button" tabindex="0"></i>
<ng-template #includeTypeTooltip>{{t('include-type-tooltip')}}</ng-template>

View File

@ -1,16 +1,11 @@
<!--<span class="epub-highlight">-->
<!-- <i class="fa-solid fa-pen-clip" role="button" (click)="toggleHighlight()"></i>-->
<!-- <span [class]="highlightClasses()" [attr.data-highlight-color]="color()">-->
<!-- <ng-content />-->
<!-- </span>-->
<!--</span>-->
<span class="epub-highlight"
#highlightSpan
(mouseenter)="onMouseEnter()"
(mouseleave)="onMouseLeave()">
<i class="fa-solid fa-pen-clip" role="button" (click)="toggleHighlight()"></i>
<span [class]="highlightClasses()" [attr.data-highlight-color]="color()">
@if (showIcon()) {
<i class="fa-solid fa-pen-clip" role="button" (click)="toggleHighlight()"></i>
}
<span [class]="highlightClasses()" [attr.data-highlight-color]="annotation()!.highlightColor | highlightColor">
<ng-content />
</span>
</span>

View File

@ -12,102 +12,106 @@ import {
signal,
ViewChild
} from '@angular/core';
import {Annotation} from "../../../_models/annotation";
import {Annotation, HighlightColor} from "../../../_models/annotation";
import {AnnotationCardComponent} from "../annotation-card/annotation-card.component";
import {AnnotationCardService} from 'src/app/_service/annotation-card.service';
export type HighlightColor = 'blue' | 'green';
import {HighlightColorPipe} from "../../../../_pipes/highlight-color.pipe";
@Component({
selector: 'app-epub-highlight',
imports: [],
imports: [
HighlightColorPipe
],
templateUrl: './epub-highlight.component.html',
styleUrl: './epub-highlight.component.scss'
})
export class EpubHighlightComponent implements OnInit, AfterViewChecked, OnDestroy {
private annotationCardService = inject(AnnotationCardService);
showHighlight = model<boolean>(false);
color = input<HighlightColor>('blue');
color = input<HighlightColor>(HighlightColor.Blue);
annotation = model.required<Annotation | null>();
isHovered = signal<boolean>(false);
showIcon = model<boolean>(true);
@ViewChild('highlightSpan', { static: false }) highlightSpan!: ElementRef;
private resizeObserver?: ResizeObserver;
private annotationCardElement?: HTMLElement;
private annotationCardRef?: ComponentRef<AnnotationCardComponent>;
private readonly highlightColorPipe = new HighlightColorPipe();
private annotationCardService = inject(AnnotationCardService);
showAnnotationCard = computed(() => {
const annotation = this.annotation();
return this.showHighlight() && true; //annotation && annotation?.noteText.length > 0;
return this.showHighlight();
});
highlightClasses = computed(() => {
const baseClass = 'epub-highlight';
if (!this.showHighlight()) {
if (!this.showHighlight() || !this.annotation()) {
return '';
}
const colorClass = `epub-highlight-${this.color()}`;
const colorClass = `epub-highlight-${this.highlightColorPipe.transform(this.annotation()!.highlightColor)}`;
return `${colorClass}`;
});
cardPosition = computed(() => {
console.log('card position called')
if (!this.showHighlight() || !this.highlightSpan) return null;
const rect = this.highlightSpan.nativeElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const cardWidth = 200;
const cardHeight = 80; // Approximate card height
// Check if highlight is on left half (< 50%) or right half (>= 50%) of document
const highlightCenterX = rect.left + (rect.width / 2);
const isOnLeftHalf = highlightCenterX < (viewportWidth * 0.5);
const cardLeft = isOnLeftHalf
? Math.max(20, rect.left - cardWidth - 20) // Left side with margin consideration
: Math.min(viewportWidth - cardWidth - 20, rect.right + 20); // Right side
const cardTop = rect.top + window.scrollY;
// Calculate connection points
const highlightCenterY = rect.top + window.scrollY + (rect.height / 2);
const cardCenterY = cardTop + (cardHeight / 2);
// Connection points
const highlightPoint = {
x: isOnLeftHalf ? rect.left : rect.right,
y: highlightCenterY
};
const cardPoint = {
x: isOnLeftHalf ? cardLeft + cardWidth : cardLeft,
y: cardCenterY
};
// Calculate line properties
const deltaX = cardPoint.x - highlightPoint.x;
const deltaY = cardPoint.y - highlightPoint.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI;
return {
top: cardTop,
left: cardLeft,
isRight: !isOnLeftHalf,
connection: {
startX: highlightPoint.x,
startY: highlightPoint.y,
endX: cardPoint.x,
endY: cardPoint.y,
distance: distance,
angle: angle
}
};
});
// cardPosition = computed(() => {
// console.log('card position called')
// if (!this.showHighlight() || !this.highlightSpan) return null;
//
// const rect = this.highlightSpan.nativeElement.getBoundingClientRect();
// const viewportWidth = window.innerWidth;
// const cardWidth = 200;
// const cardHeight = 80; // Approximate card height
//
// // Check if highlight is on left half (< 50%) or right half (>= 50%) of document
// const highlightCenterX = rect.left + (rect.width / 2);
// const isOnLeftHalf = highlightCenterX < (viewportWidth * 0.5);
//
// const cardLeft = isOnLeftHalf
// ? Math.max(20, rect.left - cardWidth - 20) // Left side with margin consideration
// : Math.min(viewportWidth - cardWidth - 20, rect.right + 20); // Right side
//
// const cardTop = rect.top + window.scrollY;
//
// // Calculate connection points
// const highlightCenterY = rect.top + window.scrollY + (rect.height / 2);
// const cardCenterY = cardTop + (cardHeight / 2);
//
// // Connection points
// const highlightPoint = {
// x: isOnLeftHalf ? rect.left : rect.right,
// y: highlightCenterY
// };
//
// const cardPoint = {
// x: isOnLeftHalf ? cardLeft + cardWidth : cardLeft,
// y: cardCenterY
// };
//
// // Calculate line properties
// const deltaX = cardPoint.x - highlightPoint.x;
// const deltaY = cardPoint.y - highlightPoint.y;
// const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// const angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI;
//
// return {
// top: cardTop,
// left: cardLeft,
// isRight: !isOnLeftHalf,
// connection: {
// startX: highlightPoint.x,
// startY: highlightPoint.y,
// endX: cardPoint.x,
// endY: cardPoint.y,
// distance: distance,
// angle: angle
// }
// };
// });
ngOnInit() {
// Monitor viewport changes for repositioning
@ -121,11 +125,11 @@ export class EpubHighlightComponent implements OnInit, AfterViewChecked, OnDestr
}
ngAfterViewChecked() {
if (this.showAnnotationCard() && this.cardPosition()) {
this.createOrUpdateAnnotationCard();
} else {
this.removeAnnotationCard();
}
// if (this.showAnnotationCard() && this.cardPosition()) {
// this.createOrUpdateAnnotationCard();
// } else {
// this.removeAnnotationCard();
// }
}
ngOnDestroy() {
@ -153,100 +157,100 @@ export class EpubHighlightComponent implements OnInit, AfterViewChecked, OnDestr
// TODO: Figure this out
}
private createOrUpdateAnnotationCard() {
// const pos = this.cardPosition();
// if (!pos) return;
//
// // Remove existing card if it exists
// this.removeAnnotationCard();
//
// // Create new card element
// this.annotationCardElement = document.createElement('div');
// this.annotationCardElement.className = `annotation-card ${this.isHovered() ? 'hovered' : ''}`;
// this.annotationCardElement.setAttribute('data-position', pos.isRight ? 'right' : 'left');
// this.annotationCardElement.style.position = 'absolute';
// this.annotationCardElement.style.top = `${pos.top}px`;
// this.annotationCardElement.style.left = `${pos.left}px`;
// this.annotationCardElement.style.zIndex = '1000';
//
// // Add event listeners for hover
// this.annotationCardElement.addEventListener('mouseenter', () => this.onMouseEnter());
// this.annotationCardElement.addEventListener('mouseleave', () => this.onMouseLeave());
//
// // Create card content
// this.annotationCardElement.innerHTML = `
// <div class="annotation-content">
// <div class="annotation-text">This is test text</div>
// <div class="annotation-meta">
// <small>10/20/2025</small>
// </div>
// </div>
// `;
//
// // Create connection line
// const lineElement = document.createElement('div');
// lineElement.className = `connection-line ${this.isHovered() ? 'hovered' : ''}`;
// lineElement.style.position = 'absolute';
// lineElement.style.left = `${pos.connection.startX}px`;
// lineElement.style.top = `${pos.connection.startY}px`;
// lineElement.style.width = `${pos.connection.distance}px`;
// lineElement.style.height = '2px';
// lineElement.style.backgroundColor = '#9ca3af';
// lineElement.style.transformOrigin = '0 50%';
// lineElement.style.transform = `rotate(${pos.connection.angle}deg)`;
// lineElement.style.opacity = this.isHovered() ? '1' : '0.3';
// lineElement.style.transition = 'opacity 0.2s ease';
// lineElement.style.zIndex = '999';
//
// // Add dot at the end
// const dotElement = document.createElement('div');
// dotElement.style.position = 'absolute';
// dotElement.style.right = '-3px';
// dotElement.style.top = '50%';
// dotElement.style.width = '6px';
// dotElement.style.height = '6px';
// dotElement.style.backgroundColor = '#9ca3af';
// dotElement.style.borderRadius = '50%';
// dotElement.style.transform = 'translateY(-50%)';
//
// lineElement.appendChild(dotElement);
//
// // Append to body
// document.body.appendChild(this.annotationCardElement);
// document.body.appendChild(lineElement);
//
// // Store reference to line for updates
// (this.annotationCardElement as any).lineElement = lineElement;
const pos = this.cardPosition();
if (!pos) return;
// Only create if not already created
if (!this.annotationCardRef) {
this.annotationCardRef = this.annotationCardService.show({
position: pos,
annotationText: this.annotation()?.comment,
createdDate: new Date('10/20/2025'),
onMouseEnter: () => this.onMouseEnter(),
onMouseLeave: () => this.onMouseLeave()
});
}
}
private removeAnnotationCard() {
// if (this.annotationCardElement) {
// // Remove associated line element
// const lineElement = (this.annotationCardElement as any).lineElement;
// if (lineElement) {
// lineElement.remove();
// }
//
// this.annotationCardElement.remove();
// this.annotationCardElement = undefined;
// }
if (this.annotationCardRef) {
this.annotationCardService.hide();
this.annotationCardRef = undefined;
}
}
// private createOrUpdateAnnotationCard() {
// // const pos = this.cardPosition();
// // if (!pos) return;
// //
// // // Remove existing card if it exists
// // this.removeAnnotationCard();
// //
// // // Create new card element
// // this.annotationCardElement = document.createElement('div');
// // this.annotationCardElement.className = `annotation-card ${this.isHovered() ? 'hovered' : ''}`;
// // this.annotationCardElement.setAttribute('data-position', pos.isRight ? 'right' : 'left');
// // this.annotationCardElement.style.position = 'absolute';
// // this.annotationCardElement.style.top = `${pos.top}px`;
// // this.annotationCardElement.style.left = `${pos.left}px`;
// // this.annotationCardElement.style.zIndex = '1000';
// //
// // // Add event listeners for hover
// // this.annotationCardElement.addEventListener('mouseenter', () => this.onMouseEnter());
// // this.annotationCardElement.addEventListener('mouseleave', () => this.onMouseLeave());
// //
// // // Create card content
// // this.annotationCardElement.innerHTML = `
// // <div class="annotation-content">
// // <div class="annotation-text">This is test text</div>
// // <div class="annotation-meta">
// // <small>10/20/2025</small>
// // </div>
// // </div>
// // `;
// //
// // // Create connection line
// // const lineElement = document.createElement('div');
// // lineElement.className = `connection-line ${this.isHovered() ? 'hovered' : ''}`;
// // lineElement.style.position = 'absolute';
// // lineElement.style.left = `${pos.connection.startX}px`;
// // lineElement.style.top = `${pos.connection.startY}px`;
// // lineElement.style.width = `${pos.connection.distance}px`;
// // lineElement.style.height = '2px';
// // lineElement.style.backgroundColor = '#9ca3af';
// // lineElement.style.transformOrigin = '0 50%';
// // lineElement.style.transform = `rotate(${pos.connection.angle}deg)`;
// // lineElement.style.opacity = this.isHovered() ? '1' : '0.3';
// // lineElement.style.transition = 'opacity 0.2s ease';
// // lineElement.style.zIndex = '999';
// //
// // // Add dot at the end
// // const dotElement = document.createElement('div');
// // dotElement.style.position = 'absolute';
// // dotElement.style.right = '-3px';
// // dotElement.style.top = '50%';
// // dotElement.style.width = '6px';
// // dotElement.style.height = '6px';
// // dotElement.style.backgroundColor = '#9ca3af';
// // dotElement.style.borderRadius = '50%';
// // dotElement.style.transform = 'translateY(-50%)';
// //
// // lineElement.appendChild(dotElement);
// //
// // // Append to body
// // document.body.appendChild(this.annotationCardElement);
// // document.body.appendChild(lineElement);
// //
// // // Store reference to line for updates
// // (this.annotationCardElement as any).lineElement = lineElement;
//
// const pos = this.cardPosition();
// if (!pos) return;
//
// // Only create if not already created
// if (!this.annotationCardRef) {
// this.annotationCardRef = this.annotationCardService.show({
// position: pos,
// annotationText: this.annotation()?.comment,
// createdDate: new Date('10/20/2025'),
// onMouseEnter: () => this.onMouseEnter(),
// onMouseLeave: () => this.onMouseLeave()
// });
// }
// }
//
// private removeAnnotationCard() {
// // if (this.annotationCardElement) {
// // // Remove associated line element
// // const lineElement = (this.annotationCardElement as any).lineElement;
// // if (lineElement) {
// // lineElement.remove();
// // }
// //
// // this.annotationCardElement.remove();
// // this.annotationCardElement = undefined;
// // }
// if (this.annotationCardRef) {
// this.annotationCardService.hide();
// this.annotationCardRef = undefined;
// }
// }
}

View File

@ -7,25 +7,92 @@
</div>
<div class="offcanvas-body">
<div class="mx-auto mb-2 context-quote" id="render-target">
<div [innerHTML]="totalText()"></div>
</div>
<div class="row g-0">
<textarea rows="3"></textarea>
</div>
<div class="">
<div style="width: 120px">
Highlight colors
</div>
<div style="width: 120px">
WYSIWYG
<form [formGroup]="formGroup" novalidate>
<div class="mx-auto context-quote" id="render-target" #renderTarget>
<div [innerHTML]="totalText()"></div>
</div>
<div class="row g-0 mt-2">
<div class="col-6">
</div>
<div class="editor">
<textarea id="annotation-note" rows="3" style="width: 100%" formControlName="note"></textarea>
</div>
</div>
<div class="col-6">
@let content = markdownContent();
@if (content) {
<markdown [data]="markdownContent()"></markdown>
} @else {
<p style="font-style: italic">Nothing to preview</p>
}
</div>
</div>
<div class="row g-0 mt-2">
<div class="col-3">
<div class="highlight-bar">
@let color = createAnnotation()?.highlightColor ?? HighlightColor.Blue;
@for(highlightColor of allHighlightColors; track highlightColor) {
<button class="btn btn-icon color" [ngClass]="{'active': color === highlightColor}" (click)="changeHighlight(highlightColor)">
<div class="dot" [ngStyle]="{'background-color': highlightColor | highlightColor}"></div>
</button>
}
</div>
</div>
<div class="col-3 me-auto">
<div class="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-outline-secondary btn-sm"
(click)="insertMarkdown('**', '**', 'bold text')">
<i class="fas fa-bold" aria-hidden="true"></i>
<span class="visually-hidden">Bold</span>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
(click)="insertMarkdown('*', '*', 'italic text')">
<i class="fas fa-italic" aria-hidden="true"></i>
<span class="visually-hidden">Italic</span>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
(click)="insertMarkdown('## ', '', 'Heading')">
<i class="fas fa-heading" aria-hidden="true"></i>
<span class="visually-hidden">Heading</span>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
(click)="insertMarkdown('- ', '', 'List item')">
<i class="fas fa-list" aria-hidden="true"></i>
<span class="visually-hidden">List Item</span>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
(click)="insertMarkdown('[', '](url)', 'link text')">
<i class="fas fa-link" aria-hidden="true"></i>
<span class="visually-hidden">Link</span>
</button>
<button type="button" class="btn btn-outline-secondary btn-sm"
(click)="insertMarkdown('`', '`', 'code')">
<i class="fas fa-code" aria-hidden="true"></i>
<span class="visually-hidden">Code</span>
</button>
</div>
</div>
</div>
<div class="col-auto">
<div class="form-check form-switch">
<input type="checkbox" id="contains-spoiler" role="switch" formControlName="hasSpoiler" class="form-check-input" aria-labelledby="auto-close-label" switch>
<label class="form-check-label" for="contains-spoiler">Contains Spoiler</label>
</div>
</div>
<div class="col-auto">
<button class="btn btn-secondary me-1">Cancel</button>
<button class="btn btn-primary">Save</button>
</div>
</div>
</form>
</div>
</ng-container>

View File

@ -10,3 +10,33 @@
line-clamp: 2;
font-style: italic;
}
.toolbar {
padding: 10px;
}
.btn-sm {
padding: 0.25rem 0.5rem;
}
.highlight-bar .btn.btn-icon {
display: flex;
width: 50%;
justify-content: center;
align-items: center;
&.color {
display: unset;
width: auto;
.dot {
height: 25px;
width: 25px;
border-radius: 50%;
margin: 0 auto;
}
}
}
.active {
border: 1px solid var(--primary-color);
}

View File

@ -1,151 +1,319 @@
import {ChangeDetectionStrategy, Component, computed, inject, model, Signal} from '@angular/core';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
DestroyRef,
effect,
inject,
model,
signal,
Signal,
ViewChild,
ViewContainerRef
} from '@angular/core';
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
import {CreateAnnotationRequest} from "../../../_models/create-annotation-request";
import {TranslocoDirective} from "@jsverse/transloco";
import {SafeHtmlPipe} from "../../../../_pipes/safe-html.pipe";
import {MarkdownComponent, MarkdownService, MARKED_OPTIONS, provideMarkdown} from 'ngx-markdown';
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {tap} from "rxjs/operators";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {DOCUMENT, NgClass, NgStyle} from "@angular/common";
import {allHighlightColors, Annotation, HighlightColor} from "../../../_models/annotation";
import {HighlightColorPipe} from "../../../../_pipes/highlight-color.pipe";
import {EpubHighlightService} from "../../../../_services/epub-highlight.service";
import {DomSanitizer, SafeHtml} from "@angular/platform-browser";
@Component({
selector: 'app-create-annotation-drawer',
selector: 'app-create-annotation-drawer',
imports: [
TranslocoDirective
TranslocoDirective,
ReactiveFormsModule,
MarkdownComponent,
NgClass,
NgStyle,
HighlightColorPipe
],
templateUrl: './create-annotation-drawer.component.html',
styleUrl: './create-annotation-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CreateAnnotationDrawerComponent {
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly safeHtml = new SafeHtmlPipe();
createAnnotation = model<CreateAnnotationRequest | null>(null);
totalText!: Signal<string>;
constructor() {
this.totalText = computed(() => {
const annotation = this.createAnnotation();
if (annotation == null || annotation?.context === null) return '';
const contextText = annotation.context;
const selectedText = annotation.selectedText!;
if (!contextText.includes(selectedText)) {
return selectedText;
}
// Get estimated character capacity for 2 lines
const estimatedCapacity = this.estimateCharacterCapacity('render-target') * 2;
// If selected text alone is too long, just show it
if (selectedText.length >= estimatedCapacity) {
return `<span class="fw-bold">${this.safeHtml.transform(selectedText)}</span>`;
}
// Find the position of selected text in context
const selectedIndex = contextText.indexOf(selectedText);
const selectedEndIndex = selectedIndex + selectedText.length;
// Check if selected text follows punctuation (smart context detection)
const shouldIgnoreBeforeContext = this.isSelectedTextAfterPunctuation(contextText, selectedIndex);
// Extract after text first to see if we have content after
const afterText = contextText.substring(selectedEndIndex);
// If selected text follows punctuation AND we have after content, ignore before context
if (shouldIgnoreBeforeContext && afterText.trim().length > 0) {
const availableCapacity = estimatedCapacity - selectedText.length;
const trimmedAfterText = this.extractAfterContext(afterText, availableCapacity);
return `<span class="fw-bold">${this.safeHtml.transform(selectedText)}</span>${this.safeHtml.transform(trimmedAfterText)}`;
}
// Otherwise, use normal context distribution
const remainingCapacity = estimatedCapacity - selectedText.length;
const beforeCapacity = Math.floor(remainingCapacity * 0.4); // 40% before
const afterCapacity = remainingCapacity - beforeCapacity; // 60% after
// Extract context portions
let beforeText = contextText.substring(0, selectedIndex);
let trimmedAfterText = afterText;
// Trim context to fit capacity
if (beforeText.length > beforeCapacity) {
beforeText = '...' + beforeText.substring(beforeText.length - beforeCapacity + 3);
// Try to break at word boundary
const spaceIndex = beforeText.indexOf(' ', 3);
if (spaceIndex !== -1 && spaceIndex < beforeCapacity * 0.8) {
beforeText = '...' + beforeText.substring(spaceIndex + 1);
templateUrl: './create-annotation-drawer.component.html',
styleUrl: './create-annotation-drawer.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
provideMarkdown({
markedOptions: {
provide: MARKED_OPTIONS,
useValue: {
gfm: true,
breaks: false,
pedantic: false,
},
}
}
})
]
})
export class CreateAnnotationDrawerComponent {
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
private readonly fb = inject(FormBuilder);
private readonly markdownService = inject(MarkdownService);
private readonly destroyRef = inject(DestroyRef);
private readonly epubHighlightService = inject(EpubHighlightService);
private readonly document = inject(DOCUMENT);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly safeHtml = new SafeHtmlPipe();
private readonly sanitizer = inject(DomSanitizer);
if (trimmedAfterText.length > afterCapacity) {
trimmedAfterText = trimmedAfterText.substring(0, afterCapacity - 3) + '...';
// Try to break at word boundary
const lastSpaceIndex = trimmedAfterText.lastIndexOf(' ', afterCapacity - 3);
if (lastSpaceIndex !== -1 && lastSpaceIndex > afterCapacity * 0.8) {
trimmedAfterText = trimmedAfterText.substring(0, lastSpaceIndex) + '...';
}
}
createAnnotation = model<CreateAnnotationRequest | null>(null);
totalText!: Signal<SafeHtml>;
return `${this.safeHtml.transform(beforeText)}<span class="fw-bold">${this.safeHtml.transform(selectedText)}</span>${this.safeHtml.transform(afterText)}`;
formGroup!: FormGroup;
private _markdownContent = signal('');
// Computed for any transformations if needed
markdownContent = computed(() => {
return this._markdownContent();
});
}
@ViewChild('renderTarget', { read: ViewContainerRef }) renderTarget!: ViewContainerRef;
constructor() {
this.formGroup = new FormGroup({
'note': new FormControl(this.createAnnotation()?.comment || '', []),
'hasSpoiler': new FormControl(false, [])
});
this.formGroup.get('note')?.valueChanges.pipe(
tap((note: string) => this._markdownContent.set(note ?? '')),
takeUntilDestroyed(this.destroyRef)
).subscribe();
// Update formGroup whenever we modify markdownContent
effect(() => {
const existingNote = this.formGroup.get('note')!.value;
const currentMarkdown = this._markdownContent();
if (existingNote === currentMarkdown) return;
this.formGroup.get('note')?.patchValue(this._markdownContent());
});
this.totalText = computed(() => {
const annotation = this.createAnnotation();
console.log('Calculating totalText()', annotation);
if (annotation == null || annotation?.context === null) return '';
const contextText = annotation.context;
const selectedText = annotation.selectedText!;
if (!contextText.includes(selectedText)) {
return selectedText;
}
// Get estimated character capacity for 2 lines
const estimatedCapacity = this.estimateCharacterCapacity('render-target') * 2;
// If selected text alone is too long, just show it
if (selectedText.length >= estimatedCapacity) {
return `<span class="fw-bold">${this.safeHtml.transform(selectedText)}</span>`;
}
// Find the position of selected text in context
const selectedIndex = contextText.indexOf(selectedText);
const selectedEndIndex = selectedIndex + selectedText.length;
// Check if selected text follows punctuation (smart context detection)
const shouldIgnoreBeforeContext = this.isSelectedTextAfterPunctuation(contextText, selectedIndex);
// Extract after text first to see if we have content after
const afterText = contextText.substring(selectedEndIndex);
// If selected text follows punctuation AND we have after content, ignore before context
if (shouldIgnoreBeforeContext && afterText.trim().length > 0) {
const availableCapacity = estimatedCapacity - selectedText.length;
const trimmedAfterText = this.extractAfterContext(afterText, availableCapacity);
setTimeout(() => {
this.initHighlights();
}, 100);
return this.sanitizer.bypassSecurityTrustHtml(`<app-epub-highlight id="epub-highlight-0">${this.safeHtml.transform(selectedText)}</app-epub-highlight>${this.safeHtml.transform(trimmedAfterText)}`);
}
// Otherwise, use normal context distribution
const remainingCapacity = estimatedCapacity - selectedText.length;
const beforeCapacity = Math.floor(remainingCapacity * 0.4); // 40% before
const afterCapacity = remainingCapacity - beforeCapacity; // 60% after
// Extract context portions
let beforeText = contextText.substring(0, selectedIndex);
let trimmedAfterText = afterText;
// Trim context to fit capacity
if (beforeText.length > beforeCapacity) {
beforeText = '...' + beforeText.substring(beforeText.length - beforeCapacity + 3);
// Try to break at word boundary
const spaceIndex = beforeText.indexOf(' ', 3);
if (spaceIndex !== -1 && spaceIndex < beforeCapacity * 0.8) {
beforeText = '...' + beforeText.substring(spaceIndex + 1);
}
}
if (trimmedAfterText.length > afterCapacity) {
trimmedAfterText = trimmedAfterText.substring(0, afterCapacity - 3) + '...';
// Try to break at word boundary
const lastSpaceIndex = trimmedAfterText.lastIndexOf(' ', afterCapacity - 3);
if (lastSpaceIndex !== -1 && lastSpaceIndex > afterCapacity * 0.8) {
trimmedAfterText = trimmedAfterText.substring(0, lastSpaceIndex) + '...';
}
}
setTimeout(() => {
this.initHighlights();
}, 100);
return this.sanitizer.bypassSecurityTrustHtml(`${this.safeHtml.transform(beforeText)}<app-epub-highlight id="epub-highlight-0">${this.safeHtml.transform(selectedText)}</app-epub-highlight>${this.safeHtml.transform(trimmedAfterText)}`);
});
private isSelectedTextAfterPunctuation(contextText: string, selectedIndex: number): boolean {
if (selectedIndex === 0) return false;
// Look backwards from the selected text to find the last non-whitespace character
let checkIndex = selectedIndex - 1;
// Skip whitespace
while (checkIndex >= 0 && /\s/.test(contextText[checkIndex])) {
checkIndex--;
}
// If we found a character, check if it's punctuation
if (checkIndex >= 0) {
const lastChar = contextText[checkIndex];
// Define sentence-ending punctuation
const sentenceEnders = ['.', '!', '?', '"', "'", ')', ']', '—', ''];
return sentenceEnders.includes(lastChar);
private initHighlights() {
const annotation = this.createAnnotation();
if (annotation === null) return;
// Clear any existing components first
this.renderTarget.clear();
const properAnnotation = {id: 0,
xpath: annotation.xpath,
endingXPath: annotation.xpath,
selectedText: annotation.selectedText,
comment: annotation.comment,
highlightColor: annotation.highlightColor,
containsSpoiler: annotation.containsSpoiler,
pageNumber: annotation.pageNumber,
chapterId: annotation.chapterId,
} as Annotation;
const parentElem = this.document.querySelector('#render-target');
this.epubHighlightService.initializeHighlightElements([properAnnotation], this.renderTarget, parentElem, {showIcon: false, showHighlight: true});
}
return false;
}
private extractAfterContext(afterText: string, capacity: number): string {
if (afterText.length <= capacity) {
return afterText;
private isSelectedTextAfterPunctuation(contextText: string, selectedIndex: number): boolean {
if (selectedIndex === 0) return false;
// Look backwards from the selected text to find the last non-whitespace character
let checkIndex = selectedIndex - 1;
// Skip whitespace
while (checkIndex >= 0 && /\s/.test(contextText[checkIndex])) {
checkIndex--;
}
// If we found a character, check if it's punctuation
if (checkIndex >= 0) {
const lastChar = contextText[checkIndex];
// Define sentence-ending punctuation
const sentenceEnders = ['.', '!', '?', '"', "'", ')', ']', '—', ''];
return sentenceEnders.includes(lastChar);
}
return false;
}
let result = afterText.substring(0, capacity - 3) + '...';
private extractAfterContext(afterText: string, capacity: number): string {
if (afterText.length <= capacity) {
return afterText;
}
// Try to break at word boundary
const lastSpaceIndex = result.lastIndexOf(' ', capacity - 3);
if (lastSpaceIndex !== -1 && lastSpaceIndex > capacity * 0.8) {
result = result.substring(0, lastSpaceIndex) + '...';
let result = afterText.substring(0, capacity - 3) + '...';
// Try to break at word boundary
const lastSpaceIndex = result.lastIndexOf(' ', capacity - 3);
if (lastSpaceIndex !== -1 && lastSpaceIndex > capacity * 0.8) {
result = result.substring(0, lastSpaceIndex) + '...';
}
return result;
}
return result;
private estimateCharacterCapacity(elementId: string): number {
const element = document.getElementById(elementId);
if (!element) return 100; // fallback
const computedStyle = window.getComputedStyle(element);
const fontSize = parseFloat(computedStyle.fontSize);
const avgCharWidth = fontSize * 0.6;
const paddingLeft = parseFloat(computedStyle.paddingLeft);
const paddingRight = parseFloat(computedStyle.paddingRight);
const availableWidth = element.clientWidth - paddingLeft - paddingRight;
return Math.floor(availableWidth / avgCharWidth);
}
insertMarkdown(before: string, after: string, placeholder: string): void {
const textarea = document.querySelector('textarea') as HTMLTextAreaElement;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
let textToInsert: string;
let beforeSpaces = '';
let afterSpaces = '';
if (selectedText) {
// Extract leading and trailing whitespace
const leadingMatch = selectedText.match(/^\s*/);
const trailingMatch = selectedText.match(/\s*$/);
beforeSpaces = leadingMatch ? leadingMatch[0] : '';
afterSpaces = trailingMatch ? trailingMatch[0] : '';
// Get the trimmed text
textToInsert = selectedText.trim();
} else {
textToInsert = placeholder;
}
// Construct the replacement: spaces + before + trimmed text + after + spaces
const newText = beforeSpaces + before + textToInsert + after + afterSpaces;
const newValue =
textarea.value.substring(0, start) +
newText +
textarea.value.substring(end);
this._markdownContent.set(newValue);
// Set cursor position after insertion
setTimeout(() => {
// Position cursor after the closing marker but before trailing spaces
const newPosition = start + beforeSpaces.length + before.length + textToInsert.length + after.length;
textarea.setSelectionRange(newPosition, newPosition);
textarea.focus();
});
}
changeHighlight(highlight: HighlightColor) {
let annotation = this.createAnnotation();
if (annotation) {
this.createAnnotation.set({...annotation, highlightColor: highlight});
}
}
close() {
this.activeOffcanvas.close();
}
protected readonly HighlightColor = HighlightColor;
protected readonly allHighlightColors = allHighlightColors;
}
private estimateCharacterCapacity(elementId: string): number {
const element = document.getElementById(elementId);
if (!element) return 100; // fallback
const computedStyle = window.getComputedStyle(element);
const fontSize = parseFloat(computedStyle.fontSize);
const avgCharWidth = fontSize * 0.6;
const paddingLeft = parseFloat(computedStyle.paddingLeft);
const paddingRight = parseFloat(computedStyle.paddingRight);
const availableWidth = element.clientWidth - paddingLeft - paddingRight;
return Math.floor(availableWidth / avgCharWidth);
}
close() {
this.activeOffcanvas.close();
}
}

View File

@ -21,7 +21,7 @@ import {translate, TranslocoDirective} from "@jsverse/transloco";
import {KEY_CODES} from "../../../shared/_services/utility.service";
import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service";
import {CreateAnnotationRequest} from "../../_models/create-annotation-request";
import {HightlightColor} from "../../_models/annotation";
import {HighlightColor} from "../../_models/annotation";
enum BookLineOverlayMode {
None = 0,
@ -149,7 +149,7 @@ export class BookLineOverlayComponent implements OnInit {
xpath: this.xPath,
endingXPath: this.xPath, // TODO: Figure this out
highlightCount: this.selectedText.length,
hightlightColor: HightlightColor.Blue,
highlightColor: HighlightColor.Blue,
context: this.allTextFromSelection,
} as CreateAnnotationRequest;

View File

@ -53,7 +53,6 @@ import {BookLineOverlayComponent} from "../book-line-overlay/book-line-overlay.c
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {ReadingProfile} from "../../../_models/preferences/reading-profiles";
import {ConfirmService} from "../../../shared/confirm.service";
import {EpubHighlightComponent} from "../_annotations/epub-highlight/epub-highlight.component";
import {Annotation} from "../../_models/annotation";
import {EpubReaderMenuService} from "../../../_services/epub-reader-menu.service";
import {LoadPageEvent} from "../_drawers/view-toc-drawer/view-toc-drawer.component";
@ -63,6 +62,7 @@ import {WritingStyleClassPipe} from "../../_pipes/writing-style-class.pipe";
import {ChapterService} from "../../../_services/chapter.service";
import {ReadTimeLeftPipe} from "../../../_pipes/read-time-left.pipe";
import {PageBookmark} from "../../../_models/readers/page-bookmark";
import {EpubHighlightService} from "../../../_services/epub-highlight.service";
interface HistoryPoint {
@ -114,6 +114,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly router = inject(Router);
private readonly seriesService = inject(SeriesService);
private readonly readerService = inject(ReaderService);
private readonly epubHighlightService = inject(EpubHighlightService);
private readonly chapterService = inject(ChapterService);
private readonly renderer = inject(Renderer2);
private readonly navService = inject(NavService);
@ -1180,32 +1181,34 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
private setupAnnotationElements() {
const annoationMap: {[key: number]: Annotation} = this.annotations.reduce((map, obj) => {
// @ts-ignore
map[obj.id] = obj;
return map;
}, {});
this.epubHighlightService.initializeHighlightElements(this.annotations, this.readingContainer);
// Make the highlight components "real"
const highlightElems = this.document.querySelectorAll('app-epub-highlight');
for (let i = 0; i < highlightElems.length; i++) {
const highlight = highlightElems[i];
const idAttr = highlight.getAttribute('id');
// Don't allow highlight injection unless the id is present
if (!idAttr) continue;
const annotationId = parseInt(idAttr.replace('epub-highlight-', ''), 10);
const componentRef = this.readingContainer.createComponent<EpubHighlightComponent>(EpubHighlightComponent,
{projectableNodes: [[document.createTextNode(highlight.innerHTML)]]});
if (highlight.parentNode != null) {
highlight.parentNode.replaceChild(componentRef.location.nativeElement, highlight);
}
componentRef.instance.annotation.set(annoationMap[annotationId]);
}
// const annoationMap: {[key: number]: Annotation} = this.annotations.reduce((map, obj) => {
// // @ts-ignore
// map[obj.id] = obj;
// return map;
// }, {});
//
// // Make the highlight components "real"
// const highlightElems = this.document.querySelectorAll('app-epub-highlight');
//
// for (let i = 0; i < highlightElems.length; i++) {
// const highlight = highlightElems[i];
// const idAttr = highlight.getAttribute('id');
//
// // Don't allow highlight injection unless the id is present
// if (!idAttr) continue;
//
//
// const annotationId = parseInt(idAttr.replace('epub-highlight-', ''), 10);
// const componentRef = this.readingContainer.createComponent<EpubHighlightComponent>(EpubHighlightComponent,
// {projectableNodes: [[document.createTextNode(highlight.innerHTML)]]});
// if (highlight.parentNode != null) {
// highlight.parentNode.replaceChild(componentRef.location.nativeElement, highlight);
// }
//
// componentRef.instance.annotation.set(annoationMap[annotationId]);
// }
}
private addEmptyPageIfRequired(): void {

View File

@ -97,7 +97,7 @@
<ng-container [ngTemplateOutlet]="tapPaginationTooltip"></ng-container>
</span>
<div class="form-check form-switch">
<input type="checkbox" id="tap-pagination" formControlName="bookReaderTapToPaginate" class="form-check-input" aria-labelledby="tapPagination-help">
<input type="checkbox" id="tap-pagination" formControlName="bookReaderTapToPaginate" class="form-check-input" aria-labelledby="tapPagination-help" switch>
<label>{{settingsForm.get('bookReaderTapToPaginate')?.value ? t('on') : t('off')}} </label>
</div>
</div>
@ -108,7 +108,7 @@
<ng-container [ngTemplateOutlet]="immersiveModeTooltip"></ng-container>
</span>
<div class="form-check form-switch">
<input type="checkbox" id="immersive-mode" formControlName="bookReaderImmersiveMode" class="form-check-input" aria-labelledby="immersiveMode-help">
<input type="checkbox" id="immersive-mode" formControlName="bookReaderImmersiveMode" class="form-check-input" aria-labelledby="immersiveMode-help" switch>
<label>{{settingsForm.get('bookReaderImmersiveMode')?.value ? t('on') : t('off')}} </label>
</div>
</div>

View File

@ -1,15 +1,17 @@
export enum HightlightColor {
export enum HighlightColor {
Blue = 1,
Green = 2,
}
export const allHighlightColors = [HighlightColor.Blue, HighlightColor.Green];
export interface Annotation {
id: number;
xpath: string;
endingXPath: string | null;
selectedText: string | null;
comment: string;
hightlightColor: HightlightColor;
highlightColor: HighlightColor;
containsSpoiler: boolean;
pageNumber: number;

View File

@ -1,4 +1,4 @@
import {HightlightColor} from "./annotation";
import {HighlightColor} from "./annotation";
export interface CreateAnnotationRequest {
libraryId: number;
@ -8,7 +8,7 @@ export interface CreateAnnotationRequest {
endingXPath: string | null;
selectedText: string | null;
comment: string | null;
hightlightColor: HightlightColor;
highlightColor: HighlightColor;
highlightCount: number;
containsSpoiler: boolean;
pageNumber: number;

View File

@ -29,7 +29,7 @@
</div>
<div class="col-md-3 col-sm-12 ms-2">
<div class="form-check form-switch">
<input type="checkbox" id="tag-promoted" role="switch" formControlName="promoted" class="form-check-input"
<input type="checkbox" id="tag-promoted" role="switch" formControlName="promoted" class="form-check-input" switch
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
<label class="form-check-label me-1" for="tag-promoted">{{t('promote-label')}}</label>
<i class="fa fa-info-circle" aria-hidden="true" placement="left" [ngbTooltip]="promotedTooltip" role="button" tabindex="0"></i>

View File

@ -264,7 +264,7 @@
<div class="mb-3">
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" formControlName="autoCloseMenu" class="form-check-input" >
<input type="checkbox" id="auto-close" formControlName="autoCloseMenu" class="form-check-input" switch>
<label class="form-check-label" for="auto-close">{{t('auto-close-menu-label')}}</label>
</div>
</div>
@ -273,7 +273,7 @@
<div class="mb-3">
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="swipe-to-paginate" formControlName="swipeToPaginate" class="form-check-input" >
<input type="checkbox" id="swipe-to-paginate" formControlName="swipeToPaginate" class="form-check-input" switch>
<label class="form-check-label" for="swipe-to-paginate">{{t('swipe-enabled-label')}}</label>
</div>
</div>
@ -283,7 +283,7 @@
<div class="mb-3">
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="emulate-book" formControlName="emulateBook" class="form-check-input">
<input type="checkbox" id="emulate-book" formControlName="emulateBook" class="form-check-input" switch>
<label class="form-check-label" for="emulate-book">{{t('emulate-comic-book-label')}}</label>
</div>
</div>

View File

@ -164,7 +164,7 @@
<ng-container [ngTemplateOutlet]="extraTemplate"></ng-container>
<form [formGroup]="searchSettingsForm">
<div class="form-check form-switch">
<input type="checkbox" id="search-include-extras" role="switch" formControlName="includeExtras" class="form-check-input"
<input type="checkbox" id="search-include-extras" role="switch" formControlName="includeExtras" class="form-check-input" switch
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
<label class="form-check-label" for="search-include-extras">{{t('include-extras')}}</label>
</div>

View File

@ -157,7 +157,7 @@
<div class="col-auto">
<div class="form-check form-switch">
<input type="checkbox" id="settings-comicvine-mode" role="switch" formControlName="comicVineMatching" class="form-check-input"
aria-labelledby="auto-close-label">
aria-labelledby="auto-close-label" switch>
<label class="form-check-label" for="settings-comicvine-mode">{{t('comicvine-parsing-label')}}</label>
</div>
</div>

View File

@ -27,7 +27,7 @@
<ng-container *ngIf="(accountService.currentUser$ | async) as user">
<div class="col-md-3 col-sm-12 ms-2" *ngIf="accountService.hasAdminRole(user)">
<div class="form-check form-switch">
<input type="checkbox" id="tag-promoted" role="switch" formControlName="promoted" class="form-check-input"
<input type="checkbox" id="tag-promoted" role="switch" formControlName="promoted" class="form-check-input" switch
aria-labelledby="auto-close-label" aria-describedby="tag-promoted-help">
<label class="form-check-label me-1" for="tag-promoted">{{t('promote-label')}}</label>
<i class="fa fa-info-circle" aria-hidden="true" placement="left" [ngbTooltip]="promotedTooltip" role="button" tabindex="0"></i>

View File

@ -118,7 +118,7 @@
<div class="hstack gap-2">
@for (group of fileTypeGroups; track group) {
<div class="form-check form-switch">
<input class="form-check-input" [formControlName]="group" type="checkbox" [id]="group">
<input class="form-check-input" [formControlName]="group" type="checkbox" [id]="group" switch>
<label class="form-check-label" [for]="group">{{ group | fileTypeGroup }}</label>
</div>
}

View File

@ -24,7 +24,7 @@
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" id="auto-close" role="switch" formControlName="ageRestrictionIncludeUnknowns" class="form-check-input" aria-describedby="include-unknowns-help" [value]="true" aria-labelledby="auto-close-label">
<input type="checkbox" id="auto-close" role="switch" formControlName="ageRestrictionIncludeUnknowns" class="form-check-input" aria-describedby="include-unknowns-help" [value]="true" aria-labelledby="auto-close-label" switch>
<label class="form-check-label" for="auto-close">{{t('include-unknowns-label')}}</label><i class="fa fa-info-circle ms-1" aria-hidden="true" placement="top" [ngbTooltip]="includeUnknownsTooltip" role="button" tabindex="0"></i>
</div>

View File

@ -149,7 +149,7 @@ bootstrapApplication(AppComponent, {
useFactory: getBaseHref,
deps: [PlatformLocation]
},
provideHttpClient(withInterceptorsFromDi(), withFetch())
provideHttpClient(withInterceptorsFromDi(), withFetch()),
]
} as ApplicationConfig)
.catch(err => console.error(err));