mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Merge branch 'dev' into feature-notification-wf-action
This commit is contained in:
@@ -47,14 +47,19 @@
|
||||
</tr>
|
||||
}
|
||||
@for (document of documentsInTrash; track document.id) {
|
||||
<tr (click)="toggleSelected(document); $event.stopPropagation();">
|
||||
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()">
|
||||
<td>
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="{{document.id}}"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row">{{ document.title }}</td>
|
||||
<td scope="row">
|
||||
{{ document.title }}
|
||||
<pngx-preview-popup [document]="document" linkClasses="btn btn-sm btn-link" #popupPreview>
|
||||
<i-bs name="eye"></i-bs>
|
||||
</pngx-preview-popup>
|
||||
</td>
|
||||
<td scope="row" i18n>{{ getDaysRemaining(document) }} days</td>
|
||||
<td scope="row">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
|
@@ -1,6 +1,6 @@
|
||||
.pdf-viewer-container {
|
||||
background-color: gray;
|
||||
height: 350px;
|
||||
height: 550px;
|
||||
|
||||
pdf-viewer {
|
||||
width: 100%;
|
||||
|
@@ -6,7 +6,7 @@
|
||||
<div class="modal-body">
|
||||
<p>{{message}}</p>
|
||||
<div class="row mb-2">
|
||||
<div class="col-8">
|
||||
<div class="col-7">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
|
||||
@@ -21,7 +21,7 @@
|
||||
</pdf-viewer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="col-5">
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="!canSplit">
|
||||
<i-bs name="plus-circle"></i-bs>
|
||||
@@ -44,12 +44,12 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check form-switch mt-4">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="form-check form-switch me-auto">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalSwitch" [(ngModel)]="deleteOriginal" [disabled]="!userOwnsDocument">
|
||||
<label class="form-check-label" for="deleteOriginalSwitch" i18n>Delete original document after successful split</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||
</button>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
.pdf-viewer-container {
|
||||
background-color: gray;
|
||||
height: 350px;
|
||||
height: 500px;
|
||||
|
||||
pdf-viewer {
|
||||
width: 100%;
|
||||
|
@@ -38,7 +38,15 @@
|
||||
@for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) {
|
||||
@if (allowSelectNone || item.id) {
|
||||
<pngx-toggleable-dropdown-button
|
||||
[item]="item" [hideCount]="hideCount(item)" [state]="selectionModel.get(item.id)" [count]="getUpdatedDocumentCount(item.id)" (toggled)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" (click)="setButtonItemIndex(i - 1)" [disabled]="disabled">
|
||||
[item]="item"
|
||||
[hideCount]="hideCount(item)"
|
||||
[opacifyCount]="!editing"
|
||||
[state]="selectionModel.get(item.id)"
|
||||
[count]="getUpdatedDocumentCount(item.id)"
|
||||
(toggled)="selectionModel.toggle(item.id)"
|
||||
(exclude)="excludeClicked(item.id)"
|
||||
(click)="setButtonItemIndex(i - 1)"
|
||||
[disabled]="disabled">
|
||||
</pngx-toggleable-dropdown-button>
|
||||
}
|
||||
}
|
||||
|
@@ -509,6 +509,37 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
])
|
||||
})
|
||||
|
||||
it('selection model should sort items by state and document counts, if set', () => {
|
||||
component.items = items.concat([{ id: 4, name: 'Item D' }])
|
||||
component.selectionModel = selectionModel
|
||||
component.documentCounts = [
|
||||
{ id: 1, document_count: 0 }, // Tag1
|
||||
{ id: 2, document_count: 1 }, // Tag2
|
||||
{ id: 4, document_count: 2 },
|
||||
]
|
||||
component.selectionModel.apply()
|
||||
expect(selectionModel.items).toEqual([
|
||||
nullItem,
|
||||
{ id: 4, name: 'Item D' },
|
||||
items[1], // Tag2
|
||||
items[0], // Tag1
|
||||
])
|
||||
|
||||
selectionModel.toggle(items[1].id)
|
||||
component.documentCounts = [
|
||||
{ id: 1, document_count: 0 },
|
||||
{ id: 2, document_count: 1 },
|
||||
{ id: 4, document_count: 0 },
|
||||
]
|
||||
selectionModel.apply()
|
||||
expect(selectionModel.items).toEqual([
|
||||
nullItem,
|
||||
items[1], // Tag2
|
||||
{ id: 4, name: 'Item D' },
|
||||
items[0], // Tag1
|
||||
])
|
||||
})
|
||||
|
||||
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
|
@@ -43,6 +43,11 @@ export class FilterableDropdownSelectionModel {
|
||||
private _intersection: Intersection = Intersection.Include
|
||||
temporaryIntersection: Intersection = this._intersection
|
||||
|
||||
private _documentCounts: SelectionDataItem[] = []
|
||||
public set documentCounts(counts: SelectionDataItem[]) {
|
||||
this._documentCounts = counts
|
||||
}
|
||||
|
||||
private _items: MatchingModel[] = []
|
||||
get items(): MatchingModel[] {
|
||||
return this._items
|
||||
@@ -69,6 +74,16 @@ export class FilterableDropdownSelectionModel {
|
||||
this.getNonTemporary(b.id) == ToggleableItemState.NotSelected
|
||||
) {
|
||||
return -1
|
||||
} else if (
|
||||
this._documentCounts.length &&
|
||||
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
||||
) {
|
||||
return -1
|
||||
} else if (
|
||||
this._documentCounts.length &&
|
||||
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
||||
) {
|
||||
return 1
|
||||
} else {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
@@ -286,6 +301,10 @@ export class FilterableDropdownSelectionModel {
|
||||
)
|
||||
}
|
||||
|
||||
getDocumentCount(id: number) {
|
||||
return this._documentCounts.find((c) => c.id === id)?.document_count
|
||||
}
|
||||
|
||||
init(map: Map<number, ToggleableItemState>) {
|
||||
this.temporarySelectionStates = map
|
||||
this.apply()
|
||||
@@ -431,7 +450,11 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
@Input()
|
||||
documentCounts: SelectionDataItem[]
|
||||
set documentCounts(counts: SelectionDataItem[]) {
|
||||
if (counts) {
|
||||
this.selectionModel.documentCounts = counts
|
||||
}
|
||||
}
|
||||
|
||||
@Input()
|
||||
shortcutKey: string
|
||||
@@ -544,9 +567,7 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
getUpdatedDocumentCount(id: number) {
|
||||
if (this.documentCounts) {
|
||||
return this.documentCounts.find((c) => c.id === id)?.document_count
|
||||
}
|
||||
return this.selectionModel.getDocumentCount(id)
|
||||
}
|
||||
|
||||
listKeyDown(event: KeyboardEvent) {
|
||||
|
@@ -1,4 +1,9 @@
|
||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="toggleItem($event)" [disabled]="disabled">
|
||||
<button
|
||||
class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom"
|
||||
[class.opacity-50]="opacifyCount && !hideCount && currentCount === 0"
|
||||
role="menuitem"
|
||||
(click)="toggleItem($event)"
|
||||
[disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (isChecked()) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
@@ -18,6 +23,6 @@
|
||||
}
|
||||
</div>
|
||||
@if (!hideCount) {
|
||||
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{count ?? item.document_count}}</div>
|
||||
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
|
||||
}
|
||||
</button>
|
||||
|
@@ -29,6 +29,9 @@ export class ToggleableDropdownButtonComponent {
|
||||
@Input()
|
||||
hideCount: boolean = false
|
||||
|
||||
@Input()
|
||||
opacifyCount: boolean = true
|
||||
|
||||
@Output()
|
||||
toggled = new EventEmitter()
|
||||
|
||||
@@ -39,6 +42,10 @@ export class ToggleableDropdownButtonComponent {
|
||||
return 'is_inbox_tag' in this.item
|
||||
}
|
||||
|
||||
get currentCount(): number {
|
||||
return this.count ?? this.item.document_count
|
||||
}
|
||||
|
||||
toggleItem(event: MouseEvent): void {
|
||||
if (this.state == ToggleableItemState.Selected) {
|
||||
this.exclude.emit()
|
||||
|
@@ -1,30 +1,37 @@
|
||||
<div class="preview-popup-container">
|
||||
@if (error) {
|
||||
<div class="w-100 h-100 position-relative">
|
||||
<p class="fst-italic position-absolute top-50 start-50 translate-middle" i18n>Error loading preview</p>
|
||||
</div>
|
||||
} @else {
|
||||
@if (renderAsObject) {
|
||||
@if (previewText) {
|
||||
<div class="bg-light p-3 overflow-auto whitespace-preserve" width="100%">{{previewText}}</div>
|
||||
} @else {
|
||||
<object [data]="previewURL | safeUrl" width="100%" class="bg-light" [class.p-2]="!isPdf"></object>
|
||||
}
|
||||
<a [href]="link ?? previewUrl" class="{{linkClasses}}" [target]="linkTarget" [title]="linkTitle"
|
||||
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle" container="body"
|
||||
autoClose="true" [popoverClass]="popoverClass" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
||||
<ng-content></ng-content>
|
||||
</a>
|
||||
<ng-template #previewContent>
|
||||
<div class="preview-popup-container">
|
||||
@if (error) {
|
||||
<div class="w-100 h-100 position-relative">
|
||||
<p class="fst-italic position-absolute top-50 start-50 translate-middle" i18n>Error loading preview</p>
|
||||
</div>
|
||||
} @else {
|
||||
@if (requiresPassword) {
|
||||
<div class="w-100 h-100 position-relative">
|
||||
<i-bs width="2em" height="2em" class="position-absolute top-50 start-50 translate-middle" name="file-earmark-lock"></i-bs>
|
||||
</div>
|
||||
}
|
||||
@if (!requiresPassword) {
|
||||
<pdf-viewer
|
||||
[src]="previewURL"
|
||||
[original-size]="false"
|
||||
[show-borders]="false"
|
||||
[show-all]="true"
|
||||
(error)="onError($event)">
|
||||
</pdf-viewer>
|
||||
@if (renderAsObject) {
|
||||
@if (previewText) {
|
||||
<div class="bg-light p-3 overflow-auto whitespace-preserve" width="100%">{{previewText}}</div>
|
||||
} @else {
|
||||
<object [data]="previewURL | safeUrl" width="100%" class="bg-light" [class.p-2]="!isPdf"></object>
|
||||
}
|
||||
} @else {
|
||||
@if (requiresPassword) {
|
||||
<div class="w-100 h-100 position-relative">
|
||||
<i-bs width="2em" height="2em" class="position-absolute top-50 start-50 translate-middle" name="file-earmark-lock"></i-bs>
|
||||
</div>
|
||||
}
|
||||
@if (!requiresPassword) {
|
||||
<pdf-viewer
|
||||
[src]="previewURL"
|
||||
[original-size]="false"
|
||||
[show-borders]="false"
|
||||
[show-all]="true"
|
||||
(error)="onError($event)">
|
||||
</pdf-viewer>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
@@ -1,4 +1,9 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
fakeAsync,
|
||||
TestBed,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
|
||||
import { PreviewPopupComponent } from './preview-popup.component'
|
||||
import { By } from '@angular/platform-browser'
|
||||
@@ -15,6 +20,8 @@ import {
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
|
||||
const doc = {
|
||||
id: 10,
|
||||
@@ -34,8 +41,12 @@ describe('PreviewPopupComponent', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [PreviewPopupComponent, SafeUrlPipe],
|
||||
imports: [NgxBootstrapIconsModule.pick(allIcons), PdfViewerModule],
|
||||
declarations: [PreviewPopupComponent, SafeUrlPipe, DocumentTitlePipe],
|
||||
imports: [
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
PdfViewerModule,
|
||||
NgbPopoverModule,
|
||||
],
|
||||
providers: [
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
@@ -70,12 +81,14 @@ describe('PreviewPopupComponent', () => {
|
||||
|
||||
it('should render object if native PDF viewer enabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
||||
component.popover.open()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should render pngx viewer if native PDF viewer disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||
component.popover.open()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
|
||||
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
|
||||
@@ -83,6 +96,7 @@ describe('PreviewPopupComponent', () => {
|
||||
|
||||
it('should show lock icon on password error', () => {
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||
component.popover.open()
|
||||
component.onError({ name: 'PasswordException' })
|
||||
fixture.detectChanges()
|
||||
expect(component.requiresPassword).toBeTruthy()
|
||||
@@ -93,16 +107,18 @@ describe('PreviewPopupComponent', () => {
|
||||
component.document.original_file_name = 'sample.png'
|
||||
component.document.mime_type = 'image/png'
|
||||
component.document.archived_file_name = undefined
|
||||
component.popover.open()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should show message on error', () => {
|
||||
component.popover.open()
|
||||
component.onError({})
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain(
|
||||
'Error loading preview'
|
||||
)
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.popover')).nativeElement.textContent
|
||||
).toContain('Error loading preview')
|
||||
})
|
||||
|
||||
it('should get text content from http if appropriate', () => {
|
||||
@@ -122,4 +138,17 @@ describe('PreviewPopupComponent', () => {
|
||||
component.init()
|
||||
expect(component.previewText).toEqual('Preview text')
|
||||
})
|
||||
|
||||
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
|
||||
component.mouseEnterPreview()
|
||||
expect(component.popover.isOpen()).toBeTruthy()
|
||||
tick(600)
|
||||
component.close()
|
||||
|
||||
component.mouseEnterPreview()
|
||||
tick(100)
|
||||
component.mouseLeavePreview()
|
||||
tick(600)
|
||||
expect(component.popover.isOpen()).toBeFalsy()
|
||||
}))
|
||||
})
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Component, Input, OnDestroy } from '@angular/core'
|
||||
import { Component, Input, OnDestroy, ViewChild } from '@angular/core'
|
||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { first, Subject, takeUntil } from 'rxjs'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
@@ -23,6 +24,18 @@ export class PreviewPopupComponent implements OnDestroy {
|
||||
return this._document
|
||||
}
|
||||
|
||||
@Input()
|
||||
link: string
|
||||
|
||||
@Input()
|
||||
linkClasses: string = 'btn btn-sm btn-outline-secondary'
|
||||
|
||||
@Input()
|
||||
linkTarget: string = '_blank'
|
||||
|
||||
@Input()
|
||||
linkTitle: string = $localize`Open preview`
|
||||
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
error = false
|
||||
@@ -31,6 +44,12 @@ export class PreviewPopupComponent implements OnDestroy {
|
||||
|
||||
previewText: string
|
||||
|
||||
@ViewChild('popover') popover: NgbPopover
|
||||
|
||||
mouseOnPreview: boolean
|
||||
|
||||
popoverClass: string = 'shadow popover-preview'
|
||||
|
||||
get renderAsObject(): boolean {
|
||||
return (this.isPdf && this.useNativePdfViewer) || !this.isPdf
|
||||
}
|
||||
@@ -83,4 +102,33 @@ export class PreviewPopupComponent implements OnDestroy {
|
||||
this.error = true
|
||||
}
|
||||
}
|
||||
|
||||
get previewUrl() {
|
||||
return this.documentService.getPreviewUrl(this.document.id)
|
||||
}
|
||||
|
||||
mouseEnterPreview() {
|
||||
this.mouseOnPreview = true
|
||||
if (!this.popover.isOpen()) {
|
||||
// we're going to open but hide to pre-load content during hover delay
|
||||
this.popover.open()
|
||||
this.popoverClass = 'shadow popover-preview pe-none opacity-0'
|
||||
setTimeout(() => {
|
||||
if (this.mouseOnPreview) {
|
||||
// show popover
|
||||
this.popoverClass = this.popoverClass.replace('pe-none opacity-0', '')
|
||||
} else {
|
||||
this.popover.close()
|
||||
}
|
||||
}, 600)
|
||||
}
|
||||
}
|
||||
|
||||
mouseLeavePreview() {
|
||||
this.mouseOnPreview = false
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.popover.close(false)
|
||||
}
|
||||
}
|
||||
|
@@ -388,6 +388,15 @@
|
||||
<img [src]="previewUrl | safeUrl" width="100%" height="100%" alt="{{title}}" />
|
||||
</div>
|
||||
}
|
||||
@case (ContentRenderType.TIFF) {
|
||||
@if (!tiffError) {
|
||||
<div class="preview-sticky">
|
||||
<img [src]="tiffURL" width="100%" height="100%" alt="{{title}}" />
|
||||
</div>
|
||||
} @else {
|
||||
<div class="preview-sticky bg-light p-3 overflow-auto whitespace-preserve" width="100%">{{tiffError}}</div>
|
||||
}
|
||||
}
|
||||
@case (ContentRenderType.Other) {
|
||||
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
||||
}
|
||||
|
@@ -61,6 +61,7 @@ textarea.rtl {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: top;
|
||||
}
|
||||
|
||||
.thumb-preview {
|
||||
|
@@ -1270,4 +1270,46 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(component.createDisabled(DataType.StoragePath)).toBeFalsy()
|
||||
expect(component.createDisabled(DataType.Tag)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should call tryRenderTiff when no archive and file is tiff', () => {
|
||||
initNormally()
|
||||
const tiffRenderSpy = jest.spyOn(
|
||||
DocumentDetailComponent.prototype as any,
|
||||
'tryRenderTiff'
|
||||
)
|
||||
const doc = Object.assign({}, component.document)
|
||||
doc.archived_file_name = null
|
||||
doc.mime_type = 'image/tiff'
|
||||
jest
|
||||
.spyOn(documentService, 'getMetadata')
|
||||
.mockReturnValue(
|
||||
of({ has_archive_version: false, original_mime_type: 'image/tiff' })
|
||||
)
|
||||
component.updateComponent(doc)
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.TIFF
|
||||
)
|
||||
expect(tiffRenderSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should try to render tiff and show error if failed', () => {
|
||||
initNormally()
|
||||
// just the text request
|
||||
httpTestingController.expectOne(component.previewUrl)
|
||||
|
||||
// invalid tiff
|
||||
component['tryRenderTiff']()
|
||||
httpTestingController
|
||||
.expectOne(component.previewUrl)
|
||||
.flush(new ArrayBuffer(100)) // arraybuffer
|
||||
expect(component.tiffError).not.toBeUndefined()
|
||||
|
||||
// http error
|
||||
component['tryRenderTiff']()
|
||||
httpTestingController
|
||||
.expectOne(component.previewUrl)
|
||||
.error(new ErrorEvent('failed'))
|
||||
expect(component.tiffError).not.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
@@ -72,6 +72,7 @@ import { DeletePagesConfirmDialogComponent } from '../common/confirm-dialog/dele
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
|
||||
import { DataType } from 'src/app/data/datatype'
|
||||
import * as UTIF from 'utif'
|
||||
|
||||
enum DocumentDetailNavIDs {
|
||||
Details = 1,
|
||||
@@ -89,6 +90,7 @@ enum ContentRenderType {
|
||||
Text = 'text',
|
||||
Other = 'other',
|
||||
Unknown = 'unknown',
|
||||
TIFF = 'tiff',
|
||||
}
|
||||
|
||||
enum ZoomSetting {
|
||||
@@ -136,6 +138,8 @@ export class DocumentDetailComponent
|
||||
downloadUrl: string
|
||||
downloadOriginalUrl: string
|
||||
previewLoaded: boolean = false
|
||||
tiffURL: string
|
||||
tiffError: string
|
||||
|
||||
correspondents: Correspondent[]
|
||||
documentTypes: DocumentType[]
|
||||
@@ -244,6 +248,8 @@ export class DocumentDetailComponent
|
||||
['text/plain', 'application/csv', 'text/csv'].includes(mimeType)
|
||||
) {
|
||||
return ContentRenderType.Text
|
||||
} else if (mimeType.indexOf('tiff') >= 0) {
|
||||
return ContentRenderType.TIFF
|
||||
} else if (mimeType?.indexOf('image/') === 0) {
|
||||
return ContentRenderType.Image
|
||||
}
|
||||
@@ -542,6 +548,9 @@ export class DocumentDetailComponent
|
||||
this.document = doc
|
||||
this.requiresPassword = false
|
||||
this.updateFormForCustomFields()
|
||||
if (this.archiveContentRenderType === ContentRenderType.TIFF) {
|
||||
this.tryRenderTiff()
|
||||
}
|
||||
this.documentsService
|
||||
.getMetadata(doc.id)
|
||||
.pipe(
|
||||
@@ -721,6 +730,7 @@ export class DocumentDetailComponent
|
||||
|
||||
save(close: boolean = false) {
|
||||
this.networkActive = true
|
||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||
this.documentsService
|
||||
.update(this.document)
|
||||
.pipe(first())
|
||||
@@ -1163,6 +1173,7 @@ export class DocumentDetailComponent
|
||||
splitDocument() {
|
||||
let modal = this.modalService.open(SplitConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Split confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
|
||||
@@ -1201,6 +1212,7 @@ export class DocumentDetailComponent
|
||||
rotateDocument() {
|
||||
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Rotate confirm`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
|
||||
@@ -1275,4 +1287,45 @@ export class DocumentDetailComponent
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private tryRenderTiff() {
|
||||
this.http.get(this.previewUrl, { responseType: 'arraybuffer' }).subscribe({
|
||||
next: (res) => {
|
||||
/* istanbul ignore next */
|
||||
try {
|
||||
// See UTIF.js > _imgLoaded
|
||||
const tiffIfds: any[] = UTIF.decode(res)
|
||||
var vsns = tiffIfds,
|
||||
ma = 0,
|
||||
page = vsns[0]
|
||||
if (tiffIfds[0].subIFD) vsns = vsns.concat(tiffIfds[0].subIFD)
|
||||
for (var i = 0; i < vsns.length; i++) {
|
||||
var img = vsns[i]
|
||||
if (img['t258'] == null || img['t258'].length < 3) continue
|
||||
var ar = img['t256'] * img['t257']
|
||||
if (ar > ma) {
|
||||
ma = ar
|
||||
page = img
|
||||
}
|
||||
}
|
||||
UTIF.decodeImage(res, page, tiffIfds)
|
||||
const rgba = UTIF.toRGBA8(page)
|
||||
const { width: w, height: h } = page
|
||||
var cnv = document.createElement('canvas')
|
||||
cnv.width = w
|
||||
cnv.height = h
|
||||
var ctx = cnv.getContext('2d'),
|
||||
imgd = ctx.createImageData(w, h)
|
||||
for (var i = 0; i < rgba.length; i++) imgd.data[i] = rgba[i]
|
||||
ctx.putImageData(imgd, 0, 0)
|
||||
this.tiffURL = cnv.toDataURL()
|
||||
} catch (err) {
|
||||
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.tiffError = $localize`An error occurred loading tiff: ${err.toString()}`
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -782,11 +782,11 @@ export class BulkEditorComponent
|
||||
rotateSelected() {
|
||||
let modal = this.modalService.open(RotateConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
|
||||
rotateDialog.title = $localize`Rotate confirm`
|
||||
rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
|
||||
rotateDialog.message = $localize`This will alter the original copy.`
|
||||
rotateDialog.btnClass = 'btn-danger'
|
||||
rotateDialog.btnCaption = $localize`Proceed`
|
||||
rotateDialog.documentID = Array.from(this.list.selected)[0]
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<div class="card mb-3 shadow-sm bg-light" [class.card-selected]="selected" [class.document-card]="selectable" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
|
||||
<div class="card mb-3 shadow-sm bg-light" [class.card-selected]="selected" [class.document-card]="selectable" (mouseleave)="mouseLeaveCard()">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
|
||||
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
|
||||
@@ -56,14 +56,9 @@
|
||||
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<i-bs name="file-earmark-richtext"></i-bs> <span class="d-none d-md-inline" i18n>Open</span>
|
||||
</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
|
||||
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
|
||||
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
||||
<pngx-preview-popup [document]="document" #popupPreview>
|
||||
<i-bs name="eye"></i-bs> <span class="d-none d-md-inline" i18n>View</span>
|
||||
</a>
|
||||
<ng-template #previewContent>
|
||||
<pngx-preview-popup [document]="document"></pngx-preview-popup>
|
||||
</ng-template>
|
||||
</pngx-preview-popup>
|
||||
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
|
||||
<i-bs name="download"></i-bs> <span class="d-none d-md-inline" i18n>Download</span>
|
||||
</a>
|
||||
|
@@ -1,11 +1,6 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
@@ -84,21 +79,6 @@ describe('DocumentCardLargeComponent', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('8 pages')
|
||||
})
|
||||
|
||||
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
|
||||
component.mouseEnterPreview()
|
||||
expect(component.popover.isOpen()).toBeTruthy()
|
||||
expect(component.popoverHidden).toBeTruthy()
|
||||
tick(600)
|
||||
expect(component.popoverHidden).toBeFalsy()
|
||||
component.mouseLeaveCard()
|
||||
|
||||
component.mouseEnterPreview()
|
||||
tick(100)
|
||||
component.mouseLeavePreview()
|
||||
tick(600)
|
||||
expect(component.popover.isOpen()).toBeFalsy()
|
||||
}))
|
||||
|
||||
it('should trim content', () => {
|
||||
expect(component.contentTrimmed).toHaveLength(503) // includes ...
|
||||
})
|
||||
|
@@ -12,9 +12,9 @@ import {
|
||||
} from 'src/app/data/document'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-card-large',
|
||||
@@ -65,7 +65,7 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
|
||||
@Output()
|
||||
clickMoreLike = new EventEmitter()
|
||||
|
||||
@ViewChild('popover') popover: NgbPopover
|
||||
@ViewChild('popupPreview') popupPreview: PreviewPopupComponent
|
||||
|
||||
mouseOnPreview = false
|
||||
popoverHidden = true
|
||||
@@ -112,29 +112,8 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
|
||||
return this.documentService.getPreviewUrl(this.document.id)
|
||||
}
|
||||
|
||||
mouseEnterPreview() {
|
||||
this.mouseOnPreview = true
|
||||
if (!this.popover.isOpen()) {
|
||||
// we're going to open but hide to pre-load content during hover delay
|
||||
this.popover.open()
|
||||
this.popoverHidden = true
|
||||
setTimeout(() => {
|
||||
if (this.mouseOnPreview) {
|
||||
// show popover
|
||||
this.popoverHidden = false
|
||||
} else {
|
||||
this.popover.close()
|
||||
}
|
||||
}, 600)
|
||||
}
|
||||
}
|
||||
|
||||
mouseLeavePreview() {
|
||||
this.mouseOnPreview = false
|
||||
}
|
||||
|
||||
mouseLeaveCard() {
|
||||
this.popover.close()
|
||||
this.popupPreview.close()
|
||||
}
|
||||
|
||||
get contentTrimmed() {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<div class="col p-2 h-100">
|
||||
<div class="card h-100 shadow-sm document-card" [class.card-selected]="selected" [class.popover-hidden]="popoverHidden" (mouseleave)="mouseLeaveCard()">
|
||||
<div class="card h-100 shadow-sm document-card" [class.card-selected]="selected" (mouseleave)="mouseLeaveCard()">
|
||||
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
|
||||
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
|
||||
|
||||
@@ -129,14 +129,9 @@
|
||||
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n-title>
|
||||
<i-bs name="file-earmark-richtext"></i-bs>
|
||||
</a>
|
||||
<a [href]="previewUrl" target="_blank" class="btn btn-sm btn-outline-secondary"
|
||||
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"
|
||||
autoClose="true" popoverClass="shadow popover-preview" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()" #popover="ngbPopover">
|
||||
<pngx-preview-popup [document]="document" #popupPreview>
|
||||
<i-bs name="eye"></i-bs>
|
||||
</a>
|
||||
<ng-template #previewContent>
|
||||
<pngx-preview-popup [document]="document"></pngx-preview-popup>
|
||||
</ng-template>
|
||||
</pngx-preview-popup>
|
||||
<a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" i18n-title (click)="$event.stopPropagation()">
|
||||
<i-bs name="download"></i-bs>
|
||||
</a>
|
||||
|
@@ -1,11 +1,6 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
NgbPopoverModule,
|
||||
@@ -116,19 +111,4 @@ describe('DocumentCardSmallComponent', () => {
|
||||
fixture.debugElement.queryAll(By.directive(TagComponent))
|
||||
).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
|
||||
component.mouseEnterPreview()
|
||||
expect(component.popover.isOpen()).toBeTruthy()
|
||||
expect(component.popoverHidden).toBeTruthy()
|
||||
tick(600)
|
||||
expect(component.popoverHidden).toBeFalsy()
|
||||
component.mouseLeaveCard()
|
||||
|
||||
component.mouseEnterPreview()
|
||||
tick(100)
|
||||
component.mouseLeavePreview()
|
||||
tick(600)
|
||||
expect(component.popover.isOpen()).toBeFalsy()
|
||||
}))
|
||||
})
|
||||
|
@@ -13,9 +13,9 @@ import {
|
||||
} from 'src/app/data/document'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-card-small',
|
||||
@@ -61,10 +61,7 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
|
||||
|
||||
moreTags: number = null
|
||||
|
||||
@ViewChild('popover') popover: NgbPopover
|
||||
|
||||
mouseOnPreview = false
|
||||
popoverHidden = true
|
||||
@ViewChild('popupPreview') popupPreview: PreviewPopupComponent
|
||||
|
||||
getIsThumbInverted() {
|
||||
return this.settingsService.get(SETTINGS_KEYS.DARK_MODE_THUMB_INVERTED)
|
||||
@@ -78,10 +75,6 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
|
||||
return this.documentService.getDownloadUrl(this.document.id)
|
||||
}
|
||||
|
||||
get previewUrl() {
|
||||
return this.documentService.getPreviewUrl(this.document.id)
|
||||
}
|
||||
|
||||
get privateName() {
|
||||
return $localize`Private`
|
||||
}
|
||||
@@ -100,29 +93,8 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
|
||||
)
|
||||
}
|
||||
|
||||
mouseEnterPreview() {
|
||||
this.mouseOnPreview = true
|
||||
if (!this.popover.isOpen()) {
|
||||
// we're going to open but hide to pre-load content during hover delay
|
||||
this.popover.open()
|
||||
this.popoverHidden = true
|
||||
setTimeout(() => {
|
||||
if (this.mouseOnPreview) {
|
||||
// show popover
|
||||
this.popoverHidden = false
|
||||
} else {
|
||||
this.popover.close()
|
||||
}
|
||||
}, 600)
|
||||
}
|
||||
}
|
||||
|
||||
mouseLeavePreview() {
|
||||
this.mouseOnPreview = false
|
||||
}
|
||||
|
||||
mouseLeaveCard() {
|
||||
this.popover.close()
|
||||
this.popupPreview.close()
|
||||
}
|
||||
|
||||
get notesEnabled(): boolean {
|
||||
|
@@ -292,7 +292,12 @@
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||
<td width="30%">
|
||||
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
|
||||
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||
<div class="d-inline-block" (mouseleave)="popupPreview.close()">
|
||||
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
|
||||
<pngx-preview-popup [document]="d" linkClasses="btn btn-sm btn-link text-secondary" linkTitle="Preview document" (click)="$event.stopPropagation()" i18n-linkTitle #popupPreview>
|
||||
<i-bs name="eye"></i-bs>
|
||||
</pngx-preview-popup>
|
||||
</div>
|
||||
}
|
||||
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
|
||||
@for (t of d.tags$ | async; track t) {
|
||||
|
@@ -72,6 +72,7 @@ import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
|
||||
|
||||
const docs: Document[] = [
|
||||
{
|
||||
@@ -137,6 +138,7 @@ describe('DocumentListComponent', () => {
|
||||
UsernamePipe,
|
||||
SafeHtmlPipe,
|
||||
IsNumberPipe,
|
||||
PreviewPopupComponent,
|
||||
],
|
||||
imports: [
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
|
@@ -17,6 +17,8 @@ export enum GlobalSearchType {
|
||||
TITLE_CONTENT = 'title-content',
|
||||
}
|
||||
|
||||
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||
|
||||
export const SETTINGS_KEYS = {
|
||||
LANGUAGE: 'language',
|
||||
APP_LOGO: 'app_logo',
|
||||
|
@@ -17,7 +17,12 @@ import {
|
||||
hexToHsl,
|
||||
} from 'src/app/utils/color'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { UiSettings, SETTINGS, SETTINGS_KEYS } from '../data/ui-settings'
|
||||
import {
|
||||
UiSettings,
|
||||
SETTINGS,
|
||||
SETTINGS_KEYS,
|
||||
PAPERLESS_GREEN_HEX,
|
||||
} from '../data/ui-settings'
|
||||
import { User } from '../data/user'
|
||||
import {
|
||||
PermissionAction,
|
||||
@@ -420,7 +425,7 @@ export class SettingsService {
|
||||
)
|
||||
}
|
||||
|
||||
if (themeColor) {
|
||||
if (themeColor?.length) {
|
||||
const hsl = hexToHsl(themeColor)
|
||||
const bgBrightnessEstimate = estimateBrightnessForColor(themeColor)
|
||||
|
||||
@@ -445,6 +450,11 @@ export class SettingsService {
|
||||
document.documentElement.style.removeProperty('--pngx-primary')
|
||||
document.documentElement.style.removeProperty('--pngx-primary-lightness')
|
||||
}
|
||||
|
||||
this.meta.updateTag({
|
||||
name: 'theme-color',
|
||||
content: themeColor?.length ? themeColor : PAPERLESS_GREEN_HEX,
|
||||
})
|
||||
}
|
||||
|
||||
getLanguageOptions(): LanguageOption[] {
|
||||
|
@@ -564,11 +564,6 @@ table.table {
|
||||
}
|
||||
}
|
||||
|
||||
.popover-hidden .popover {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Tour
|
||||
.tour-active .popover {
|
||||
min-width: 360px;
|
||||
@@ -728,3 +723,27 @@ i-bs svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
// fixes for buttons in preview popup
|
||||
.btn-group pngx-preview-popup:not(:last-child) {
|
||||
// Prevent double borders when buttons are next to each other
|
||||
> .btn {
|
||||
margin-left: calc(#{$btn-border-width} * -1);
|
||||
}
|
||||
> .btn {
|
||||
@include border-end-radius(0);
|
||||
}
|
||||
}
|
||||
.btn-group pngx-preview-popup:not(:first-child) {
|
||||
> .btn {
|
||||
@include border-start-radius(0);
|
||||
}
|
||||
}
|
||||
.btn-group pngx-preview-popup {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
|
||||
> .btn {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user