mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Merge pull request #362 from shamoon/fix/issue-347
Allow shift-select in documents list
This commit is contained in:
		| @@ -1,11 +1,11 @@ | ||||
| <div class="card mb-3 shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable"> | ||||
| <div class="card mb-3 shadow-sm" [class.card-selected]="selected" [class.document-card]="selectable" (click)="this.toggleSelected.emit($event)"> | ||||
|   <div class="row no-gutters"> | ||||
|     <div class="col-md-2 d-none d-lg-block doc-img-background rounded-left" [class.doc-img-background-selected]="selected"> | ||||
|       <img [src]="getThumbUrl()" class="card-img doc-img border-right rounded-left" (click)="setSelected(selectable ? !selected : false)"> | ||||
|       <img [src]="getThumbUrl()" class="card-img doc-img border-right rounded-left"> | ||||
|  | ||||
|       <div style="top: 0; left: 0" class="position-absolute border-right border-bottom bg-light p-1" [class.document-card-check]="!selected"> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
|           <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (change)="setSelected($event.target.checked)"> | ||||
|           <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (click)="this.toggleSelected.emit($event)"> | ||||
|           <label class="custom-control-label" for="smallCardCheck{{document.id}}"></label> | ||||
|         </div> | ||||
|       </div> | ||||
| @@ -17,11 +17,11 @@ | ||||
|         <div class="d-flex justify-content-between align-items-center"> | ||||
|           <h5 class="card-title"> | ||||
|             <ng-container *ngIf="document.correspondent"> | ||||
|               <a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a> | ||||
|               <a *ngIf="clickCorrespondent.observers.length ; else nolink" [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a> | ||||
|               <ng-template #nolink>{{(document.correspondent$ | async)?.name}}</ng-template>: | ||||
|             </ng-container> | ||||
|             {{document.title | documentTitle}} | ||||
|             <app-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id)" [clickable]="clickTag.observers.length"></app-tag> | ||||
|             <app-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle *ngFor="let t of document.tags$ | async" class="ml-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></app-tag> | ||||
|           </h5> | ||||
|           <h5 class="card-title" *ngIf="document.archive_serial_number">#{{document.archive_serial_number}}</h5> | ||||
|         </div> | ||||
|   | ||||
| @@ -15,16 +15,11 @@ export class DocumentCardLargeComponent implements OnInit { | ||||
|   @Input() | ||||
|   selected = false | ||||
|  | ||||
|   setSelected(value: boolean) { | ||||
|     this.selected = value | ||||
|     this.selectedChange.emit(value) | ||||
|   } | ||||
|  | ||||
|   @Output() | ||||
|   selectedChange = new EventEmitter<boolean>() | ||||
|   toggleSelected = new EventEmitter() | ||||
|  | ||||
|   get selectable() { | ||||
|     return this.selectedChange.observers.length > 0 | ||||
|     return this.toggleSelected.observers.length > 0 | ||||
|   } | ||||
|  | ||||
|   @Input() | ||||
|   | ||||
| @@ -1,18 +1,18 @@ | ||||
| <div class="col p-2 h-100"> | ||||
|   <div class="card h-100 shadow-sm document-card" [class.card-selected]="selected"> | ||||
|   <div class="card h-100 shadow-sm document-card" [class.card-selected]="selected" (click)="this.toggleSelected.emit($event)"> | ||||
|     <div class="border-bottom doc-img-container" [class.doc-img-background-selected]="selected"> | ||||
|       <img class="card-img doc-img rounded-top" [src]="getThumbUrl()" (click)="setSelected(!selected)"> | ||||
|       <img class="card-img doc-img rounded-top" [src]="getThumbUrl()"> | ||||
|  | ||||
|       <div class="border-right border-bottom bg-light p-1 rounded document-card-check"> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
|           <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (change)="setSelected($event.target.checked)"> | ||||
|           <input type="checkbox" class="custom-control-input" id="smallCardCheck{{document.id}}" [checked]="selected" (click)="this.toggleSelected.emit($event)"> | ||||
|           <label class="custom-control-label" for="smallCardCheck{{document.id}}"></label> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <div style="top: 0; right: 0; font-size: large" class="text-right position-absolute mr-1"> | ||||
|         <div *ngFor="let t of getTagsLimited$() | async"> | ||||
|           <app-tag [tag]="t" (click)="clickTag.emit(t.id)" [clickable]="true" linkTitle="Filter by tag" i18n-linkTitle></app-tag> | ||||
|           <app-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Filter by tag" i18n-linkTitle></app-tag> | ||||
|         </div> | ||||
|         <div *ngIf="moreTags"> | ||||
|           <span class="badge badge-secondary">+ {{moreTags}}</span> | ||||
| @@ -23,7 +23,7 @@ | ||||
|     <div class="card-body p-2"> | ||||
|       <p class="card-text"> | ||||
|         <ng-container *ngIf="document.correspondent"> | ||||
|           <a [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent)" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>: | ||||
|           <a [routerLink]="" title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="font-weight-bold">{{(document.correspondent$ | async)?.name}}</a>: | ||||
|         </ng-container> | ||||
|         {{document.title | documentTitle}} <span *ngIf="document.archive_serial_number">(#{{document.archive_serial_number}})</span> | ||||
|       </p> | ||||
| @@ -43,7 +43,7 @@ | ||||
|               <path fill-rule="evenodd" d="M6.5 12a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zM13 6.5a6.5 6.5 0 1 1-13 0 6.5 6.5 0 0 1 13 0z"/> | ||||
|             </svg> | ||||
|           </a> | ||||
|           <a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" i18n-title> | ||||
|           <a [href]="getDownloadUrl()" class="btn btn-sm btn-outline-secondary" title="Download" (click)="$event.stopPropagation()" i18n-title> | ||||
|             <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-download" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> | ||||
|               <path fill-rule="evenodd" d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/> | ||||
|               <path fill-rule="evenodd" d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/> | ||||
|   | ||||
| @@ -14,14 +14,9 @@ export class DocumentCardSmallComponent implements OnInit { | ||||
|  | ||||
|   @Input() | ||||
|   selected = false | ||||
|  | ||||
|   setSelected(value: boolean) { | ||||
|     this.selected = value | ||||
|     this.selectedChange.emit(value) | ||||
|   } | ||||
|  | ||||
|    | ||||
|   @Output() | ||||
|   selectedChange = new EventEmitter<boolean>() | ||||
|   toggleSelected = new EventEmitter() | ||||
|  | ||||
|   @Input() | ||||
|   document: PaperlessDocument | ||||
|   | ||||
| @@ -90,7 +90,7 @@ | ||||
| </div> | ||||
|  | ||||
| <div *ngIf="displayMode == 'largeCards'"> | ||||
|   <app-document-card-large [selected]="list.isSelected(d)" (selectedChange)="list.setSelected(d, $event)"   *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"> | ||||
|   <app-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" *ngFor="let d of list.documents; trackBy: trackByDocumentId" [document]="d" [details]="d.content" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"> | ||||
|   </app-document-card-large> | ||||
| </div> | ||||
|  | ||||
| @@ -135,10 +135,10 @@ | ||||
|       i18n>Added</th> | ||||
|   </thead> | ||||
|   <tbody> | ||||
|     <tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''"> | ||||
|     <tr *ngFor="let d of list.documents; trackBy: trackByDocumentId" (click)="toggleSelected(d, $event)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''"> | ||||
|       <td> | ||||
|         <div class="custom-control custom-checkbox"> | ||||
|           <input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (change)="list.setSelected(d, $event.target.checked)"> | ||||
|           <input type="checkbox" class="custom-control-input" id="docCheck{{d.id}}" [checked]="list.isSelected(d)" (click)="toggleSelected(d, $event)"> | ||||
|           <label class="custom-control-label" for="docCheck{{d.id}}"></label> | ||||
|         </div> | ||||
|       </td> | ||||
| @@ -147,7 +147,7 @@ | ||||
|       </td> | ||||
|       <td class="d-none d-md-table-cell"> | ||||
|         <ng-container *ngIf="d.correspondent"> | ||||
|           <a [routerLink]="" (click)="clickCorrespondent(d.correspondent)" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a> | ||||
|           <a [routerLink]="" (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent">{{(d.correspondent$ | async)?.name}}</a> | ||||
|         </ng-container> | ||||
|       </td> | ||||
|       <td> | ||||
| @@ -156,7 +156,7 @@ | ||||
|       </td> | ||||
|       <td class="d-none d-xl-table-cell"> | ||||
|         <ng-container *ngIf="d.document_type"> | ||||
|           <a [routerLink]="" (click)="clickDocumentType(d.document_type)" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a> | ||||
|           <a [routerLink]="" (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type">{{(d.document_type$ | async)?.name}}</a> | ||||
|         </ng-container> | ||||
|       </td> | ||||
|       <td> | ||||
| @@ -170,5 +170,5 @@ | ||||
| </table> | ||||
|  | ||||
| <div class="m-n2 row row-cols-paperless-cards" *ngIf="displayMode == 'smallCards'"> | ||||
|   <app-document-card-small [selected]="list.isSelected(d)" (selectedChange)="list.setSelected(d, $event)"  [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small> | ||||
|   <app-document-card-small [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)"  [document]="d" *ngFor="let d of list.documents; trackBy: trackByDocumentId" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)"></app-document-card-small> | ||||
| </div> | ||||
|   | ||||
| @@ -1,5 +1,9 @@ | ||||
| @import "/src/theme"; | ||||
|  | ||||
| tr { | ||||
|   user-select: none; | ||||
| } | ||||
|  | ||||
| .table-row-selected { | ||||
|   background-color: $primaryFaded; | ||||
| } | ||||
|   | ||||
| @@ -160,6 +160,11 @@ export class DocumentListComponent implements OnInit { | ||||
|     this.filterRulesModified = modified | ||||
|   } | ||||
|  | ||||
|   toggleSelected(document: PaperlessDocument, event: MouseEvent): void { | ||||
|     if (!event.shiftKey) this.list.toggleSelected(document) | ||||
|     else this.list.selectRangeTo(document) | ||||
|   } | ||||
|  | ||||
|   clickTag(tagID: number) { | ||||
|     this.list.selectNone() | ||||
|     setTimeout(() => { | ||||
|   | ||||
| @@ -27,6 +27,8 @@ export class DocumentListViewService { | ||||
|   currentPage = 1 | ||||
|   currentPageSize: number = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) | ||||
|   collectionSize: number | ||||
|   rangeSelectionAnchorIndex: number | ||||
|   lastRangeSelectionToIndex: number | ||||
|  | ||||
|   /** | ||||
|    * This is the current config for the document list. The service will always remember the last settings used for the document list. | ||||
| @@ -108,6 +110,7 @@ export class DocumentListViewService { | ||||
|           if (onFinish) { | ||||
|             onFinish() | ||||
|           } | ||||
|           this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null | ||||
|           this.isReloading = false | ||||
|         }, | ||||
|         error => { | ||||
| @@ -218,6 +221,7 @@ export class DocumentListViewService { | ||||
|  | ||||
|   selectNone() { | ||||
|     this.selected.clear() | ||||
|     this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null | ||||
|   } | ||||
|  | ||||
|   reduceSelectionToFilter() { | ||||
| @@ -249,14 +253,39 @@ export class DocumentListViewService { | ||||
|     return this.selected.has(d.id) | ||||
|   } | ||||
|  | ||||
|   setSelected(d: PaperlessDocument, value: boolean) { | ||||
|     if (value) { | ||||
|       this.selected.add(d.id) | ||||
|     } else if (!value) { | ||||
|       this.selected.delete(d.id) | ||||
|   toggleSelected(d: PaperlessDocument): void { | ||||
|     if (this.selected.has(d.id)) this.selected.delete(d.id) | ||||
|     else this.selected.add(d.id) | ||||
|     this.rangeSelectionAnchorIndex = this.documentIndexInCurrentView(d.id) | ||||
|     this.lastRangeSelectionToIndex = null | ||||
|   } | ||||
|  | ||||
|   selectRangeTo(d: PaperlessDocument) { | ||||
|     if (this.rangeSelectionAnchorIndex !== null) { | ||||
|       const documentToIndex = this.documentIndexInCurrentView(d.id) | ||||
|       const fromIndex = Math.min(this.rangeSelectionAnchorIndex, documentToIndex) | ||||
|       const toIndex = Math.max(this.rangeSelectionAnchorIndex, documentToIndex) | ||||
|  | ||||
|       if (this.lastRangeSelectionToIndex !== null) { | ||||
|         // revert the old selection | ||||
|         this.documents.slice(Math.min(this.rangeSelectionAnchorIndex, this.lastRangeSelectionToIndex), Math.max(this.rangeSelectionAnchorIndex, this.lastRangeSelectionToIndex) + 1).forEach(d => { | ||||
|           this.selected.delete(d.id) | ||||
|         }) | ||||
|       } | ||||
|  | ||||
|       this.documents.slice(fromIndex, toIndex + 1).forEach(d => { | ||||
|         this.selected.add(d.id) | ||||
|       }) | ||||
|       this.lastRangeSelectionToIndex = documentToIndex | ||||
|     } else { // e.g. shift key but was first click | ||||
|       this.toggleSelected(d) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   documentIndexInCurrentView(documentID: number): number { | ||||
|     return this.documents.map(d => d.id).indexOf(documentID) | ||||
|   } | ||||
|  | ||||
|   constructor(private documentService: DocumentService, private settings: SettingsService, private router: Router) { | ||||
|     let documentListViewConfigJson = sessionStorage.getItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) | ||||
|     if (documentListViewConfigJson) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Jonas Winkler
					Jonas Winkler