mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-08-30 23:00:06 -04:00
First pass, context rendering of the annotation is done. This context rendering only occurs for first annotations, while edit flows show just the selected text.
This commit is contained in:
parent
9348e77716
commit
3c28291cdb
@ -37,10 +37,10 @@ export class EpubReaderMenuService {
|
|||||||
*/
|
*/
|
||||||
public readonly isDrawerOpen = signal<boolean>(false);
|
public readonly isDrawerOpen = signal<boolean>(false);
|
||||||
|
|
||||||
openCreateAnnotationDrawer(annotation: CreateAnnotationRequest) {
|
openCreateAnnotationDrawer(annotation: CreateAnnotationRequest, callbackFn: () => void) {
|
||||||
const ref = this.offcanvasService.open(CreateAnnotationDrawerComponent, {position: 'bottom', panelClass: ''});
|
const ref = this.offcanvasService.open(CreateAnnotationDrawerComponent, {position: 'bottom', panelClass: ''});
|
||||||
ref.closed.subscribe(() => this.setDrawerClosed());
|
ref.closed.subscribe(() => {this.setDrawerClosed(); callbackFn();});
|
||||||
ref.dismissed.subscribe(() => this.setDrawerClosed());
|
ref.dismissed.subscribe(() => {this.setDrawerClosed(); callbackFn();});
|
||||||
ref.componentInstance.createAnnotation.set(annotation);
|
ref.componentInstance.createAnnotation.set(annotation);
|
||||||
|
|
||||||
this.isDrawerOpen.set(true);
|
this.isDrawerOpen.set(true);
|
||||||
|
@ -8,7 +8,24 @@
|
|||||||
|
|
||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body">
|
||||||
|
|
||||||
Hello
|
<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
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -4,3 +4,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.context-quote {
|
||||||
|
text-align: center;
|
||||||
|
line-clamp: 2;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, model} from '@angular/core';
|
import {ChangeDetectionStrategy, Component, computed, inject, model, Signal} from '@angular/core';
|
||||||
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
||||||
import {CreateAnnotationRequest} from "../../../_models/create-annotation-request";
|
import {CreateAnnotationRequest} from "../../../_models/create-annotation-request";
|
||||||
import {TranslocoDirective} from "@jsverse/transloco";
|
import {TranslocoDirective} from "@jsverse/transloco";
|
||||||
|
import {SafeHtmlPipe} from "../../../../_pipes/safe-html.pipe";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-create-annotation-drawer',
|
selector: 'app-create-annotation-drawer',
|
||||||
@ -14,9 +15,134 @@ import {TranslocoDirective} from "@jsverse/transloco";
|
|||||||
})
|
})
|
||||||
export class CreateAnnotationDrawerComponent {
|
export class CreateAnnotationDrawerComponent {
|
||||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly safeHtml = new SafeHtmlPipe();
|
||||||
|
|
||||||
createAnnotation = model<CreateAnnotationRequest | null>(null);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${this.safeHtml.transform(beforeText)}<span class="fw-bold">${this.safeHtml.transform(selectedText)}</span>${this.safeHtml.transform(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractAfterContext(afterText: string, capacity: number): string {
|
||||||
|
if (afterText.length <= capacity) {
|
||||||
|
return afterText;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
close() {
|
||||||
|
@ -47,6 +47,7 @@ export class BookLineOverlayComponent implements OnInit {
|
|||||||
@Output() isOpen: EventEmitter<boolean> = new EventEmitter(false);
|
@Output() isOpen: EventEmitter<boolean> = new EventEmitter(false);
|
||||||
|
|
||||||
xPath: string = '';
|
xPath: string = '';
|
||||||
|
allTextFromSelection: string = '';
|
||||||
selectedText: string = '';
|
selectedText: string = '';
|
||||||
mode: BookLineOverlayMode = BookLineOverlayMode.None;
|
mode: BookLineOverlayMode = BookLineOverlayMode.None;
|
||||||
bookmarkForm: FormGroup = new FormGroup({
|
bookmarkForm: FormGroup = new FormGroup({
|
||||||
@ -111,12 +112,15 @@ export class BookLineOverlayComponent implements OnInit {
|
|||||||
|
|
||||||
this.selectedText = selection ? selection.toString().trim() : '';
|
this.selectedText = selection ? selection.toString().trim() : '';
|
||||||
|
|
||||||
|
|
||||||
if (this.selectedText.length > 0 && this.mode === BookLineOverlayMode.None) {
|
if (this.selectedText.length > 0 && this.mode === BookLineOverlayMode.None) {
|
||||||
this.xPath = this.readerService.getXPathTo(event.target);
|
this.xPath = this.readerService.getXPathTo(event.target);
|
||||||
if (this.xPath !== '') {
|
if (this.xPath !== '') {
|
||||||
this.xPath = '//' + this.xPath;
|
this.xPath = '//' + this.xPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.allTextFromSelection = (event.target as Element).textContent || '';
|
||||||
|
|
||||||
this.isOpen.emit(true);
|
this.isOpen.emit(true);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@ -134,8 +138,6 @@ export class BookLineOverlayComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.mode === BookLineOverlayMode.Annotate) {
|
if (this.mode === BookLineOverlayMode.Annotate) {
|
||||||
// TODO: Open annotation drawer
|
|
||||||
this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNumber, this.xPath, this.selectedText
|
|
||||||
const createAnnotation = {
|
const createAnnotation = {
|
||||||
chapterId: this.chapterId,
|
chapterId: this.chapterId,
|
||||||
libraryId: this.libraryId,
|
libraryId: this.libraryId,
|
||||||
@ -147,9 +149,13 @@ export class BookLineOverlayComponent implements OnInit {
|
|||||||
xpath: this.xPath,
|
xpath: this.xPath,
|
||||||
endingXPath: this.xPath, // TODO: Figure this out
|
endingXPath: this.xPath, // TODO: Figure this out
|
||||||
highlightCount: this.selectedText.length,
|
highlightCount: this.selectedText.length,
|
||||||
hightlightColor: HightlightColor.Blue
|
hightlightColor: HightlightColor.Blue,
|
||||||
|
context: this.allTextFromSelection,
|
||||||
} as CreateAnnotationRequest;
|
} as CreateAnnotationRequest;
|
||||||
this.epubMenuService.openCreateAnnotationDrawer(createAnnotation);
|
|
||||||
|
this.epubMenuService.openCreateAnnotationDrawer(createAnnotation, () => {
|
||||||
|
this.reset();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,6 +181,7 @@ export class BookLineOverlayComponent implements OnInit {
|
|||||||
this.mode = BookLineOverlayMode.None;
|
this.mode = BookLineOverlayMode.None;
|
||||||
this.xPath = '';
|
this.xPath = '';
|
||||||
this.selectedText = '';
|
this.selectedText = '';
|
||||||
|
this.allTextFromSelection = '';
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (selection) {
|
if (selection) {
|
||||||
selection.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
|
@ -764,9 +764,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEY_CODES.G) {
|
} else if (event.key === KEY_CODES.G) {
|
||||||
await this.goToPage();
|
await this.goToPage();
|
||||||
} else if (event.key === KEY_CODES.F) {
|
|
||||||
this.applyFullscreen()
|
|
||||||
}
|
}
|
||||||
|
// else if (event.key === KEY_CODES.F) {
|
||||||
|
// this.applyFullscreen()
|
||||||
|
// } // TODO: Find a better key bind
|
||||||
}
|
}
|
||||||
|
|
||||||
onWheel(event: WheelEvent) {
|
onWheel(event: WheelEvent) {
|
||||||
@ -1534,7 +1535,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleReaderSettingsUpdate(res: ReaderSettingUpdate) {
|
handleReaderSettingsUpdate(res: ReaderSettingUpdate) {
|
||||||
console.log('Handling ', res.setting, ' setting update to ', res.object);
|
|
||||||
switch (res.setting) {
|
switch (res.setting) {
|
||||||
case "pageStyle":
|
case "pageStyle":
|
||||||
this.applyPageStyles(res.object as PageStyle);
|
this.applyPageStyles(res.object as PageStyle);
|
||||||
@ -1585,7 +1585,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
let element: Element | null = null;
|
let element: Element | null = null;
|
||||||
if (partSelector.startsWith('//') || partSelector.startsWith('id(')) {
|
if (partSelector.startsWith('//') || partSelector.startsWith('id(')) {
|
||||||
// Part selector is a XPATH
|
// Part selector is a XPATH
|
||||||
element = this.getElementFromXPath(partSelector);
|
element = this.readerService.getElementFromXPath(partSelector);
|
||||||
} else {
|
} else {
|
||||||
element = this.document.querySelector('*[id="' + partSelector + '"]');
|
element = this.document.querySelector('*[id="' + partSelector + '"]');
|
||||||
}
|
}
|
||||||
@ -1606,14 +1606,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getElementFromXPath(path: string) {
|
|
||||||
const node = this.document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
|
||||||
if (node?.nodeType === Node.ELEMENT_NODE) {
|
|
||||||
return node as Element;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state
|
* Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state
|
||||||
*/
|
*/
|
||||||
|
@ -12,4 +12,8 @@ export interface CreateAnnotationRequest {
|
|||||||
highlightCount: number;
|
highlightCount: number;
|
||||||
containsSpoiler: boolean;
|
containsSpoiler: boolean;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
|
/**
|
||||||
|
* Ui Only - the full paragraph of selected context
|
||||||
|
*/
|
||||||
|
context: string | null;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user