Rewrite infinite scroll

pull/292/head
Chocobozzz 2018-02-13 14:11:05 +01:00
parent 29c6b82944
commit 0cd4344f3c
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
15 changed files with 245 additions and 119 deletions

View File

@ -61,7 +61,6 @@
"ngx-bootstrap": "2.0.2",
"ngx-chips": "1.6.3",
"ngx-clipboard": "9.0.1",
"ngx-infinite-scroll": "0.7.2",
"ngx-pipes": "^2.0.5",
"node-sass": "^4.1.1",
"npm-font-source-sans-pro": "^1.0.2",

View File

@ -1,44 +1,44 @@
<div *ngIf="pagination.totalItems === 0">No results.</div>
<div
class="videos"
infiniteScroll
[infiniteScrollDistance]="0.5"
[infiniteScrollUpDistance]="1.5"
(scrolled)="onNearOfBottom()"
(scrolledUp)="onNearOfTop()"
myInfiniteScroller
[pageHeight]="pageHeight"
(nearOfTop)="onNearOfTop()" (nearOfBottom)="onNearOfBottom()" (pageChanged)="onPageChanged($event)"
class="videos" #videoElement
>
<div class="video" *ngFor="let video of videos; let i = index">
<div class="checkbox-container">
<input [id]="'video-check-' + i" type="checkbox" [(ngModel)]="checkedVideos[video.id]" />
<label [for]="'video-check-' + i"></label>
</div>
<my-video-thumbnail [video]="video"></my-video-thumbnail>
<div class="video-info">
<a class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
<span class="video-info-date-views">{{ video.createdAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span>
</div>
<!-- Display only once -->
<div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0">
<div class="action-selection-mode-child">
<span class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
Cancel
</span>
<span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()">
<span class="icon icon-delete-white"></span>
Delete
</span>
<div *ngFor="let videos of videoPages; let i = index" class="videos-page">
<div class="video" *ngFor="let video of videos; let j = index">
<div class="checkbox-container">
<input [id]="'video-check-' + video.id" type="checkbox" [(ngModel)]="checkedVideos[video.id]" />
<label [for]="'video-check-' + video.id"></label>
</div>
</div>
<div class="video-buttons" *ngIf="isInSelectionMode() === false">
<my-delete-button (click)="deleteVideo(video)"></my-delete-button>
<my-video-thumbnail [video]="video"></my-video-thumbnail>
<my-edit-button [routerLink]="[ '/videos', 'edit', video.uuid ]"></my-edit-button>
<div class="video-info">
<a class="video-info-name" [routerLink]="['/videos/watch', video.uuid]" [attr.title]="video.name">{{ video.name }}</a>
<span class="video-info-date-views">{{ video.createdAt | myFromNow }} - {{ video.views | myNumberFormatter }} views</span>
</div>
<!-- Display only once -->
<div class="action-selection-mode" *ngIf="isInSelectionMode() === true && i === 0 && j === 0">
<div class="action-selection-mode-child">
<span class="action-button action-button-cancel-selection" (click)="abortSelectionMode()">
Cancel
</span>
<span class="action-button action-button-delete-selection" (click)="deleteSelectedVideos()">
<span class="icon icon-delete-white"></span>
Delete
</span>
</div>
</div>
<div class="video-buttons" *ngIf="isInSelectionMode() === false">
<my-delete-button (click)="deleteVideo(video)"></my-delete-button>
<my-edit-button [routerLink]="[ '/videos', 'edit', video.uuid ]"></my-edit-button>
</div>
</div>
</div>
</div>

View File

@ -45,16 +45,13 @@
display: flex;
min-height: 130px;
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 1px solid #C6C6C6;
&:first-child {
margin-top: 47px;
}
&:not(:last-child) {
margin-bottom: 20px;
border-bottom: 1px solid #C6C6C6;
}
.checkbox-container {
display: flex;
align-items: center;

View File

@ -1,5 +1,7 @@
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { immutableAssign } from '@app/shared/misc/utils'
import { ComponentPagination } from '@app/shared/rest/component-pagination.model'
import { NotificationsService } from 'angular2-notifications'
import 'rxjs/add/observable/from'
import 'rxjs/add/operator/concatAll'
@ -19,7 +21,9 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit
titlePage = 'My videos'
currentRoute = '/account/videos'
checkedVideos: { [ id: number ]: boolean } = {}
pagination = {
videoHeight = 155
videoWidth = -1
pagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 10,
totalItems: null
@ -46,8 +50,10 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit
return Object.keys(this.checkedVideos).some(k => this.checkedVideos[k] === true)
}
getVideosObservable () {
return this.videoService.getMyVideos(this.pagination, this.sort)
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
return this.videoService.getMyVideos(newPagination, this.sort)
}
deleteSelectedVideos () {
@ -71,9 +77,12 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit
Observable.from(observables)
.concatAll()
.subscribe(
res => this.notificationsService.success('Success', `${toDeleteVideosIds.length} videos deleted.`),
res => {
this.notificationsService.success('Success', `${toDeleteVideosIds.length} videos deleted.`)
this.buildVideoPages()
},
err => this.notificationsService.error('Error', err.message)
err => this.notificationsService.error('Error', err.message)
)
}
)
@ -89,6 +98,7 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit
status => {
this.notificationsService.success('Success', `Video ${video.name} deleted.`)
this.spliceVideosById(video.id)
this.buildVideoPages()
},
error => this.notificationsService.error('Error', error.message)
@ -98,7 +108,14 @@ export class AccountVideosComponent extends AbstractVideoList implements OnInit
}
private spliceVideosById (id: number) {
const index = this.videos.findIndex(v => v.id === id)
this.videos.splice(index, 1)
for (const key of Object.keys(this.loadedPages)) {
const videos = this.loadedPages[key]
const index = videos.findIndex(v => v.id === id)
if (index !== -1) {
videos.splice(index, 1)
return
}
}
}
}

