Improve search typeahead performance and use native events

pull/2448/head
Rigel Kent 2020-02-04 16:44:53 +01:00
parent ece3029bd9
commit 9b8a7aa8ea
No known key found for this signature in database
GPG Key ID: 5E53E96A494E452F
19 changed files with 191 additions and 208 deletions

View File

@ -7,14 +7,13 @@
<div class="actor-info">
<div class="actor-names">
<div class="actor-display-name">{{ account.displayName }}</div>
<div class="actor-name">{{ account.nameWithHost }}
<button [cdkCopyToClipboard]="account.nameWithHostForced" (click)="activateCopiedMessage()"
class="btn btn-outline-secondary btn-sm copy-button"
>
<span class="glyphicon glyphicon-copy"></span>
</button>
<div class="actor-name">
<span>{{ account.nameWithHost }}</span>
<button [cdkCopyToClipboard]="account.nameWithHostForced" (click)="activateCopiedMessage()"
class="btn btn-outline-secondary btn-sm copy-button"
>
<span class="glyphicon glyphicon-copy"></span>
</button>
</div>
<span *ngIf="accountUser?.blocked" [ngbTooltip]="accountUser.blockedReason" class="badge badge-danger" i18n>Banned</span>
<span *ngIf="account.mutedByUser" class="badge badge-danger" i18n>Muted</span>

View File

