mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-08-30 23:00:06 -04:00
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:
parent
a3d999b7b9
commit
5950a9b711
1025
UI/Web/package-lock.json
generated
1025
UI/Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
18
UI/Web/src/app/_pipes/highlight-color.pipe.ts
Normal file
18
UI/Web/src/app/_pipes/highlight-color.pipe.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
53
UI/Web/src/app/_services/epub-highlight.service.ts
Normal file
53
UI/Web/src/app/_services/epub-highlight.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -149,7 +149,7 @@ bootstrapApplication(AppComponent, {
|
||||
useFactory: getBaseHref,
|
||||
deps: [PlatformLocation]
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi(), withFetch())
|
||||
provideHttpClient(withInterceptorsFromDi(), withFetch()),
|
||||
]
|
||||
} as ApplicationConfig)
|
||||
.catch(err => console.error(err));
|
||||
|
Loading…
x
Reference in New Issue
Block a user