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);
|
||||
|
||||
openCreateAnnotationDrawer(annotation: CreateAnnotationRequest) {
|
||||
openCreateAnnotationDrawer(annotation: CreateAnnotationRequest, callbackFn: () => void) {
|
||||
const ref = this.offcanvasService.open(CreateAnnotationDrawerComponent, {position: 'bottom', panelClass: ''});
|
||||
ref.closed.subscribe(() => this.setDrawerClosed());
|
||||
ref.dismissed.subscribe(() => this.setDrawerClosed());
|
||||
ref.closed.subscribe(() => {this.setDrawerClosed(); callbackFn();});
|
||||
ref.dismissed.subscribe(() => {this.setDrawerClosed(); callbackFn();});
|
||||
ref.componentInstance.createAnnotation.set(annotation);
|
||||
|
||||
this.isDrawerOpen.set(true);
|
||||
|
@ -8,7 +8,24 @@
|
||||
|
||||
<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>
|
||||
</ng-container>
|
||||
|
@ -4,3 +4,9 @@
|
||||
display: flex;
|
||||
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 {CreateAnnotationRequest} from "../../../_models/create-annotation-request";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {SafeHtmlPipe} from "../../../../_pipes/safe-html.pipe";
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-annotation-drawer',
|
||||
@ -14,9 +15,134 @@ import {TranslocoDirective} from "@jsverse/transloco";
|
||||
})
|
||||
export class CreateAnnotationDrawerComponent {
|
||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
|
@ -47,6 +47,7 @@ export class BookLineOverlayComponent implements OnInit {
|
||||
@Output() isOpen: EventEmitter<boolean> = new EventEmitter(false);
|
||||
|
||||
xPath: string = '';
|
||||
allTextFromSelection: string = '';
|
||||
selectedText: string = '';
|
||||
mode: BookLineOverlayMode = BookLineOverlayMode.None;
|
||||
bookmarkForm: FormGroup = new FormGroup({
|
||||
@ -111,12 +112,15 @@ export class BookLineOverlayComponent implements OnInit {
|
||||
|
||||
this.selectedText = selection ? selection.toString().trim() : '';
|
||||
|
||||
|
||||
if (this.selectedText.length > 0 && this.mode === BookLineOverlayMode.None) {
|
||||
this.xPath = this.readerService.getXPathTo(event.target);
|
||||
if (this.xPath !== '') {
|
||||
this.xPath = '//' + this.xPath;
|
||||
}
|
||||
|
||||
this.allTextFromSelection = (event.target as Element).textContent || '';
|
||||
|
||||
this.isOpen.emit(true);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@ -134,8 +138,6 @@ export class BookLineOverlayComponent implements OnInit {
|
||||
}
|
||||
|
||||
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 = {
|
||||
chapterId: this.chapterId,
|
||||
libraryId: this.libraryId,
|
||||
@ -147,9 +149,13 @@ export class BookLineOverlayComponent implements OnInit {
|
||||
xpath: this.xPath,
|
||||
endingXPath: this.xPath, // TODO: Figure this out
|
||||
highlightCount: this.selectedText.length,
|
||||
hightlightColor: HightlightColor.Blue
|
||||
hightlightColor: HightlightColor.Blue,
|
||||
context: this.allTextFromSelection,
|
||||
} 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.xPath = '';
|
||||
this.selectedText = '';
|
||||
this.allTextFromSelection = '';
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
|
@ -764,9 +764,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
event.preventDefault();
|
||||
} else if (event.key === KEY_CODES.G) {
|
||||
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) {
|
||||
@ -1534,7 +1535,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
handleReaderSettingsUpdate(res: ReaderSettingUpdate) {
|
||||
console.log('Handling ', res.setting, ' setting update to ', res.object);
|
||||
switch (res.setting) {
|
||||
case "pageStyle":
|
||||
this.applyPageStyles(res.object as PageStyle);
|
||||
@ -1585,7 +1585,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
let element: Element | null = null;
|
||||
if (partSelector.startsWith('//') || partSelector.startsWith('id(')) {
|
||||
// Part selector is a XPATH
|
||||
element = this.getElementFromXPath(partSelector);
|
||||
element = this.readerService.getElementFromXPath(partSelector);
|
||||
} else {
|
||||
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
|
||||
*/
|
||||
|
@ -12,4 +12,8 @@ export interface CreateAnnotationRequest {
|
||||
highlightCount: number;
|
||||
containsSpoiler: boolean;
|
||||
pageNumber: number;
|
||||
/**
|
||||
* Ui Only - the full paragraph of selected context
|
||||
*/
|
||||
context: string | null;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user