mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 23:08:14 +00:00
make the ui usable
This commit is contained in:
6 files changed
+664
-226
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
@if (zones.length === 0) {
|
||||
<p class="text-muted" i18n>
|
||||
No zones defined. Load a document preview and draw rectangles to add zones.
|
||||
No zones defined.
|
||||
</p>
|
||||
}
|
||||
|
||||
|
||||
+162
-140
@@ -1,60 +1,57 @@
|
||||
<pngx-page-header [title]="pageTitle" [id]="template.id">
|
||||
<div class="input-group input-group-sm me-5 align-items-center">
|
||||
<div class="input-group-text">
|
||||
<i-bs name="file-text"></i-bs>
|
||||
</div>
|
||||
<input
|
||||
<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"
|
||||
placeholder="Search documents by title..."
|
||||
container="body"
|
||||
popupClass="ocr-template-document-results col-11 col-md-6 overflow-y-auto"
|
||||
placeholder="Search documents..."
|
||||
i18n-placeholder
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewPageCount" [(ngModel)]="previewPageDisplay" />
|
||||
<div class="input-group-text" i18n>of {{previewPageCount}}</div>
|
||||
@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>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="prevPage()" [disabled]="!pageImageUrl || previewPage <= 0">
|
||||
<i-bs width="1.2em" height="1.2em" name="arrow-left"></i-bs>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextPage()" [disabled]="!pageImageUrl || previewPage >= (previewPageCount ?? 1) - 1">
|
||||
<i-bs width="1.2em" height="1.2em" name="arrow-right"></i-bs>
|
||||
</button>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<button class="btn btn-outline-secondary" (click)="zoomOut()" i18n>-</button>
|
||||
<span class="input-group-text">{{ zoom * 100 | number: '1.0-0' }}%</span>
|
||||
<button class="btn btn-outline-secondary" (click)="zoomIn()" i18n>+</button>
|
||||
<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>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="btn-toolbar mb-1 border-bottom">
|
||||
<div class="btn-group pb-3">
|
||||
<a routerLink="/ocr-templates" class="btn btn-sm btn-outline-secondary">
|
||||
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
|
||||
<span class="ms-1" i18n>Close</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="btn-group ms-auto pb-3">
|
||||
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
|
||||
@if (saving) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<span i18n>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
@@ -72,10 +69,6 @@
|
||||
|
||||
<pngx-input-select [(ngModel)]="template.document_type" [items]="documentTypes" bindLabel="name" bindValue="id" title="Document type" i18n-title></pngx-input-select>
|
||||
|
||||
<small class="text-muted" i18n>
|
||||
Draw rectangles on the preview to define extraction zones. Use the
|
||||
page controls above the preview to add zones on different pages.
|
||||
</small>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
@@ -101,13 +94,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<strong>{{ zone.name }}</strong>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" (click)="save()" [disabled]="saving">
|
||||
@if (saving) {
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<span i18n>Save</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="deleteSelectedZone()">
|
||||
<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>
|
||||
@@ -305,7 +292,7 @@
|
||||
<h6 i18n>Test</h6>
|
||||
@if (!previewDocId) {
|
||||
<p class="text-muted small mb-0" i18n>
|
||||
Load a document in the Settings tab to test this zone.
|
||||
No sample document selected.
|
||||
</p>
|
||||
} @else {
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="testZone()" [disabled]="zoneTesting">
|
||||
@@ -314,19 +301,19 @@
|
||||
}
|
||||
<span i18n>Test this zone</span>
|
||||
</button>
|
||||
@if (zoneTestResult) {
|
||||
@if (zoneTestResult.error) {
|
||||
<div class="alert alert-warning py-2 mt-2 mb-0 small">{{ zoneTestResult.error }}</div>
|
||||
@if (zoneTestResult; as result) {
|
||||
@if (result.error) {
|
||||
<div class="alert alert-warning py-2 mt-2 mb-0 small" role="alert">{{ result.error }}</div>
|
||||
} @else {
|
||||
<dl class="row small mt-2 mb-0">
|
||||
<dt class="col-sm-4" i18n>OCR text</dt>
|
||||
<dd class="col-sm-8"><code>{{ zoneTestResult.raw_text || '(nothing detected)' }}</code></dd>
|
||||
<dd class="col-sm-8"><code>{{ result.raw_text || '(nothing detected)' }}</code></dd>
|
||||
<dt class="col-sm-4" i18n>Value</dt>
|
||||
<dd class="col-sm-8"><code>{{ zoneTestResult.value || '(empty)' }}</code></dd>
|
||||
@if (zoneTestResult.regex) {
|
||||
<dd class="col-sm-8"><code>{{ result.value || '(empty)' }}</code></dd>
|
||||
@if (result.regex) {
|
||||
<dt class="col-sm-4" i18n>Validation</dt>
|
||||
<dd class="col-sm-8">
|
||||
@if (zoneTestResult.regex_match) {
|
||||
@if (result.regex_match) {
|
||||
<span class="badge bg-success" i18n>Regex matches</span>
|
||||
} @else {
|
||||
<span class="badge bg-danger" i18n>Regex does not match</span>
|
||||
@@ -339,7 +326,7 @@
|
||||
}
|
||||
} @else {
|
||||
<p class="text-muted" i18n>
|
||||
Select a zone from the Zones tab, or draw a rectangle on the document to create one.
|
||||
No zone selected.
|
||||
</p>
|
||||
}
|
||||
</ng-template>
|
||||
@@ -349,94 +336,129 @@
|
||||
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right column: Document preview with zone overlay -->
|
||||
<div class="col-md-8">
|
||||
@if (pageImageUrl) {
|
||||
<div class="zone-preview-scroll border">
|
||||
<div class="zone-preview-stage" [style.width.%]="zoom * 100">
|
||||
<img
|
||||
#pageImage
|
||||
[src]="pageImageUrl"
|
||||
(load)="onImageLoad()"
|
||||
class="zone-preview-image"
|
||||
[style.visibility]="imageLoaded ? 'visible' : 'hidden'"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
@if (imageLoaded) {
|
||||
<svg
|
||||
#zoneOverlay
|
||||
class="zone-overlay"
|
||||
[attr.viewBox]="overlayViewBox()"
|
||||
preserveAspectRatio="none"
|
||||
[style.cursor]="overlayCursor"
|
||||
(mousedown)="onOverlayMouseDown($event)"
|
||||
(mousemove)="onOverlayMouseMove($event)"
|
||||
(mouseup)="onOverlayMouseUp($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"
|
||||
[attr.x]="rect.x + overlayUnitSize(6)"
|
||||
[attr.y]="zoneLabelY(rect)"
|
||||
[attr.font-size]="overlayFontSize()"
|
||||
[attr.fill]="zoneColor(i)"
|
||||
>{{ zoneLabel(zone, i) }}</text>
|
||||
<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="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>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary" [class.active]="overlayMode === 'draw'" [attr.aria-pressed]="overlayMode === 'draw'" [disabled]="!imageLoaded" (click)="setOverlayMode('draw')" title="Draw a zone" i18n-title>
|
||||
<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 (selectedZoneIndex === i) {
|
||||
@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>
|
||||
}
|
||||
@if (!imageLoaded) {
|
||||
<div class="d-flex justify-content-center p-5">
|
||||
@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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="border rounded p-5 text-center text-muted">
|
||||
<i-bs name="file-earmark-image" width="48" height="48"></i-bs>
|
||||
<p class="mt-3" i18n>
|
||||
Enter a document ID and click "Load" to preview a page and draw extraction zones.
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+19
-13
@@ -4,25 +4,33 @@
|
||||
|
||||
.zone-preview-scroll {
|
||||
max-height: 78vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.zone-preview-stage {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
.preview-search-status {
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.zone-preview-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
.preview-page-input {
|
||||
width: 3.5rem;
|
||||
}
|
||||
|
||||
.preview-zoom {
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
// The typeahead popup is appended to body.
|
||||
::ng-deep .ocr-template-document-results {
|
||||
max-height: min(24rem, 60vh);
|
||||
}
|
||||
|
||||
.zone-overlay {
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.zone-overlay-drawing,
|
||||
.zone-rect,
|
||||
.zone-resize-handle {
|
||||
touch-action: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zone-rect,
|
||||
@@ -40,9 +48,7 @@
|
||||
|
||||
.zone-label {
|
||||
font-family: var(--bs-font-sans-serif);
|
||||
font-weight: 600;
|
||||
paint-order: stroke;
|
||||
pointer-events: none;
|
||||
stroke: #fff;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 4px;
|
||||
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
import { ElementRef } from '@angular/core'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { of, Subject, throwError } from 'rxjs'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { OcrTemplateZone, OcrZoneTestResult } from 'src/app/data/ocr-template'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { OcrTemplateEditorComponent } from './ocr-template-editor.component'
|
||||
|
||||
function zone(): OcrTemplateZone {
|
||||
return {
|
||||
name: 'Invoice number',
|
||||
custom_field: 1,
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
page: 1,
|
||||
ocr_language: 'eng',
|
||||
transform: 'strip',
|
||||
validation_regex: '',
|
||||
order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('OcrTemplateEditorComponent', () => {
|
||||
let fixture: ComponentFixture<OcrTemplateEditorComponent>
|
||||
let component: OcrTemplateEditorComponent
|
||||
const documents = {
|
||||
list: jest.fn(),
|
||||
get: jest.fn(),
|
||||
}
|
||||
const templates = {
|
||||
testZone: jest.fn(),
|
||||
getPageImageUrl: (id: number, page: number) => `/preview/${id}/${page}`,
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
documents.list.mockReset().mockReturnValue(of({ results: [] }))
|
||||
documents.get.mockReset().mockReturnValue(of({ id: 1, page_count: 3 }))
|
||||
templates.testZone.mockReset()
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [OcrTemplateEditorComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { paramMap: new Map(), queryParams: {} } },
|
||||
},
|
||||
{ provide: DocumentService, useValue: documents },
|
||||
{ provide: OcrTemplateService, useValue: templates },
|
||||
...[CustomFieldsService, DocumentTypeService, CorrespondentService].map(
|
||||
(provide) => ({
|
||||
provide,
|
||||
useValue: { listAll: () => of({ results: [] }) },
|
||||
})
|
||||
),
|
||||
{ provide: ToastService, useValue: {} },
|
||||
],
|
||||
})
|
||||
.overrideComponent(OcrTemplateEditorComponent, {
|
||||
set: { template: '{{ searchStatus() }}', imports: [] },
|
||||
})
|
||||
.compileComponents()
|
||||
fixture = TestBed.createComponent(OcrTemplateEditorComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
|
||||
afterEach(() => jest.useRealTimers())
|
||||
|
||||
function prepareOverlay() {
|
||||
const image = document.createElement('img')
|
||||
Object.defineProperties(image, {
|
||||
naturalWidth: { value: 1000 },
|
||||
naturalHeight: { value: 1000 },
|
||||
clientWidth: { value: 1000 },
|
||||
})
|
||||
const overlay = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'svg'
|
||||
)
|
||||
Object.assign(overlay, {
|
||||
getScreenCTM: () => ({ inverse: () => ({}) }),
|
||||
createSVGPoint: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
matrixTransform() {
|
||||
return { x: this.x, y: this.y }
|
||||
},
|
||||
}),
|
||||
setPointerCapture: jest.fn(),
|
||||
hasPointerCapture: () => true,
|
||||
releasePointerCapture: jest.fn(),
|
||||
})
|
||||
component.imageRef = new ElementRef(image)
|
||||
component.overlayRef = new ElementRef(overlay)
|
||||
component.previewStatus = 'ready'
|
||||
}
|
||||
|
||||
function pointer(x: number, y: number, pointerId = 1): PointerEvent {
|
||||
return Object.assign(
|
||||
new MouseEvent('pointermove', { clientX: x, clientY: y, button: 0 }),
|
||||
{ pointerId }
|
||||
) as PointerEvent
|
||||
}
|
||||
|
||||
it('searches across document metadata even when a template document type is set', () => {
|
||||
jest.useFakeTimers()
|
||||
const term$ = new Subject<string>()
|
||||
const doc = { id: 1, title: 'Electricity Bill' } as Document
|
||||
documents.list.mockReturnValue(of({ results: [doc] }))
|
||||
component.template.document_type = 2
|
||||
const found = jest.fn()
|
||||
component.searchDocuments(term$).subscribe(found)
|
||||
|
||||
term$.next(' northw ')
|
||||
jest.advanceTimersByTime(250)
|
||||
|
||||
expect(documents.list).toHaveBeenCalledWith(1, 10, 'created', true, {
|
||||
search: 'northw',
|
||||
truncate_content: true,
|
||||
})
|
||||
expect(found).toHaveBeenCalledWith([doc])
|
||||
expect(component.searchStatus()).toBe('ready')
|
||||
})
|
||||
|
||||
it('distinguishes an empty search from a failed request and allows another search', () => {
|
||||
jest.useFakeTimers()
|
||||
const term$ = new Subject<string>()
|
||||
component.searchDocuments(term$).subscribe()
|
||||
term$.next('missing')
|
||||
jest.advanceTimersByTime(250)
|
||||
expect(component.searchStatus()).toBe('empty')
|
||||
|
||||
documents.list.mockReturnValueOnce(throwError(() => new Error('offline')))
|
||||
term$.next('offline')
|
||||
jest.advanceTimersByTime(250)
|
||||
expect(component.searchStatus()).toBe('error')
|
||||
|
||||
term$.next('retry')
|
||||
jest.advanceTimersByTime(250)
|
||||
expect(component.searchStatus()).toBe('empty')
|
||||
})
|
||||
|
||||
it('renders asynchronous search feedback without a user interaction', async () => {
|
||||
fixture.detectChanges()
|
||||
const pending = new Subject<{ results: Document[] }>()
|
||||
const term$ = new Subject<string>()
|
||||
documents.list.mockReturnValue(pending)
|
||||
component.searchDocuments(term$).subscribe()
|
||||
term$.next('missing')
|
||||
await new Promise((resolve) => setTimeout(resolve, 275))
|
||||
pending.next({ results: [] })
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toBe('empty')
|
||||
})
|
||||
|
||||
it('does not create zones by dragging empty space in Select mode', () => {
|
||||
prepareOverlay()
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
component.onOverlayPointerMove(pointer(300, 200))
|
||||
component.onOverlayPointerUp(pointer(300, 200))
|
||||
|
||||
expect(component.template.zones).toEqual([])
|
||||
})
|
||||
|
||||
it('draws within the page bounds, uses the release position and returns to Select', () => {
|
||||
prepareOverlay()
|
||||
component.setOverlayMode('draw')
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
component.onOverlayPointerUp(pointer(1200, 250))
|
||||
|
||||
expect(component.template.zones[0]).toMatchObject({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 900,
|
||||
height: 150,
|
||||
})
|
||||
expect(component.selectedZoneIndex).toBe(0)
|
||||
expect(component.overlayMode).toBe('select')
|
||||
})
|
||||
|
||||
it('discards a drawing on Escape and ignores other pointers', () => {
|
||||
prepareOverlay()
|
||||
component.setOverlayMode('draw')
|
||||
component.onOverlayPointerDown(pointer(100, 100))
|
||||
component.onOverlayPointerUp(pointer(300, 200, 2))
|
||||
expect(component.drawingRect()).not.toBeNull()
|
||||
|
||||
component.cancelDrawing()
|
||||
component.onOverlayPointerUp(pointer(300, 200))
|
||||
expect(component.template.zones).toEqual([])
|
||||
expect(component.overlayMode).toBe('select')
|
||||
})
|
||||
|
||||
it('hides results when extraction settings or geometry change', () => {
|
||||
const selected = zone()
|
||||
component.template.zones = [selected]
|
||||
component.selectedZoneIndex = 0
|
||||
component.previewDocId = 1
|
||||
templates.testZone.mockReturnValue(of({ value: '123' }))
|
||||
component.testZone()
|
||||
expect(component.zoneTestResult?.value).toBe('123')
|
||||
|
||||
selected.transform = 'numeric'
|
||||
expect(component.zoneTestResult).toBeNull()
|
||||
component.testZone()
|
||||
selected.width++
|
||||
expect(component.zoneTestResult).toBeNull()
|
||||
})
|
||||
|
||||
it('cancels a pending test when selection or sample document changes', () => {
|
||||
const pending = new Subject<OcrZoneTestResult>()
|
||||
component.template.zones = [zone(), zone()]
|
||||
component.selectedZoneIndex = 0
|
||||
component.previewDocId = 1
|
||||
templates.testZone.mockReturnValue(pending)
|
||||
component.testZone()
|
||||
expect(component.zoneTesting).toBe(true)
|
||||
|
||||
component.selectZone(1)
|
||||
pending.next({ value: 'Old zone' })
|
||||
expect(component.zoneTesting).toBe(false)
|
||||
expect(component.zoneTestResult).toBeNull()
|
||||
|
||||
component.testZone()
|
||||
component.clearPreviewDoc()
|
||||
pending.next({ value: 'Old document' })
|
||||
expect(component.zoneTestResult).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores document metadata from a previous preview selection', () => {
|
||||
const pending = new Subject<Document>()
|
||||
documents.get.mockReturnValueOnce(pending)
|
||||
component.previewDocId = 1
|
||||
component.loadPreview()
|
||||
component.previewDocId = 2
|
||||
component.loadPreview()
|
||||
pending.next({ id: 1, page_count: 99 } as Document)
|
||||
|
||||
expect(component.previewPageCount).toBe(3)
|
||||
})
|
||||
|
||||
it('can retry a failed preview and clear the selected document', () => {
|
||||
component.previewDocId = 1
|
||||
component.loadPreview()
|
||||
component.onImageError()
|
||||
expect(component.previewStatus).toBe('error')
|
||||
|
||||
component.loadPreview()
|
||||
expect(component.previewStatus).toBe('loading')
|
||||
component.clearPreviewDoc()
|
||||
expect(component.previewStatus).toBe('empty')
|
||||
expect(component.pageImageUrl).toBeNull()
|
||||
})
|
||||
})
|
||||
+219
-72
@@ -7,10 +7,11 @@ import {
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbNavModule,
|
||||
NgbPopoverModule,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
||||
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
|
||||
@@ -70,6 +72,7 @@ import {
|
||||
getZonePage,
|
||||
HANDLE_SIZE,
|
||||
isZoneOnPage,
|
||||
MIN_ZONE_SIZE,
|
||||
MoveStart,
|
||||
moveZone,
|
||||
Point,
|
||||
@@ -79,17 +82,28 @@ import {
|
||||
|
||||
type ActiveTab = 'settings' | 'zones' | 'zone'
|
||||
type ZoneFieldSelection = OcrBuiltinTarget | number | null
|
||||
type OverlayMode = 'select' | 'draw'
|
||||
type OverlayInteraction =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'drawing'; rect: DrawingRect }
|
||||
| { kind: 'moving'; zoneIndex: number; start: MoveStart }
|
||||
| { kind: 'resizing'; zoneIndex: number; handle: ResizeHandle }
|
||||
| { kind: 'drawing'; pointerId: number; rect: DrawingRect }
|
||||
| { kind: 'moving'; pointerId: number; zoneIndex: number; start: MoveStart }
|
||||
| {
|
||||
kind: 'resizing'
|
||||
pointerId: number
|
||||
zoneIndex: number
|
||||
handle: ResizeHandle
|
||||
}
|
||||
interface ZoneTest {
|
||||
zone: OcrTemplateZone
|
||||
documentId: number
|
||||
request: ZoneTestRequest
|
||||
result: OcrZoneTestResult | null
|
||||
}
|
||||
interface ResizeHandleMarker extends Point {
|
||||
handle: ResizeHandle
|
||||
}
|
||||
|
||||
const CUSTOM_DATE_FORMAT_CHOICE = 'custom'
|
||||
const MIN_DRAWN_ZONE_SIZE = 10
|
||||
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
|
||||
const ZONE_COLORS = [
|
||||
'#4f8ff7',
|
||||
@@ -135,7 +149,6 @@ const RESIZE_CURSOR: Record<ResizeHandle, string> = {
|
||||
})
|
||||
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
private readonly templateService = inject(OcrTemplateService)
|
||||
private readonly customFieldsService = inject(CustomFieldsService)
|
||||
private readonly documentTypeService = inject(DocumentTypeService)
|
||||
@@ -144,10 +157,27 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
private readonly toastService = inject(ToastService)
|
||||
private readonly changeDetector = inject(ChangeDetectorRef)
|
||||
private readonly destroy$ = new Subject<void>()
|
||||
private readonly cancelZoneTest$ = new Subject<void>()
|
||||
private readonly customDateFormatZones = new WeakSet<OcrTemplateZone>()
|
||||
|
||||
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
|
||||
@ViewChild('pageImage') imageRef: ElementRef<HTMLImageElement>
|
||||
imageRef: ElementRef<HTMLImageElement>
|
||||
private previewResizeObserver: ResizeObserver | null = null
|
||||
private readonly overlayScale = signal(1)
|
||||
|
||||
@ViewChild('pageImage')
|
||||
set pageImage(ref: ElementRef<HTMLImageElement> | undefined) {
|
||||
this.previewResizeObserver?.disconnect()
|
||||
this.imageRef = ref
|
||||
if (!ref) return
|
||||
this.previewResizeObserver = new ResizeObserver(() => {
|
||||
const image = ref.nativeElement
|
||||
if (image.naturalWidth && image.clientWidth) {
|
||||
this.overlayScale.set(image.naturalWidth / image.clientWidth)
|
||||
}
|
||||
})
|
||||
this.previewResizeObserver.observe(ref.nativeElement)
|
||||
}
|
||||
|
||||
template: OcrTemplate = {
|
||||
id: null,
|
||||
@@ -177,7 +207,11 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
previewPageCount: number | null = null
|
||||
private pageCountForDoc: number | null = null
|
||||
pageImageUrl: string | null = null
|
||||
imageLoaded = false
|
||||
previewStatus: 'empty' | 'loading' | 'ready' | 'error' = 'empty'
|
||||
previewError = ''
|
||||
readonly searchStatus = signal<
|
||||
'idle' | 'loading' | 'ready' | 'empty' | 'error'
|
||||
>('idle')
|
||||
zoom = 1
|
||||
previewDocModel: Document | string = ''
|
||||
private correspondentNames = new Map<number, string>()
|
||||
@@ -194,10 +228,22 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
|
||||
selectedZoneIndex: number | null = null
|
||||
private overlayInteraction: OverlayInteraction = NO_OVERLAY_INTERACTION
|
||||
overlayCursor = 'crosshair'
|
||||
overlayMode: OverlayMode = 'select'
|
||||
overlayCursor = 'default'
|
||||
|
||||
zoneTestResult: OcrZoneTestResult | null = null
|
||||
zoneTesting = false
|
||||
private zoneTest: ZoneTest | null = null
|
||||
|
||||
get imageLoaded(): boolean {
|
||||
return this.previewStatus === 'ready'
|
||||
}
|
||||
|
||||
get zoneTestResult(): OcrZoneTestResult | null {
|
||||
return this.isCurrentZoneTest() ? this.zoneTest.result : null
|
||||
}
|
||||
|
||||
get zoneTesting(): boolean {
|
||||
return this.isCurrentZoneTest() && this.zoneTest.result === null
|
||||
}
|
||||
|
||||
showQuickCreate = false
|
||||
quickCreateName = ''
|
||||
@@ -282,23 +328,35 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
|
||||
searchDocuments = (text$: Observable<string>): Observable<Document[]> =>
|
||||
text$.pipe(
|
||||
map((term) => term.trim()),
|
||||
debounceTime(250),
|
||||
distinctUntilChanged(),
|
||||
switchMap((term) => {
|
||||
if (!term || term.trim().length < 2) return of([])
|
||||
const params: { title__icontains: string; document_type__id?: number } =
|
||||
{ title__icontains: term.trim() }
|
||||
if (this.template.document_type) {
|
||||
params['document_type__id'] = this.template.document_type
|
||||
if (term.length < 2) {
|
||||
this.searchStatus.set('idle')
|
||||
return of([])
|
||||
}
|
||||
return this.documentService.list(1, 10, 'created', true, params).pipe(
|
||||
map((r) => r.results),
|
||||
catchError(() => of([]))
|
||||
)
|
||||
this.searchStatus.set('loading')
|
||||
return this.documentService
|
||||
.list(1, 10, 'created', true, {
|
||||
search: term,
|
||||
truncate_content: true,
|
||||
})
|
||||
.pipe(
|
||||
map((r) => r.results),
|
||||
tap((documents) => {
|
||||
this.searchStatus.set(documents.length ? 'ready' : 'empty')
|
||||
}),
|
||||
catchError(() => {
|
||||
this.searchStatus.set('error')
|
||||
return of([])
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
documentFormatter = (doc: Document | string): string => {
|
||||
documentFormatter = (doc: Document | string | null): string => {
|
||||
if (!doc) return ''
|
||||
if (typeof doc === 'string') return doc
|
||||
const corr = doc.correspondent
|
||||
? this.correspondentNames.get(doc.correspondent)
|
||||
@@ -312,6 +370,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
event.preventDefault()
|
||||
const doc: Document = event.item
|
||||
this.previewDocModel = doc
|
||||
this.searchStatus.set('idle')
|
||||
this.previewDocId = doc.id
|
||||
if (!this.template.document_type && doc.document_type) {
|
||||
this.template.document_type = doc.document_type
|
||||
@@ -321,31 +380,45 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
clearPreviewDoc() {
|
||||
this.stopOverlayInteraction()
|
||||
this.clearZoneTest()
|
||||
this.previewDocModel = ''
|
||||
this.previewDocId = null
|
||||
this.previewPageCount = null
|
||||
this.pageCountForDoc = null
|
||||
this.previewPage = 0
|
||||
this.pageImageUrl = null
|
||||
this.imageLoaded = false
|
||||
this.previewStatus = 'empty'
|
||||
this.previewError = ''
|
||||
this.searchStatus.set('idle')
|
||||
}
|
||||
|
||||
loadPreview() {
|
||||
if (!this.previewDocId) return
|
||||
if (this.pageCountForDoc !== this.previewDocId) {
|
||||
this.pageCountForDoc = this.previewDocId
|
||||
this.stopOverlayInteraction()
|
||||
this.clearZoneTest()
|
||||
this.previewStatus = 'loading'
|
||||
this.previewError = ''
|
||||
const documentId = this.previewDocId
|
||||
if (this.pageCountForDoc !== documentId) {
|
||||
this.pageCountForDoc = documentId
|
||||
this.previewPageCount = null
|
||||
this.documentService
|
||||
.get(this.previewDocId)
|
||||
.get(documentId)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (doc) => {
|
||||
if (this.previewDocId !== documentId) return
|
||||
this.previewPageCount = doc?.page_count ?? null
|
||||
if (doc && !this.previewDocModel) this.previewDocModel = doc
|
||||
this.changeDetector.markForCheck()
|
||||
},
|
||||
error: () => {
|
||||
if (this.previewDocId !== documentId) return
|
||||
this.previewPageCount = null
|
||||
this.pageCountForDoc = null
|
||||
this.previewStatus = 'error'
|
||||
this.previewError = $localize`Could not load the selected document.`
|
||||
this.changeDetector.markForCheck()
|
||||
},
|
||||
})
|
||||
@@ -354,13 +427,12 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
this.previewDocId,
|
||||
this.previewPage
|
||||
)
|
||||
this.imageLoaded = false
|
||||
}
|
||||
|
||||
goToPage(page: number) {
|
||||
if (!Number.isFinite(page)) return
|
||||
const max = this.previewPageCount ? this.previewPageCount - 1 : page
|
||||
const clamped = Math.max(0, Math.min(page, max))
|
||||
const clamped = Math.max(0, Math.min(Math.trunc(page), max))
|
||||
if (clamped === this.previewPage) return
|
||||
this.previewPage = clamped
|
||||
this.loadPreview()
|
||||
@@ -395,35 +467,74 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
onImageLoad() {
|
||||
this.imageLoaded = true
|
||||
if (this.previewStatus !== 'loading') return
|
||||
this.previewStatus = 'ready'
|
||||
const img = this.imageRef.nativeElement
|
||||
this.template.source_width = img.naturalWidth
|
||||
this.template.source_height = img.naturalHeight
|
||||
}
|
||||
|
||||
onOverlayMouseDown(event: MouseEvent) {
|
||||
onImageError() {
|
||||
this.stopOverlayInteraction()
|
||||
this.previewStatus = 'error'
|
||||
this.previewError = $localize`Could not load this page.`
|
||||
}
|
||||
|
||||
setOverlayMode(mode: OverlayMode) {
|
||||
this.stopOverlayInteraction()
|
||||
this.overlayMode = mode
|
||||
this.overlayCursor = mode === 'draw' ? 'crosshair' : 'default'
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
cancelDrawing() {
|
||||
this.setOverlayMode('select')
|
||||
}
|
||||
|
||||
onOverlayPointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0 || this.overlayInteraction.kind !== 'idle') return
|
||||
const point = this.svgPointFromEvent(event)
|
||||
if (!point) return
|
||||
if (!this.imageLoaded || !point) return
|
||||
event.preventDefault()
|
||||
|
||||
if (this.overlayMode === 'draw') {
|
||||
this.clearZoneTest()
|
||||
this.overlayInteraction = {
|
||||
kind: 'drawing',
|
||||
pointerId: event.pointerId,
|
||||
rect: {
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
endX: point.x,
|
||||
endY: point.y,
|
||||
},
|
||||
}
|
||||
this.selectedZoneIndex = null
|
||||
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.selectedZoneIndex !== null) {
|
||||
const handle = this.findHandleAt(point, this.selectedZoneIndex)
|
||||
if (handle) {
|
||||
this.overlayInteraction = {
|
||||
kind: 'resizing',
|
||||
pointerId: event.pointerId,
|
||||
zoneIndex: this.selectedZoneIndex,
|
||||
handle,
|
||||
}
|
||||
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const clickedIdx = this.findZoneAt(point)
|
||||
if (clickedIdx !== null && !event.shiftKey) {
|
||||
if (clickedIdx !== null) {
|
||||
this.selectZone(clickedIdx)
|
||||
const zone = this.template.zones[clickedIdx]
|
||||
this.overlayInteraction = {
|
||||
kind: 'moving',
|
||||
pointerId: event.pointerId,
|
||||
zoneIndex: clickedIdx,
|
||||
start: {
|
||||
mouseX: point.x,
|
||||
@@ -432,23 +543,20 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
zoneY: zone.y,
|
||||
},
|
||||
}
|
||||
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||
return
|
||||
}
|
||||
|
||||
// Shift+click or click on empty area starts a new zone.
|
||||
this.overlayInteraction = {
|
||||
kind: 'drawing',
|
||||
rect: {
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
endX: point.x,
|
||||
endY: point.y,
|
||||
},
|
||||
}
|
||||
this.clearZoneTest()
|
||||
this.selectedZoneIndex = null
|
||||
}
|
||||
|
||||
onOverlayMouseMove(event: MouseEvent) {
|
||||
onOverlayPointerMove(event: PointerEvent) {
|
||||
if (
|
||||
this.overlayInteraction.kind !== 'idle' &&
|
||||
this.overlayInteraction.pointerId !== event.pointerId
|
||||
)
|
||||
return
|
||||
const point = this.svgPointFromEvent(event)
|
||||
if (!point) return
|
||||
|
||||
@@ -482,6 +590,10 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private updateOverlayCursor(point: Point) {
|
||||
if (this.overlayMode === 'draw') {
|
||||
this.overlayCursor = 'crosshair'
|
||||
return
|
||||
}
|
||||
if (this.selectedZoneIndex !== null) {
|
||||
const handle = this.findHandleAt(point, this.selectedZoneIndex)
|
||||
if (handle) {
|
||||
@@ -489,30 +601,26 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
return
|
||||
}
|
||||
}
|
||||
this.overlayCursor = this.findZoneAt(point) !== null ? 'move' : 'crosshair'
|
||||
this.overlayCursor = this.findZoneAt(point) !== null ? 'move' : 'default'
|
||||
}
|
||||
|
||||
onOverlayMouseUp(_event: MouseEvent) {
|
||||
if (
|
||||
this.overlayInteraction.kind === 'moving' ||
|
||||
this.overlayInteraction.kind === 'resizing'
|
||||
) {
|
||||
this.stopOverlayInteraction()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.overlayInteraction.kind !== 'drawing') return
|
||||
const drawingRect = this.overlayInteraction.rect
|
||||
onOverlayPointerUp(event: PointerEvent) {
|
||||
if (this.overlayInteraction.kind === 'idle') return
|
||||
if (this.overlayInteraction.pointerId !== event.pointerId) return
|
||||
this.onOverlayPointerMove(event)
|
||||
const interaction = this.overlayInteraction
|
||||
this.stopOverlayInteraction()
|
||||
if (interaction.kind !== 'drawing') return
|
||||
|
||||
const rect = this.sourceRectFromDrawing(drawingRect)
|
||||
const rect = this.sourceRectFromDrawing(interaction.rect)
|
||||
|
||||
// Ignore tiny accidental clicks.
|
||||
if (rect.w < MIN_DRAWN_ZONE_SIZE || rect.h < MIN_DRAWN_ZONE_SIZE) {
|
||||
if (rect.w < MIN_ZONE_SIZE || rect.h < MIN_ZONE_SIZE) {
|
||||
return
|
||||
}
|
||||
|
||||
this.template.zones.push(this.createZoneFromRect(rect))
|
||||
this.setOverlayMode('select')
|
||||
this.selectZone(this.template.zones.length - 1)
|
||||
}
|
||||
|
||||
@@ -541,15 +649,23 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
return this.customFields[0]?.id ?? null
|
||||
}
|
||||
|
||||
@HostListener('document:mouseup')
|
||||
onDocumentMouseUp() {
|
||||
onOverlayPointerCancel(event: PointerEvent) {
|
||||
if (this.overlayInteraction.kind === 'idle') return
|
||||
if (this.overlayInteraction.pointerId !== event.pointerId) return
|
||||
this.stopOverlayInteraction()
|
||||
}
|
||||
|
||||
private stopOverlayInteraction() {
|
||||
const interaction = this.overlayInteraction
|
||||
this.overlayInteraction = NO_OVERLAY_INTERACTION
|
||||
this.overlayCursor = 'crosshair'
|
||||
this.overlayCursor = this.overlayMode === 'draw' ? 'crosshair' : 'default'
|
||||
const overlay = this.overlayRef?.nativeElement
|
||||
if (
|
||||
interaction.kind !== 'idle' &&
|
||||
overlay?.hasPointerCapture(interaction.pointerId)
|
||||
) {
|
||||
overlay.releasePointerCapture(interaction.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
drawingRect(): DisplayRect | null {
|
||||
@@ -646,9 +762,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
overlayUnitSize(screenPixels: number): number {
|
||||
const img = this.imageRef?.nativeElement
|
||||
if (!img?.naturalWidth || !img.clientWidth) return screenPixels
|
||||
return (screenPixels * img.naturalWidth) / img.clientWidth
|
||||
return screenPixels * this.overlayScale()
|
||||
}
|
||||
|
||||
private svgPointFromEvent(event: MouseEvent): Point | null {
|
||||
@@ -661,7 +775,11 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
point.y = event.clientY
|
||||
|
||||
const svgPoint = point.matrixTransform(matrix.inverse())
|
||||
return { x: svgPoint.x, y: svgPoint.y }
|
||||
const size = this.imageNaturalSize()
|
||||
return {
|
||||
x: Math.max(0, Math.min(svgPoint.x, size.width)),
|
||||
y: Math.max(0, Math.min(svgPoint.y, size.height)),
|
||||
}
|
||||
}
|
||||
|
||||
private displayRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||
@@ -689,6 +807,8 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
removeZone(index: number) {
|
||||
this.stopOverlayInteraction()
|
||||
this.clearZoneTest()
|
||||
this.template.zones.splice(index, 1)
|
||||
if (this.selectedZoneIndex === index) {
|
||||
this.selectedZoneIndex = null
|
||||
@@ -700,7 +820,7 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
selectZone(index: number) {
|
||||
this.selectedZoneIndex = index
|
||||
this.activeTab = 'zone'
|
||||
this.zoneTestResult = null
|
||||
this.clearZoneTest()
|
||||
const zone = this.template.zones[index]
|
||||
if (zone) {
|
||||
this.seedCombineDefault(zone)
|
||||
@@ -711,27 +831,51 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
testZone() {
|
||||
const zone = this.selectedZone
|
||||
if (!zone || !this.previewDocId) return
|
||||
this.zoneTesting = true
|
||||
this.zoneTestResult = null
|
||||
this.clearZoneTest()
|
||||
const test: ZoneTest = {
|
||||
zone,
|
||||
documentId: this.previewDocId,
|
||||
request: this.zoneTestRequest(zone),
|
||||
result: null,
|
||||
}
|
||||
this.zoneTest = test
|
||||
this.templateService
|
||||
.testZone(this.previewDocId, this.zoneTestRequest(zone))
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.testZone(test.documentId, test.request)
|
||||
.pipe(takeUntil(this.destroy$), takeUntil(this.cancelZoneTest$))
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.zoneTestResult = res
|
||||
this.zoneTesting = false
|
||||
test.result = res
|
||||
this.changeDetector.markForCheck()
|
||||
},
|
||||
error: (err) => {
|
||||
this.zoneTestResult = {
|
||||
test.result = {
|
||||
error: err.error?.error || $localize`Test failed`,
|
||||
}
|
||||
this.zoneTesting = false
|
||||
this.changeDetector.markForCheck()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private clearZoneTest() {
|
||||
this.cancelZoneTest$.next()
|
||||
this.zoneTest = null
|
||||
}
|
||||
|
||||
private isCurrentZoneTest(): boolean {
|
||||
const test = this.zoneTest
|
||||
if (
|
||||
!test ||
|
||||
test.zone !== this.selectedZone ||
|
||||
test.documentId !== this.previewDocId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const request = this.zoneTestRequest(test.zone)
|
||||
return (Object.keys(request) as (keyof ZoneTestRequest)[]).every(
|
||||
(key) => request[key] === test.request[key]
|
||||
)
|
||||
}
|
||||
|
||||
private zoneTestRequest(zone: OcrTemplateZone): ZoneTestRequest {
|
||||
return {
|
||||
name: zone.name,
|
||||
@@ -975,6 +1119,9 @@ export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.previewResizeObserver?.disconnect()
|
||||
this.clearZoneTest()
|
||||
this.cancelZoneTest$.complete()
|
||||
this.destroy$.next()
|
||||
this.destroy$.complete()
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
clockHistory,
|
||||
cpu,
|
||||
creditCard,
|
||||
cursor,
|
||||
dash,
|
||||
dashCircle,
|
||||
diagram3,
|
||||
@@ -338,6 +339,7 @@ const icons = {
|
||||
cpu,
|
||||
cash,
|
||||
creditCard,
|
||||
cursor,
|
||||
dash,
|
||||
dashCircle,
|
||||
diagram3,
|
||||
|
||||
Reference in new issue
Block a user