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:
Joseph Milazzo 2025-07-12 11:17:12 -05:00
parent 9348e77716
commit 3c28291cdb
7 changed files with 174 additions and 22 deletions

View File

@ -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);

View File

@ -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>

View File

@ -4,3 +4,9 @@
display: flex;
flex-direction: column;
}
.context-quote {
text-align: center;
line-clamp: 2;
font-style: italic;
}

View File

@ -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() {

View File

@ -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();

View File

@ -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
*/

View File

@ -12,4 +12,8 @@ export interface CreateAnnotationRequest {
highlightCount: number;
containsSpoiler: boolean;
pageNumber: number;
/**
* Ui Only - the full paragraph of selected context
*/
context: string | null;
}