@ -7,25 +7,29 @@
<div class="actor-info">
<div class="actor-names">
<div class="actor-display-name">{{ videoChannel.displayName }}</div>
<div class="actor-name">{{ videoChannel.nameWithHost }}
<div class="actor-name">
<span>{{ videoChannel.nameWithHost }}</span>
<button [cdkCopyToClipboard]="videoChannel.nameWithHost" (click)="activateCopiedMessage()"
class="btn btn-outline-secondary btn-sm copy-button"
>
<span class="glyphicon glyphicon-copy"></span>
</button>
</div>
<div class="right-buttons">
<a *ngIf="isChannelManageable" [routerLink]="[ '/my-account/video-channels/update', videoChannel.nameWithHost ]" class="btn btn-outline-tertiary mr-2" i18n>Manage</a>
<my-subscribe-button #subscribeButton [videoChannels]="[videoChannel]"></my-subscribe-button>
</div>
</div>
<div class="actor-followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
<a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Go the owner account page" class="actor-owner">
<span i18n>Created by {{ videoChannel.ownerBy }}</span>
<img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" />
</a>
<div class="right-buttons">
<a *ngIf="isChannelManageable" [routerLink]="[ '/my-account/video-channels/update', videoChannel.nameWithHost ]" class="btn btn-outline-tertiary mr-2" i18n>Manage</a>
<my-subscribe-button #subscribeButton [videoChannels]="[videoChannel]"></my-subscribe-button>
</div>
<div class="actor-lower">
<div class="actor-followers" i18n>{videoChannel.followersCount, plural, =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
<a [routerLink]="[ '/accounts', videoChannel.ownerBy ]" i18n-title title="Go the owner account page" class="actor-owner">
<span i18n>Created by {{ videoChannel.ownerBy }}</span>
<img [src]="videoChannel.ownerAvatarUrl" alt="Owner account avatar" />
</a>
</div>
</div>
</div>

View File

@ -8,6 +8,23 @@
width: 100%;
}
.actor-info {
display: grid !important;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto / 1fr auto;
grid-template-areas: "name buttons"
"lower buttons";
@media screen and (max-width: #{map-get($grid-breakpoints, lg)}) {
grid-template-areas: "name name"
"lower buttons";
}
}
.actor-names {
grid-area: name;
}
.actor-name {
flex-grow: 1;
@ -25,6 +42,9 @@
margin-left: auto;
margin-top: 20px;
grid-row: buttons-start / span buttons-end;
grid-column: buttons-start;
a {
@include peertube-button-outline;
line-height: 1.8;

View File

@ -1,10 +1,4 @@
<my-search-typeahead class="w-100 d-flex justify-content-end">
<input
type="text" id="search-video" name="search-video" [attr.aria-label]="ariaLabelTextForSearch"
i18n-placeholder placeholder="Search videos, channels…" [(ngModel)]="searchValue"
>
<span class="icon icon-search"></span>
</my-search-typeahead>
<my-search-typeahead class="w-100 d-flex justify-content-end"></my-search-typeahead>
<a class="upload-button" routerLink="/videos/upload">
<my-global-icon iconName="upload"></my-global-icon>

View File

@ -5,30 +5,6 @@ my-search-typeahead {
margin-right: 15px;
}
#search-video {
@include peertube-input-text($search-input-width);
padding-left: 10px;
padding-right: 40px; // For the search icon
font-size: 14px;
&::placeholder {
color: var(--inputPlaceholderColor);
}
}
.icon.icon-search {
@include icon(25px);
height: 21px;
background-color: var(--mainForegroundColor);
mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%;
// yolo
position: absolute;
margin-left: -35px;
margin-top: 5px;
}
.upload-button {
@include peertube-button-link;
@include orange-button;

View File

@ -1,5 +1,4 @@
import { Component, OnInit } from '@angular/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { Component } from '@angular/core'
@Component({
selector: 'my-header',
@ -7,15 +6,4 @@ import { I18n } from '@ngx-translate/i18n-polyfill'
styleUrls: [ './header.component.scss' ]
})
export class HeaderComponent implements OnInit {
searchValue = ''
ariaLabelTextForSearch = ''
constructor (
private i18n: I18n
) {}
ngOnInit () {
this.ariaLabelTextForSearch = this.i18n('Search videos, channels')
}
}
export class HeaderComponent {}

View File

@ -1,9 +1,13 @@
<div class="d-inline-flex position-relative" id="typeahead-container" #contentWrapper>
<ng-content></ng-content>
<div class="d-inline-flex position-relative" id="typeahead-container">
<input
type="text" id="search-video" name="search-video" #searchVideo i18n-placeholder placeholder="Search videos, channels…"
[(ngModel)]="search" (ngModelChange)="onSearchChange()" (keyup)="handleKeyUp($event)"
>
<span class="icon icon-search" (click)="doSearch()"></span>
<div class="position-absolute jump-to-suggestions">
<!-- suggestions -->
<my-suggestions *ngIf="hasSearch && newSearch" [results]="results" [highlight]="searchInput.value" (init)="initKeyboardEventsManager($event)" #suggestions></my-suggestions>
<my-suggestions *ngIf="search && newSearch" [results]="results" [highlight]="search" (init)="initKeyboardEventsManager($event)"></my-suggestions>
<!-- suggestion help, not shown until one of the suggestions is selected and specific to that suggestion -->
<div *ngIf="showHelp" id="typeahead-help" class="overflow-hidden">
@ -11,7 +15,7 @@
<div class="d-flex justify-content-between">
<label class="small-title" i18n>Global search</label>
<div class="advanced-search-status text-muted">
<span class="mr-1" i18n>using {{ globalSearchIndex }}</span>
<span *ngIf="serverConfig" class="mr-1" i18n>using {{ serverConfig.followings.instance.autoFollowIndex.indexUrl }}</span>
<i class="glyphicon glyphicon-globe"></i>
</div>
</div>
@ -20,13 +24,14 @@
</div>
<!-- search instructions, when search input is empty -->
<div *ngIf="!hasSearch" id="typeahead-instructions" class="overflow-hidden">
<div *ngIf="showInstructions" id="typeahead-instructions" class="overflow-hidden">
<div class="d-flex justify-content-between">
<label class="small-title" i18n>Advanced search</label>
<div class="advanced-search-status c-help">
<span [ngClass]="URIPolicy === 'any' ? 'text-success' : 'text-muted'" [title]="URIPolicyText">
<span class="mr-1" i18n>{URIPolicy, select, only-followed {only followed instances} other {any instance}} </span>
<i [ngClass]="URIPolicy === 'any' ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
<span [ngClass]="anyURI ? 'text-success' : 'text-muted'" i18n-title title="Determines whether you can resolve any distant content, or if this instance only allows doing so for instances it follows.">
<span *ngIf="anyURI" class="mr-1" i18n>any instance</span>
<span *ngIf="!anyURI" class="mr-1" i18n>only followed instances</span>
<i [ngClass]="anyURI ? 'glyphicon glyphicon-ok-sign' : 'glyphicon glyphicon-exclamation-sign'"></i>
</span>
</div>
</div>

View File

@ -3,6 +3,30 @@
@import '_bootstrap-variables';
@import '~bootstrap/scss/mixins/_breakpoints';
#search-video {
@include peertube-input-text($search-input-width);
padding-left: 10px;
padding-right: 40px; // For the search icon
font-size: 14px;
&::placeholder {
color: var(--inputPlaceholderColor);
}
}
.icon.icon-search {
@include icon(25px);
height: 21px;
background-color: var(--mainForegroundColor);
mask: url('../../assets/images/header/search.svg') no-repeat 50% 50%;
// yolo
position: absolute;
margin-left: -35px;
margin-top: 5px;
}
.jump-to-suggestions {
top: 100%;
left: 0;
@ -42,7 +66,7 @@ my-suggestions ::ng-deep ul {
}
#typeahead-container {
::ng-deep input {
input {
border: 1px solid var(--mainBackgroundColor) !important;
box-shadow: rgba(0, 0, 0, 0.1) 0px 1px 20px 0px;
flex-grow: 1;
@ -56,12 +80,12 @@ my-suggestions ::ng-deep ul {
@media screen and (max-width: $small-view) {
flex: 1;
::ng-deep input {
input {
width: unset;
}
}
::ng-deep span {
span {
right: 10px;
}

View File

@ -1,16 +1,15 @@
import {
Component,
ViewChild,
ElementRef,
AfterViewInit,
OnInit,
OnDestroy,
QueryList
QueryList,
ViewChild,
ElementRef
} from '@angular/core'
import { Router, NavigationEnd, Params, ActivatedRoute } from '@angular/router'
import { Router, Params, ActivatedRoute } from '@angular/router'
import { AuthService, ServerService } from '@app/core'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { filter, first, tap, map } from 'rxjs/operators'
import { first, tap } from 'rxjs/operators'
import { ListKeyManager } from '@angular/cdk/a11y'
import { UP_ARROW, DOWN_ARROW, ENTER } from '@angular/cdk/keycodes'
import { SuggestionComponent, Result } from './suggestion.component'
@ -24,19 +23,16 @@ import { ServerConfig } from '@shared/models'
styleUrls: [ './search-typeahead.component.scss' ]
})
export class SearchTypeaheadComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef
@ViewChild('searchVideo', { static: true }) searchInput: ElementRef<HTMLInputElement>
hasChannel = false
inChannel = false
newSearch = true
searchInput: HTMLInputElement
search = ''
serverConfig: ServerConfig
URIPolicyText: string
inAllText: string
inThisChannelText: string
globalSearchIndex = 'https://index.joinpeertube.org'
keyboardEventsManager: ListKeyManager<SuggestionComponent>
results: any[] = []
@ -45,30 +41,10 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy, AfterViewIni
private authService: AuthService,
private router: Router,
private route: ActivatedRoute,
private serverService: ServerService,
private i18n: I18n
) {
this.URIPolicyText = this.i18n('Determines whether you can resolve any distant content, or if your instance only allows doing so for instances it follows.')
this.inAllText = this.i18n('In all PeerTube')
this.inThisChannelText = this.i18n('In this channel')
}
private serverService: ServerService
) {}
ngOnInit () {
this.router.events
.pipe(filter(e => e instanceof NavigationEnd))
.subscribe((event: NavigationEnd) => {
this.hasChannel = event.url.startsWith('/videos/watch')
this.inChannel = event.url.startsWith('/video-channels')
this.computeResults()
})
this.router.events
.pipe(
filter(e => e instanceof NavigationEnd),
map(() => getParameterByName('search', window.location.href))
)
.subscribe(searchQuery => this.searchInput.value = searchQuery || '')
this.serverService.getConfig()
.subscribe(config => this.serverConfig = config)
}
@ -78,53 +54,52 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy, AfterViewIni
}
ngAfterViewInit () {
this.searchInput = this.contentWrapper.nativeElement.childNodes[0]
this.searchInput.addEventListener('input', this.computeResults.bind(this))
this.searchInput.addEventListener('keyup', this.handleKeyUp.bind(this))
}
get hasSearch () {
return !!this.searchInput && !!this.searchInput.value
this.search = getParameterByName('search', window.location.href) || ''
}
get activeResult () {
return this.keyboardEventsManager && this.keyboardEventsManager.activeItem && this.keyboardEventsManager.activeItem.result
}
get showHelp () {
return this.hasSearch && this.newSearch && this.activeResult && this.activeResult.type === 'search-global' || false
get showInstructions () {
return !this.search
}
get URIPolicy (): 'only-followed' | 'any' {
return (
this.authService.isLoggedIn()
? this.serverConfig.search.remoteUri.users
: this.serverConfig.search.remoteUri.anonymous
)
? 'any'
: 'only-followed'
get showHelp () {
return this.search && this.newSearch && this.activeResult && this.activeResult.type === 'search-global' || false
}
get anyURI () {
if (!this.serverConfig) return false
return this.authService.isLoggedIn()
? this.serverConfig.search.remoteUri.users
: this.serverConfig.search.remoteUri.anonymous
}
onSearchChange () {
this.computeResults()
}
computeResults () {
this.newSearch = true
let results: Result[] = []
if (this.hasSearch) {
if (this.search) {
results = [
/* Channel search is still unimplemented. Uncomment when it is.
{
text: this.searchInput.value,
text: this.search,
type: 'search-channel'
},
*/
{
text: this.searchInput.value,
text: this.search,
type: 'search-instance',
default: true
},
/* Global search is still unimplemented. Uncomment when it is.
{
text: this.searchInput.value,
text: this.search,
type: 'search-global'
},
*/
@ -137,7 +112,8 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy, AfterViewIni
// if we're not in a channel or one of its videos/playlits, show all channel-related results
if (!(this.hasChannel || this.inChannel)) return !result.type.includes('channel')
// if we're in a channel, show all channel-related results except for the channel redirection itself
if (this.inChannel) return !(result.type === 'channel')
if (this.inChannel) return result.type !== 'channel'
// all other result types are kept
return true
}
)
@ -187,7 +163,7 @@ export class SearchTypeaheadComponent implements OnInit, OnDestroy, AfterViewIni
Object.assign(queryParams, this.route.snapshot.queryParams)
}
Object.assign(queryParams, { search: this.searchInput.value })
Object.assign(queryParams, { search: this.search })
const o = this.authService.isLoggedIn()
? this.loadUserLanguagesIfNeeded(queryParams)

View File

@ -9,15 +9,9 @@
<div class="flex-auto overflow-hidden text-left no-wrap css-truncate css-truncate-target" [attr.aria-label]="result.text" [innerHTML]="result.text | highlight : highlight"></div>
<div *ngIf="result.type !== 'channel' && result.type !== 'suggestion'" class="border rounded flex-shrink-0 px-1 bg-gray text-gray-light ml-1 f6">
<span *ngIf="result.type === 'search-channel'" [attr.aria-label]="inThisChannelText">
{{ inThisChannelText }}
</span>
<span *ngIf="result.type === 'search-instance'" [attr.aria-label]="inThisInstanceText">
{{ inThisInstanceText }}
</span>
<span *ngIf="result.type === 'search-global'" [attr.aria-label]="inAllText">
{{ inAllText }}
</span>
<span *ngIf="result.type === 'search-channel'" i18n>In this channel</span>
<span *ngIf="result.type === 'search-instance'" i18n>In this instance</span>
<span *ngIf="result.type === 'search-global'" i18n>In the vidiverse</span>
<span *ngIf="result.type === 'search-any'" aria-hidden="true" class="d-inline-block ml-1 v-align-middle"></span>
</div>

View File

@ -1,6 +1,5 @@
import { Input, Component, Output, EventEmitter, OnInit } from '@angular/core'
import { Input, Component, Output, EventEmitter, OnInit, ChangeDetectionStrategy } from '@angular/core'
import { RouterLink } from '@angular/router'
import { I18n } from '@ngx-translate/i18n-polyfill'
import { ListKeyManagerOption } from '@angular/cdk/a11y'
export type Result = {
@ -13,28 +12,17 @@ export type Result = {
@Component({
selector: 'my-suggestion',
templateUrl: './suggestion.component.html',
styleUrls: [ './suggestion.component.scss' ]
styleUrls: [ './suggestion.component.scss' ],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SuggestionComponent implements OnInit, ListKeyManagerOption {
@Input() result: Result
@Input() highlight: string
@Output() selected = new EventEmitter()
inAllText: string
inThisChannelText: string
inThisInstanceText: string
disabled = false
active = false
constructor (
private i18n: I18n
) {
this.inAllText = this.i18n('In the vidiverse')
this.inThisChannelText = this.i18n('In this channel')
this.inThisInstanceText = this.i18n('In this instance')
}
getLabel () {
return this.result.text
}

View File

@ -0,0 +1,6 @@
<ul role="listbox" class="p-0 m-0">
<li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5"
role="option" aria-selected="true" (mouseenter)="hoverItem(i)">
<my-suggestion [result]="result" [highlight]="highlight"></my-suggestion>
</li>
</ul>

View File

@ -1,16 +1,10 @@
import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren } from '@angular/core'
import { Input, QueryList, Component, Output, AfterViewInit, EventEmitter, ViewChildren, ChangeDetectionStrategy } from '@angular/core'
import { SuggestionComponent } from './suggestion.component'
@Component({
selector: 'my-suggestions',
template: `
<ul role="listbox" class="p-0 m-0">
<li *ngFor="let result of results; let i = index" class="d-flex flex-justify-start flex-items-center p-0 f5"
role="option" aria-selected="true" (mouseenter)="hoverItem(i)">
<my-suggestion [result]="result" [highlight]="highlight"></my-suggestion>
</li>
</ul>
`
templateUrl: './suggestions.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SuggestionsComponent implements AfterViewInit {
@Input() results: any[]
@ -20,7 +14,7 @@ export class SuggestionsComponent implements AfterViewInit {
ngAfterViewInit () {
this.listItems.changes.subscribe(
val => this.init.emit({ items: this.listItems })
_ => this.init.emit({ items: this.listItems })
)
}

View File

@ -5,48 +5,50 @@ import { SafeHtml } from '@angular/platform-browser'
@Pipe({ name: 'highlight' })
export class HighlightPipe implements PipeTransform {
/* use this for single match search */
static SINGLE_MATCH: string = "Single-Match"
static SINGLE_MATCH = 'Single-Match'
/* use this for single match search with a restriction that target should start with search string */
static SINGLE_AND_STARTS_WITH_MATCH: string = "Single-And-StartsWith-Match"
static SINGLE_AND_STARTS_WITH_MATCH = 'Single-And-StartsWith-Match'
/* use this for global search */
static MULTI_MATCH: string = "Multi-Match"
static MULTI_MATCH = 'Multi-Match'
constructor() {}
transform(
// tslint:disable-next-line:no-empty
constructor () {}
transform (
contentString: string = null,
stringToHighlight: string = null,
option: string = "Single-And-StartsWith-Match",
caseSensitive: boolean = false,
highlightStyleName: string = "search-highlight"
option = 'Single-And-StartsWith-Match',
caseSensitive = false,
highlightStyleName = 'search-highlight'
): SafeHtml {
if (stringToHighlight && contentString && option) {
let regex: any = ""
let caseFlag: string = !caseSensitive ? "i" : ""
switch (option) {
case "Single-Match": {
regex = new RegExp(stringToHighlight, caseFlag)
break
}
case "Single-And-StartsWith-Match": {
regex = new RegExp("^" + stringToHighlight, caseFlag)
break
}
case "Multi-Match": {
regex = new RegExp(stringToHighlight, "g" + caseFlag)
break
}
default: {
// default will be a global case-insensitive match
regex = new RegExp(stringToHighlight, "gi")
}
}
const replaced = contentString.replace(
regex,
(match) => `<span class="${highlightStyleName}">${match}</span>`
)
return replaced
} else {
return contentString
if (stringToHighlight && contentString && option) {
let regex: any = ''
const caseFlag: string = !caseSensitive ? 'i' : ''
switch (option) {
case 'Single-Match': {
regex = new RegExp(stringToHighlight, caseFlag)
break
}
case 'Single-And-StartsWith-Match': {
regex = new RegExp("^" + stringToHighlight, caseFlag)
break
}
case 'Multi-Match': {
regex = new RegExp(stringToHighlight, 'g' + caseFlag)
break
}
default: {
// default will be a global case-insensitive match
regex = new RegExp(stringToHighlight, 'gi')
}
}
const replaced = contentString.replace(
regex,
(match) => `<span class="${highlightStyleName}">${match}</span>`
)
return replaced
} else {
return contentString
}
}
}

View File

@ -1,5 +1,6 @@
$icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
@import '_bootstrap-variables';
@import '_variables';
@import '_mixins';
@ -234,7 +235,7 @@ table {
}
}
@media screen and (max-width: 1600px) {
@media screen and (max-width: #{map-get($grid-breakpoints, xxl)}) {
.main-col {
&.expanded {
.margin-content {
@ -245,7 +246,7 @@ table {
}
}
@media screen and (max-width: 900px) {
@media screen and (max-width: #{map-get($grid-breakpoints, lg)}) {
.main-col {
&.expanded {
.margin-content {

View File

@ -52,7 +52,7 @@ $icon-font-path: '~@neos21/bootstrap3-glyphicons/assets/fonts/';
}
@media screen and (min-width: 768px) {
@media screen and (min-width: #{map-get($grid-breakpoints, md)}) {
.modal:before {
vertical-align: middle;
content: " ";

View File

@ -13,8 +13,9 @@ $grid-breakpoints: (
md: 768px,
// Large screen / desktop
lg: 900px,
// Extra large screen / wide desktop
xl: 1200px
// Extra large screens / wide desktops
xl: 1200px,
xxl: 1600px
);
$container-max-widths: (

View File

@ -445,7 +445,6 @@
@mixin actor-owner {
@include disable-default-a-behaviour;
display: inline-table;
font-size: 13px;
margin-top: 4px;
color: var(--mainForegroundColor);
@ -488,14 +487,15 @@
.actor-names {
display: flex;
align-items: center;
flex-wrap: wrap;
.actor-display-name {
font-size: 23px;
font-weight: $font-bold;
margin-right: 7px;
}
.actor-name {
margin-left: 7px;
position: relative;
top: 3px;
font-size: 14px;
@ -503,6 +503,10 @@
}
}
.actor-lower {
grid-area: lower;
}
.actor-followers {
font-size: 15px;
}
@ -522,6 +526,11 @@
margin-bottom: 0;
text-transform: uppercase;
font-weight: 600;
font-size: 110%;
@media screen and (max-width: $mobile-view) {
font-size: 130%;
}
}
}
}

View File

@ -1,3 +1,5 @@
@import '_bootstrap-variables';
$small-view: 800px;
$mobile-view: 500px;