From 3c28291cdb9ee4ca0241abb940e88baea548b619 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sat, 12 Jul 2025 11:17:12 -0500 Subject: [PATCH] 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. --- .../app/_services/epub-reader-menu.service.ts | 6 +- .../create-annotation-drawer.component.html | 19 ++- .../create-annotation-drawer.component.scss | 6 + .../create-annotation-drawer.component.ts | 130 +++++++++++++++++- .../book-line-overlay.component.ts | 15 +- .../book-reader/book-reader.component.ts | 16 +-- .../_models/create-annotation-request.ts | 4 + 7 files changed, 174 insertions(+), 22 deletions(-) diff --git a/UI/Web/src/app/_services/epub-reader-menu.service.ts b/UI/Web/src/app/_services/epub-reader-menu.service.ts index ede7660a1..7af8f7b3c 100644 --- a/UI/Web/src/app/_services/epub-reader-menu.service.ts +++ b/UI/Web/src/app/_services/epub-reader-menu.service.ts @@ -37,10 +37,10 @@ export class EpubReaderMenuService { */ public readonly isDrawerOpen = signal(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); diff --git a/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.html b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.html index 102cd9245..5954eb7d6 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.html +++ b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.html @@ -8,7 +8,24 @@
- Hello +
+
+
+ +
+ +
+ +
+
+ Highlight colors +
+
+ WYSIWYG +
+ + +
diff --git a/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.scss b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.scss index 5cec79d06..a0ed00aa9 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.scss +++ b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.scss @@ -4,3 +4,9 @@ display: flex; flex-direction: column; } + +.context-quote { + text-align: center; + line-clamp: 2; + font-style: italic; +} diff --git a/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.ts b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.ts index 6c36f78c5..a6e3b55e9 100644 --- a/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.ts +++ b/UI/Web/src/app/book-reader/_components/_drawers/create-annotation-drawer/create-annotation-drawer.component.ts @@ -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(null); + totalText!: Signal; + + 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 `${this.safeHtml.transform(selectedText)}`; + } + + // 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 `${this.safeHtml.transform(selectedText)}${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)}${this.safeHtml.transform(selectedText)}${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() { diff --git a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts index 081ea8fb0..a5318731b 100644 --- a/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-line-overlay/book-line-overlay.component.ts @@ -47,6 +47,7 @@ export class BookLineOverlayComponent implements OnInit { @Output() isOpen: EventEmitter = 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(); diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 180b8d3a5..a0ff84ab7 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -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 */ diff --git a/UI/Web/src/app/book-reader/_models/create-annotation-request.ts b/UI/Web/src/app/book-reader/_models/create-annotation-request.ts index ead6954cd..15e8eece4 100644 --- a/UI/Web/src/app/book-reader/_models/create-annotation-request.ts +++ b/UI/Web/src/app/book-reader/_models/create-annotation-request.ts @@ -12,4 +12,8 @@ export interface CreateAnnotationRequest { highlightCount: number; containsSpoiler: boolean; pageNumber: number; + /** + * Ui Only - the full paragraph of selected context + */ + context: string | null; }