Ok thats a better start, two tabs

This commit is contained in:
shamoon committed 2026-10-19 22:23:50 -07:00
1 parent f5f33e0744
commit 1de1261728
4 files changed
+199 -194

No files matched your search

@@ -1,62 +1,20 @@
<pngx-page-header [title]="pageTitle" [id]="template.id">
<div class="col-12 col-md-auto">
<label class="visually-hidden" for="preview-document" i18n>Sample document</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i-bs name="file-text"></i-bs></span>
<input
id="preview-document"
type="text"
class="form-control"
[(ngModel)]="previewDocModel"
[ngbTypeahead]="searchDocuments"
#documentSearch="ngbTypeahead"
[inputFormatter]="documentFormatter"
[resultFormatter]="documentFormatter"
[resultTemplate]="documentResult"
(input)="searchStatus.set('idle')"
(selectItem)="onPreviewDocSelected($event)"
[editable]="false"
container="body"
popupClass="ocr-template-document-results col-11 col-md-6 overflow-y-auto"
placeholder="Search documents..."
i18n-placeholder
/>
@if (previewDocId) {
<button type="button" class="btn btn-outline-secondary" (click)="clearPreviewDoc(); documentSearch.dismissPopup()" title="Clear sample document" i18n-title aria-label="Clear sample document" i18n-aria-label>
<i-bs name="x"></i-bs>
</button>
}
</div>
<ng-template #documentResult let-document="result">
<span class="d-block text-wrap text-break">{{ documentFormatter(document) }}</span>
</ng-template>
<div class="preview-search-status small" role="status" [class.text-danger]="searchStatus() === 'error'" [class.text-muted]="searchStatus() !== 'error'">
@switch (searchStatus()) {
@case ('loading') { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span><span i18n>Searching...</span> }
@case ('empty') { <span i18n>No matching documents.</span> }
@case ('error') { <span i18n>Document search failed.</span> }
}
</div>
</div>
<a routerLink="/ocr-templates" class="btn btn-sm btn-outline-secondary" title="Close" i18n-title aria-label="Close" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="x"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Close</span>
</a>
<button type="button" class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
@if (saving) {
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
}
<span i18n>Save</span>
</button>
</pngx-page-header>
<div class="row">
<div class="col-lg-4 mb-3">
<div class="btn-toolbar mb-1 pb-3 gap-2 border-bottom">
<a routerLink="/ocr-templates" class="btn btn-sm btn-outline-secondary" title="Close" i18n-title aria-label="Close" i18n-aria-label>
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
</a>
<button type="button" class="btn btn-sm btn-primary ms-auto" (click)="save()" [disabled]="saving">
@if (saving) {
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
}
<span i18n>Save</span>
</button>
</div>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
<li ngbNavItem="settings">
<a ngbNavLink i18n>Settings</a>
<a ngbNavLink i18n>Template</a>
<ng-template ngbNavContent>
<div class="row mb-3">
<div class="col-9">
@@ -74,30 +32,15 @@
<li ngbNavItem="zones">
<a ngbNavLink><ng-container i18n>Zones</ng-container> <span class="badge bg-primary ms-2">{{ template.zones.length }}</span></a>
<ng-template ngbNavContent>
<pngx-ocr-template-zone-list
[zones]="template.zones"
[selectedZoneIndex]="selectedZoneIndex"
[previewPage]="previewPage"
[previewPageCount]="previewPageCount"
[customFields]="customFields"
(zoneSelected)="selectZone($event)"
(zoneRemoved)="removeZone($event)"
></pngx-ocr-template-zone-list>
</ng-template>
</li>
<li ngbNavItem="zone">
<a ngbNavLink i18n>Zone</a>
<ng-template ngbNavContent>
@if (selectedZone; as zone) {
<div class="d-flex justify-content-between align-items-center mb-3">
<strong>{{ zone.name }}</strong>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete zone</ng-container>
</button>
</div>
<button type="button" class="btn btn-sm btn-link px-0" (click)="deselectZone()">
<i-bs name="arrow-left" class="me-1"></i-bs><ng-container i18n>All zones</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete zone</ng-container>
</button>
</div>
<div class="mb-3">
@@ -325,9 +268,15 @@
}
}
} @else {
<p class="text-muted" i18n>
No zone selected.
</p>
<pngx-ocr-template-zone-list
[zones]="template.zones"
[selectedZoneIndex]="selectedZoneIndex"
[previewPage]="previewPage"
[previewPageCount]="previewPageCount"
[customFields]="customFields"
(zoneSelected)="selectZone($event)"
(zoneRemoved)="removeZone($event)"
></pngx-ocr-template-zone-list>
}
</ng-template>
</li>
@@ -338,6 +287,44 @@
<div class="col-lg-8">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2" role="toolbar" aria-label="Document preview" i18n-aria-label>
<div class="flex-grow-1">
<label class="visually-hidden" for="preview-document" i18n>Sample document</label>
<div class="input-group input-group-sm">
<span class="input-group-text">
@if (searchStatus() === 'loading') {
<span class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden" i18n>Searching...</span></span>
} @else {
<i-bs name="file-text"></i-bs>
}
</span>
<input
id="preview-document"
type="text"
class="form-control"
[(ngModel)]="previewDocModel"
[ngbTypeahead]="searchDocuments"
#documentSearch="ngbTypeahead"
[inputFormatter]="documentFormatter"
[resultFormatter]="documentFormatter"
[resultTemplate]="documentResult"
(input)="searchStatus.set('idle')"
(selectItem)="onPreviewDocSelected($event)"
[editable]="false"
container="body"
popupClass="ocr-template-document-results col-11 col-md-6 overflow-y-auto"
placeholder="Search for a sample document..."
i18n-placeholder
/>
@if (previewDocId) {
<button type="button" class="btn btn-outline-secondary" (click)="clearPreviewDoc(); documentSearch.dismissPopup()" title="Clear sample document" i18n-title aria-label="Clear sample document" i18n-aria-label>
<i-bs name="x"></i-bs>
</button>
}
</div>
<ng-template #documentResult let-document="result">
<span class="d-block text-wrap text-break">{{ documentFormatter(document) }}</span>
</ng-template>
</div>
<div class="btn-group btn-group-sm text-nowrap" role="group" aria-label="Editing mode" i18n-aria-label>
<button type="button" class="btn btn-outline-primary" [class.active]="overlayMode === 'select'" [attr.aria-pressed]="overlayMode === 'select'" [disabled]="!imageLoaded" (click)="setOverlayMode('select')" title="Select and move zones" i18n-title>
<i-bs name="cursor" class="me-1"></i-bs><span i18n>Select</span>
@@ -346,117 +333,125 @@
<i-bs name="pencil" class="me-1"></i-bs><span i18n>Draw</span>
</button>
</div>
<div class="input-group input-group-sm w-auto mw-100 text-nowrap order-1 order-sm-0">
<button type="button" class="btn btn-outline-secondary" title="Previous page" i18n-title aria-label="Previous page" i18n-aria-label (click)="prevPage()" [disabled]="!previewDocId || previewStatus === 'loading' || previewPage <= 0">
<i-bs name="arrow-left"></i-bs>
</button>
<span class="input-group-text" i18n>Page</span>
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount" [(ngModel)]="previewPageDisplay" [disabled]="!previewDocId || previewStatus === 'loading'" aria-label="Preview page" i18n-aria-label />
<span class="input-group-text" i18n>of {{ previewPageCount ?? '?' }}</span>
<button type="button" class="btn btn-outline-secondary" title="Next page" i18n-title aria-label="Next page" i18n-aria-label (click)="nextPage()" [disabled]="!previewDocId || previewStatus === 'loading' || previewPage >= (previewPageCount ?? 1) - 1">
<i-bs name="arrow-right"></i-bs>
</button>
</div>
<div class="btn-group btn-group-sm text-nowrap" role="group" aria-label="Zoom" i18n-aria-label>
<button type="button" class="btn btn-outline-secondary" title="Zoom out" i18n-title aria-label="Zoom out" i18n-aria-label [disabled]="!imageLoaded || zoom <= 0.5" (click)="zoomOut()"><i-bs name="dash"></i-bs></button>
<button type="button" class="btn btn-outline-secondary preview-zoom" title="Reset zoom" i18n-title [disabled]="!imageLoaded" (click)="resetZoom()">{{ zoom | percent: '1.0-0' }}</button>
<button type="button" class="btn btn-outline-secondary" title="Zoom in" i18n-title aria-label="Zoom in" i18n-aria-label [disabled]="!imageLoaded || zoom >= 4" (click)="zoomIn()"><i-bs name="plus"></i-bs></button>
</div>
</div>
<div class="zone-preview-scroll overflow-auto border" [attr.aria-busy]="previewStatus === 'loading'">
@if (previewStatus === 'error') {
<div class="py-5 px-3 text-center" role="alert">
<i-bs name="exclamation-triangle" width="32" height="32" class="text-warning"></i-bs>
<p class="my-3">{{ previewError }}</p>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="loadPreview()"><i-bs name="arrow-clockwise" class="me-1"></i-bs><span i18n>Retry</span></button>
</div>
} @else if (pageImageUrl) {
<div class="position-relative" [class.d-none]="!imageLoaded" [style.width.%]="zoom * 100">
<img
#pageImage
[src]="pageImageUrl"
(load)="onImageLoad()"
(error)="onImageError()"
alt="Sample document page"
i18n-alt
class="d-block w-100"
[style.visibility]="imageLoaded ? 'visible' : 'hidden'"
crossorigin="use-credentials"
/>
@if (imageLoaded) {
<svg
#zoneOverlay
class="zone-overlay position-absolute top-0 start-0 w-100 h-100"
[class.zone-overlay-drawing]="overlayMode === 'draw'"
[attr.viewBox]="overlayViewBox()"
preserveAspectRatio="none"
[style.cursor]="overlayCursor"
(pointerdown)="onOverlayPointerDown($event)"
(pointermove)="onOverlayPointerMove($event)"
(pointerup)="onOverlayPointerUp($event)"
(pointercancel)="onOverlayPointerCancel($event)"
(lostpointercapture)="onOverlayPointerCancel($event)"
>
@for (zone of template.zones; track $index; let i = $index) {
@if (zoneDisplayRect(i); as rect) {
<g>
<rect
class="zone-rect"
[class.zone-rect-selected]="selectedZoneIndex === i"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
[attr.stroke]="zoneColor(i)"
[attr.fill]="zoneFill(i)"
></rect>
<text
class="zone-label fw-semibold pe-none"
[attr.x]="rect.x + overlayUnitSize(6)"
[attr.y]="zoneLabelY(rect)"
[attr.font-size]="overlayFontSize()"
[attr.fill]="zoneColor(i)"
>{{ zoneLabel(zone, i) }}</text>
@if (searchStatus() === 'empty' || searchStatus() === 'error') {
<div class="small mb-2" role="status" [class.text-danger]="searchStatus() === 'error'" [class.text-muted]="searchStatus() === 'empty'">
@if (searchStatus() === 'error') {
<span i18n>Document search failed.</span>
} @else {
<span i18n>No matching documents.</span>
}
</div>
}
<div class="position-relative">
<div class="zone-preview-scroll overflow-auto border" [attr.aria-busy]="previewStatus === 'loading'">
@if (previewStatus === 'error') {
<div class="py-5 px-3 text-center" role="alert">
<i-bs name="exclamation-triangle" width="32" height="32" class="text-warning"></i-bs>
<p class="my-3">{{ previewError }}</p>
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="loadPreview()"><i-bs name="arrow-clockwise" class="me-1"></i-bs><span i18n>Retry</span></button>
</div>
} @else if (pageImageUrl) {
<div class="position-relative" [class.d-none]="!imageLoaded" [style.width.%]="zoom * 100">
<img
#pageImage
[src]="pageImageUrl"
(load)="onImageLoad()"
(error)="onImageError()"
alt="Sample document page"
i18n-alt
class="d-block w-100"
[style.visibility]="imageLoaded ? 'visible' : 'hidden'"
crossorigin="use-credentials"
/>
@if (imageLoaded) {
<svg
#zoneOverlay
class="zone-overlay position-absolute top-0 start-0 w-100 h-100"
[class.zone-overlay-drawing]="overlayMode === 'draw'"
[attr.viewBox]="overlayViewBox()"
preserveAspectRatio="none"
[style.cursor]="overlayCursor"
(pointerdown)="onOverlayPointerDown($event)"
(pointermove)="onOverlayPointerMove($event)"
(pointerup)="onOverlayPointerUp($event)"
(pointercancel)="onOverlayPointerCancel($event)"
(lostpointercapture)="onOverlayPointerCancel($event)"
>
@for (zone of template.zones; track $index; let i = $index) {
@if (zoneDisplayRect(i); as rect) {
<g>
<rect
class="zone-rect"
[class.zone-rect-selected]="selectedZoneIndex === i"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
[attr.stroke]="zoneColor(i)"
[attr.fill]="zoneFill(i)"
></rect>
<text
class="zone-label fw-semibold pe-none"
[attr.x]="rect.x + overlayUnitSize(6)"
[attr.y]="zoneLabelY(rect)"
[attr.font-size]="overlayFontSize()"
[attr.fill]="zoneColor(i)"
>{{ zoneLabel(zone, i) }}</text>
@if (selectedZoneIndex === i && overlayMode === 'select') {
@for (handle of resizeHandles(rect); track handle.handle) {
<rect
class="zone-resize-handle"
[attr.x]="handle.x - overlayHandleSize() / 2"
[attr.y]="handle.y - overlayHandleSize() / 2"
[attr.width]="overlayHandleSize()"
[attr.height]="overlayHandleSize()"
[attr.fill]="zoneColor(i)"
></rect>
@if (selectedZoneIndex === i && overlayMode === 'select') {
@for (handle of resizeHandles(rect); track handle.handle) {
<rect
class="zone-resize-handle"
[attr.x]="handle.x - overlayHandleSize() / 2"
[attr.y]="handle.y - overlayHandleSize() / 2"
[attr.width]="overlayHandleSize()"
[attr.height]="overlayHandleSize()"
[attr.fill]="zoneColor(i)"
></rect>
}
}
}
</g>
</g>
}
}
}
@if (drawingRect(); as rect) {
<rect
class="zone-drawing-rect"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
></rect>
}
</svg>
}
</div>
@if (previewStatus === 'loading') {
<div class="p-5 d-flex justify-content-center align-items-center" role="status">
<div class="spinner-border" role="status">
<span class="visually-hidden" i18n>Loading page...</span>
@if (drawingRect(); as rect) {
<rect
class="zone-drawing-rect"
[attr.x]="rect.x"
[attr.y]="rect.y"
[attr.width]="rect.w"
[attr.height]="rect.h"
></rect>
}
</svg>
}
</div>
@if (previewStatus === 'loading') {
<div class="p-5 d-flex justify-content-center align-items-center" role="status">
<div class="spinner-border" role="status">
<span class="visually-hidden" i18n>Loading page...</span>
</div>
</div>
}
} @else {
<div class="py-5 px-3 text-center text-muted">
<i-bs name="file-text" width="32" height="32"></i-bs>
<p class="mt-3 mb-0" i18n>No sample document selected.</p>
</div>
}
} @else {
<div class="py-5 px-3 text-center text-muted">
<i-bs name="file-text" width="32" height="32"></i-bs>
<p class="mt-3 mb-0" i18n>No sample document selected.</p>
</div>
@if (previewDocId && previewStatus !== 'error') {
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
<div class="input-group input-group-sm w-auto flex-nowrap">
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount" [(ngModel)]="previewPageDisplay" [disabled]="previewStatus === 'loading'" aria-label="Page" i18n-aria-label />
<span class="input-group-text" i18n>of {{ previewPageCount ?? '?' }}</span>
</div>
<div class="input-group input-group-sm w-auto flex-nowrap" role="group" aria-label="Zoom" i18n-aria-label>
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom out" i18n-aria-label [disabled]="!imageLoaded || zoom <= 0.5" (click)="zoomOut()"><i-bs name="dash"></i-bs></button>
<button type="button" class="btn btn-outline-secondary preview-zoom" title="Reset zoom" i18n-title [disabled]="!imageLoaded" (click)="resetZoom()">{{ zoom | percent: '1.0-0' }}</button>
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom in" i18n-aria-label [disabled]="!imageLoaded || zoom >= 4" (click)="zoomIn()"><i-bs name="plus"></i-bs></button>
</div>
</div>
}
</div>
@@ -6,10 +6,6 @@
max-height: 78vh;
}
.preview-search-status {
min-height: 1.5rem;
}
.preview-page-input {
width: 3.5rem;
}
@@ -198,6 +198,24 @@ describe('OcrTemplateEditorComponent', () => {
expect(component.overlayMode).toBe('select')
})
it('opens the zone in the Zones tab and returns to the list', () => {
component.template.zones = [zone(), zone()]
component.activeTab = 'settings'
component.selectZone(1)
expect(component.activeTab).toBe('zones')
expect(component.selectedZone).toBe(component.template.zones[1])
component.deselectZone()
expect(component.selectedZone).toBeNull()
expect(component.activeTab).toBe('zones')
component.selectZone(0)
component.deleteSelectedZone()
expect(component.template.zones.length).toBe(1)
expect(component.selectedZone).toBeNull()
})
it('hides results when extraction settings or geometry change', () => {
const selected = zone()
component.template.zones = [selected]
@@ -80,7 +80,7 @@ import {
resizeZone,
} from './zone-geometry'
type ActiveTab = 'settings' | 'zones' | 'zone'
type ActiveTab = 'settings' | 'zones'
type ZoneFieldSelection = OcrBuiltinTarget | number | null
type OverlayMode = 'select' | 'draw'
type OverlayInteraction =
@@ -438,14 +438,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
this.loadPreview()
}
prevPage() {
this.goToPage(this.previewPage - 1)
}
nextPage() {
this.goToPage(this.previewPage + 1)
}
zoomIn() {
this.zoom = Math.min(4, Math.round((this.zoom + 0.25) * 100) / 100)
}
@@ -819,7 +811,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
selectZone(index: number) {
this.selectedZoneIndex = index
this.activeTab = 'zone'
this.activeTab = 'zones'
this.clearZoneTest()
const zone = this.template.zones[index]
if (zone) {
@@ -828,6 +820,11 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
}
}
deselectZone() {
this.selectedZoneIndex = null
this.clearZoneTest()
}
testZone() {
const zone = this.selectedZone
if (!zone || !this.previewDocId) return
@@ -896,7 +893,6 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
deleteSelectedZone() {
if (this.selectedZoneIndex === null) return
this.removeZone(this.selectedZoneIndex)
this.activeTab = 'zones'
}
save() {