View File

@ -55,6 +55,10 @@ function dateToHuman (date: string) {
return datePipe.transform(date, 'medium')
}
function immutableAssign <A, B> (target: A, source: B) {
return Object.assign({}, target, source)
}
function isInSmallView () {
return window.innerWidth < 600
}
@ -70,5 +74,6 @@ export {
getAbsoluteAPIUrl,
dateToHuman,
isInSmallView,
isInMobileView
isInMobileView,
immutableAssign
}

View File

@ -4,13 +4,13 @@ import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { MarkdownTextareaComponent } from '@app/shared/forms/markdown-textarea.component'
import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
import { MarkdownService } from '@app/videos/shared'
import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
import { BsDropdownModule } from 'ngx-bootstrap/dropdown'
import { ModalModule } from 'ngx-bootstrap/modal'
import { TabsModule } from 'ngx-bootstrap/tabs'
import { InfiniteScrollModule } from 'ngx-infinite-scroll'
import { BytesPipe, KeysPipe, NgPipesModule } from 'ngx-pipes'
import { SharedModule as PrimeSharedModule } from 'primeng/components/common/shared'
@ -42,7 +42,6 @@ import { VideoService } from './video/video.service'
ModalModule.forRoot(),
PrimeSharedModule,
InfiniteScrollModule,
NgPipesModule,
TabsModule.forRoot()
],
@ -55,7 +54,8 @@ import { VideoService } from './video/video.service'
EditButtonComponent,
NumberFormatterPipe,
FromNowPipe,
MarkdownTextareaComponent
MarkdownTextareaComponent,
InfiniteScrollerDirective
],
exports: [
@ -70,7 +70,6 @@ import { VideoService } from './video/video.service'
BsDropdownModule,
ModalModule,
PrimeSharedModule,
InfiniteScrollModule,
BytesPipe,
KeysPipe,
@ -80,6 +79,7 @@ import { VideoService } from './video/video.service'
DeleteButtonComponent,
EditButtonComponent,
MarkdownTextareaComponent,
InfiniteScrollerDirective,
NumberFormatterPipe,
FromNowPipe

View File

@ -6,17 +6,17 @@
<div *ngIf="pagination.totalItems === 0">No results.</div>
<div
class="videos"
infiniteScroll
[infiniteScrollUpDistance]="1.5"
[infiniteScrollDistance]="0.5"
(scrolled)="onNearOfBottom()"
(scrolledUp)="onNearOfTop()"
myInfiniteScroller
[pageHeight]="pageHeight"
(nearOfTop)="onNearOfTop()" (nearOfBottom)="onNearOfBottom()" (pageChanged)="onPageChanged($event)"
class="videos" #videoElement
>
<my-video-miniature
class="ng-animate"
*ngFor="let video of videos" [video]="video" [user]="user"
>
</my-video-miniature>
<div *ngFor="let videos of videoPages" class="videos-page">
<my-video-miniature
class="ng-animate"
*ngFor="let video of videos" [video]="video" [user]="user"
>
</my-video-miniature>
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { OnInit } from '@angular/core'
import { ElementRef, OnInit, ViewChild, ViewChildren } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { isInMobileView, isInSmallView } from '@app/shared/misc/utils'
import { isInMobileView } from '@app/shared/misc/utils'
import { InfiniteScrollerDirective } from '@app/shared/video/infinite-scroller.directive'
import { NotificationsService } from 'angular2-notifications'
import { Observable } from 'rxjs/Observable'
import { AuthService } from '../../core/auth'
@ -9,30 +10,35 @@ import { SortField } from './sort-field.type'
import { Video } from './video.model'
export abstract class AbstractVideoList implements OnInit {
private static LINES_PER_PAGE = 3
@ViewChild('videoElement') videosElement: ElementRef
@ViewChild(InfiniteScrollerDirective) infiniteScroller: InfiniteScrollerDirective
pagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 25,
itemsPerPage: 10,
totalItems: null
}
sort: SortField = '-createdAt'
defaultSort: SortField = '-createdAt'
videos: Video[] = []
loadOnInit = true
pageHeight: number
videoWidth = 215
videoHeight = 230
videoPages: Video[][]
protected abstract notificationsService: NotificationsService
protected abstract authService: AuthService
protected abstract router: Router
protected abstract route: ActivatedRoute
protected abstract currentRoute: string
abstract titlePage: string
protected otherParams = {}
protected loadedPages: { [ id: number ]: Video[] } = {}
protected otherRouteParams = {}
private loadedPages: { [ id: number ]: boolean } = {}
abstract getVideosObservable (): Observable<{ videos: Video[], totalVideos: number}>
abstract getVideosObservable (page: number): Observable<{ videos: Video[], totalVideos: number}>
get user () {
return this.authService.getUser()
@ -45,15 +51,26 @@ export abstract class AbstractVideoList implements OnInit {
if (isInMobileView()) {
this.pagination.itemsPerPage = 5
this.videoWidth = -1
}
if (this.loadOnInit === true) this.loadMoreVideos('after')
if (this.videoWidth !== -1) {
const videosWidth = this.videosElement.nativeElement.offsetWidth
this.pagination.itemsPerPage = Math.floor(videosWidth / this.videoWidth) * AbstractVideoList.LINES_PER_PAGE
}
// Video takes all the width
if (this.videoWidth === -1) {
this.pageHeight = this.pagination.itemsPerPage * this.videoHeight
} else {
this.pageHeight = this.videoHeight * AbstractVideoList.LINES_PER_PAGE
}
if (this.loadOnInit === true) this.loadMoreVideos(this.pagination.currentPage)
}
onNearOfTop () {
if (this.pagination.currentPage > 1) {
this.previousPage()
}
this.previousPage()
}
onNearOfBottom () {
@ -62,16 +79,20 @@ export abstract class AbstractVideoList implements OnInit {
}
}
reloadVideos () {
this.videos = []
this.loadedPages = {}
this.loadMoreVideos('before')
onPageChanged (page: number) {
this.pagination.currentPage = page
this.setNewRouteParams()
}
loadMoreVideos (where: 'before' | 'after') {
if (this.loadedPages[this.pagination.currentPage] === true) return
reloadVideos () {
this.loadedPages = {}
this.loadMoreVideos(this.pagination.currentPage)
}
const observable = this.getVideosObservable()
loadMoreVideos (page: number) {
if (this.loadedPages[page] !== undefined) return
const observable = this.getVideosObservable(page)
observable.subscribe(
({ videos, totalVideos }) => {
@ -82,13 +103,14 @@ export abstract class AbstractVideoList implements OnInit {
return this.reloadVideos()
}
this.loadedPages[this.pagination.currentPage] = true
this.loadedPages[page] = videos
this.buildVideoPages()
this.pagination.totalItems = totalVideos
if (where === 'before') {
this.videos = videos.concat(this.videos)
} else {
this.videos = this.videos.concat(videos)
// Initialize infinite scroller now we loaded the first page
if (Object.keys(this.loadedPages).length === 1) {
// Wait elements creation
setTimeout(() => this.infiniteScroller.initialize(), 500)
}
},
error => this.notificationsService.error('Error', error.message)
@ -107,17 +129,15 @@ export abstract class AbstractVideoList implements OnInit {
}
protected previousPage () {
this.pagination.currentPage--
const min = this.minPageLoaded()
this.setNewRouteParams()
this.loadMoreVideos('before')
if (min > 1) {
this.loadMoreVideos(min - 1)
}
}
protected nextPage () {
this.pagination.currentPage++
this.setNewRouteParams()
this.loadMoreVideos('after')
this.loadMoreVideos(this.maxPageLoaded() + 1)
}
protected buildRouteParams () {
@ -127,7 +147,7 @@ export abstract class AbstractVideoList implements OnInit {
page: this.pagination.currentPage
}
return Object.assign(params, this.otherParams)
return Object.assign(params, this.otherRouteParams)
}
protected loadRouteParams (routeParams: { [ key: string ]: any }) {
@ -144,4 +164,16 @@ export abstract class AbstractVideoList implements OnInit {
const routeParams = this.buildRouteParams()
this.router.navigate([ this.currentRoute, routeParams ])
}
protected buildVideoPages () {
this.videoPages = Object.values(this.loadedPages)
}
private minPageLoaded () {
return Math.min(...Object.keys(this.loadedPages).map(e => parseInt(e, 10)))
}
private maxPageLoaded () {
return Math.max(...Object.keys(this.loadedPages).map(e => parseInt(e, 10)))
}
}

View File

@ -0,0 +1,77 @@
import { Directive, EventEmitter, Input, OnInit, Output } from '@angular/core'
import 'rxjs/add/operator/distinct'
import 'rxjs/add/operator/startWith'
import { fromEvent } from 'rxjs/observable/fromEvent'
@Directive({
selector: '[myInfiniteScroller]'
})
export class InfiniteScrollerDirective implements OnInit {
private static PAGE_VIEW_TOP_MARGIN = 500
@Input() containerHeight: number
@Input() pageHeight: number
@Input() percentLimit = 70
@Input() autoLoading = false
@Output() nearOfBottom = new EventEmitter<void>()
@Output() nearOfTop = new EventEmitter<void>()
@Output() pageChanged = new EventEmitter<number>()
private decimalLimit = 0
private lastCurrentBottom = -1
private lastCurrentTop = 0
constructor () {
this.decimalLimit = this.percentLimit / 100
}
ngOnInit () {
if (this.autoLoading === true) return this.initialize()
}
initialize () {
const scrollObservable = fromEvent(window, 'scroll')
.startWith(true)
.map(() => ({ current: window.scrollY, maximumScroll: document.body.clientHeight - window.innerHeight }))
// Scroll Down
scrollObservable
// Check we scroll down
.filter(({ current }) => {
const res = this.lastCurrentBottom < current
this.lastCurrentBottom = current
return res
})
.filter(({ current, maximumScroll }) => maximumScroll <= 0 || (current / maximumScroll) > this.decimalLimit)
.debounceTime(200)
.distinct()
.subscribe(() => this.nearOfBottom.emit())
// Scroll up
scrollObservable
// Check we scroll up
.filter(({ current }) => {
const res = this.lastCurrentTop > current
this.lastCurrentTop = current
return res
})
.filter(({ current, maximumScroll }) => {
return current !== 0 && (1 - (current / maximumScroll)) > this.decimalLimit
})
.debounceTime(200)
.distinct()
.subscribe(() => this.nearOfTop.emit())
// Page change
scrollObservable
.debounceTime(500)
.distinct()
.map(({ current }) => Math.max(1, Math.round((current + InfiniteScrollerDirective.PAGE_VIEW_TOP_MARGIN) / this.pageHeight)))
.distinctUntilChanged()
.subscribe(res => this.pageChanged.emit(res))
}
}

View File

@ -15,10 +15,9 @@
<div
class="comment-threads"
infiniteScroll
[infiniteScrollUpDistance]="1.5"
[infiniteScrollDistance]="0.5"
(scrolled)="onNearOfBottom()"
myInfiniteScroller
[autoLoading]="true"
(nearOfBottom)="onNearOfBottom()"
>
<div *ngFor="let comment of comments">
<my-video-comment

View File

@ -160,11 +160,8 @@ export class VideoCommentsComponent implements OnChanges {
this.threadComments = {}
this.threadLoading = {}
this.inReplyToCommentId = undefined
this.componentPagination = {
currentPage: 1,
itemsPerPage: 10,
totalItems: null
}
this.componentPagination.currentPage = 1
this.componentPagination.totalItems = null
this.loadMoreComments()
}

View File

@ -1,5 +1,6 @@
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { immutableAssign } from '@app/shared/misc/utils'
import { NotificationsService } from 'angular2-notifications'
import { AuthService } from '../../core/auth'
import { AbstractVideoList } from '../../shared/video/abstract-video-list'
@ -28,7 +29,9 @@ export class VideoRecentlyAddedComponent extends AbstractVideoList implements On
super.ngOnInit()
}
getVideosObservable () {
return this.videoService.getVideos(this.pagination, this.sort)
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
return this.videoService.getVideos(newPagination, this.sort)
}
}

View File

@ -1,5 +1,6 @@
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { immutableAssign } from '@app/shared/misc/utils'
import { NotificationsService } from 'angular2-notifications'
import { Subscription } from 'rxjs/Subscription'
import { AuthService } from '../../core/auth'
@ -16,7 +17,7 @@ export class VideoSearchComponent extends AbstractVideoList implements OnInit, O
currentRoute = '/videos/search'
loadOnInit = false
protected otherParams = {
protected otherRouteParams = {
search: ''
}
private subActivatedRoute: Subscription
@ -35,9 +36,9 @@ export class VideoSearchComponent extends AbstractVideoList implements OnInit, O
this.subActivatedRoute = this.route.queryParams.subscribe(
queryParams => {
const querySearch = queryParams['search']
if (!querySearch || this.otherParams.search === querySearch) return
if (!querySearch || this.otherRouteParams.search === querySearch) return
this.otherParams.search = querySearch
this.otherRouteParams.search = querySearch
this.reloadVideos()
},
@ -51,7 +52,8 @@ export class VideoSearchComponent extends AbstractVideoList implements OnInit, O
}
}
getVideosObservable () {
return this.videoService.searchVideos(this.otherParams.search, this.pagination, this.sort)
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
return this.videoService.searchVideos(this.otherRouteParams.search, newPagination, this.sort)
}
}

View File

@ -1,5 +1,6 @@
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { immutableAssign } from '@app/shared/misc/utils'
import { NotificationsService } from 'angular2-notifications'
import { AuthService } from '../../core/auth'
import { AbstractVideoList } from '../../shared/video/abstract-video-list'
@ -28,7 +29,8 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit
super.ngOnInit()
}
getVideosObservable () {
return this.videoService.getVideos(this.pagination, this.sort)
getVideosObservable (page: number) {
const newPagination = immutableAssign(this.pagination, { currentPage: page })
return this.videoService.getVideos(newPagination, this.sort)
}
}

View File

@ -4526,10 +4526,6 @@ ngx-clipboard@9.0.1:
dependencies:
ngx-window-token "0.0.4"
ngx-infinite-scroll@0.7.2:
version "0.7.2"
resolved "https://registry.yarnpkg.com/ngx-infinite-scroll/-/ngx-infinite-scroll-0.7.2.tgz#c1f0e7fba4731a55f15557dc6fce2721fd562420"
ngx-pipes@^2.0.5:
version "2.1.0"
resolved "https://registry.yarnpkg.com/ngx-pipes/-/ngx-pipes-2.1.0.tgz#969cbc78f1c7512b12cc050f441c2528fb3a05a0"