mirror of https://github.com/Chocobozzz/PeerTube
Provide native links for description timestamps, and re-clickability for these
parent
d68ebf0b4a
commit
b29bf61dbd
|
@ -0,0 +1,45 @@
|
|||
import { Directive, ElementRef, HostListener, Output, EventEmitter } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
|
||||
type ElementEvent = Omit<Event, 'target'> & {
|
||||
target: HTMLInputElement
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[timestampRouteTransformer]'
|
||||
})
|
||||
export class TimestampRouteTransformerDirective {
|
||||
@Output() timestampClicked = new EventEmitter<number>()
|
||||
|
||||
constructor (private el: ElementRef, private router: Router) { }
|
||||
|
||||
@HostListener('click', ['$event'])
|
||||
public onClick ($event: ElementEvent) {
|
||||
if ($event.target.hasAttribute('href')) {
|
||||
const ngxLink = document.createElement('a')
|
||||
ngxLink.href = $event.target.getAttribute('href')
|
||||
|
||||
// we only care about reflective links
|
||||
if (ngxLink.host !== window.location.host) return
|
||||
|
||||
const ngxLinkParams = new URLSearchParams(ngxLink.search)
|
||||
if (ngxLinkParams.has('start')) {
|
||||
const separators = ['h', 'm', 's']
|
||||
const start = ngxLinkParams
|
||||
.get('start')
|
||||
.match(new RegExp('(\\d{1,9}[' + separators.join('') + '])','g')) // match digits before any given separator
|
||||
.map(t => {
|
||||
if (t.includes('h')) return parseInt(t, 10) * 3600
|
||||
if (t.includes('m')) return parseInt(t, 10) * 60
|
||||
return parseInt(t, 10)
|
||||
})
|
||||
.reduce((acc, t) => acc + t)
|
||||
this.timestampClicked.emit(start)
|
||||
}
|
||||
|
||||
$event.preventDefault()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
|
@ -75,6 +75,14 @@ export class MarkdownService {
|
|||
return this.render('completeMarkdownIt', markdown)
|
||||
}
|
||||
|
||||
async processVideoTimestamps (html: string) {
|
||||
return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
|
||||
const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
|
||||
const url = buildVideoLink({ startTime: t })
|
||||
return `<a class="video-timestamp" href="${url}">${str}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
private async render (name: keyof MarkdownParsers, markdown: string) {
|
||||
if (!markdown) return ''
|
||||
|
||||
|
@ -91,14 +99,6 @@ export class MarkdownService {
|
|||
return html
|
||||
}
|
||||
|
||||
async processVideoTimestamps (html: string) {
|
||||
return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
|
||||
const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
|
||||
const url = buildVideoLink({ startTime: t })
|
||||
return `<a href="${url}">${str}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
private async createMarkdownIt (config: MarkdownConfig) {
|
||||
// FIXME: import('...') returns a struct module, containing a "default" field corresponding to our sanitizeHtml function
|
||||
const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
|
||||
|
@ -139,7 +139,7 @@ export class MarkdownService {
|
|||
private avoidTruncatedTags (html: string) {
|
||||
return html.replace(/\*\*?([^*]+)$/, '$1')
|
||||
.replace(/<a[^>]+>([^<]+)<\/a>\s*...((<\/p>)|(<\/li>)|(<\/strong>))?$/mi, '$1...')
|
||||
.replace(/\[[^\]]+\]?\(?([^\)]+)$/, '$1')
|
||||
.replace(/\[[^\]]+\]\(([^\)]+)$/m, '$1')
|
||||
.replace(/\s?\[[^\]]+\]?[.]{3}<\/p>$/m, '...</p>')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,7 +23,12 @@
|
|||
<a [routerLink]="['/videos/watch', video.uuid, { 'threadId': comment.threadId }]"
|
||||
class="comment-date">{{ comment.createdAt | myFromNow }}</a>
|
||||
</div>
|
||||
<div class="comment-html" [innerHTML]="sanitizedCommentHTML"></div>
|
||||
<div
|
||||
class="comment-html"
|
||||
[innerHTML]="sanitizedCommentHTML"
|
||||
(timestampClicked)="handleTimestampClicked($event)"
|
||||
timestampRouteTransformer
|
||||
></div>
|
||||
|
||||
<div class="comment-actions">
|
||||
<div *ngIf="isUserLoggedIn()" (click)="onWantToReply()" class="comment-action-reply" i18n>Reply</div>
|
||||
|
@ -65,6 +70,7 @@
|
|||
(wantedToReply)="onWantToReply($event)"
|
||||
(wantedToDelete)="onWantToDelete($event)"
|
||||
(resetReply)="onResetReply()"
|
||||
(timestampClicked)="handleTimestampClicked($event)"
|
||||
></my-video-comment>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -4,7 +4,7 @@ import { VideoCommentThreadTree } from '../../../../../../shared/models/videos/v
|
|||
import { AuthService } from '../../../core/auth'
|
||||
import { Video } from '../../../shared/video/video.model'
|
||||
import { VideoComment } from './video-comment.model'
|
||||
import { HtmlRendererService, MarkdownService } from '@app/shared/renderer'
|
||||
import { MarkdownService } from '@app/shared/renderer'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-comment',
|
||||
|
@ -23,12 +23,12 @@ export class VideoCommentComponent implements OnInit, OnChanges {
|
|||
@Output() wantedToReply = new EventEmitter<VideoComment>()
|
||||
@Output() threadCreated = new EventEmitter<VideoCommentThreadTree>()
|
||||
@Output() resetReply = new EventEmitter()
|
||||
@Output() timestampClicked = new EventEmitter<number>()
|
||||
|
||||
sanitizedCommentHTML = ''
|
||||
newParentComments: VideoComment[] = []
|
||||
|
||||
constructor (
|
||||
private htmlRenderer: HtmlRendererService,
|
||||
private markdownService: MarkdownService,
|
||||
private authService: AuthService
|
||||
) {}
|
||||
|
@ -78,8 +78,12 @@ export class VideoCommentComponent implements OnInit, OnChanges {
|
|||
this.resetReply.emit()
|
||||
}
|
||||
|
||||
handleTimestampClicked (timestamp: number) {
|
||||
this.timestampClicked.emit(timestamp)
|
||||
}
|
||||
|
||||
isRemovableByUser () {
|
||||
return this.isUserLoggedIn() &&
|
||||
return this.comment.account && this.isUserLoggedIn() &&
|
||||
(
|
||||
this.user.account.id === this.comment.account.id ||
|
||||
this.user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT)
|
||||
|
@ -87,8 +91,8 @@ export class VideoCommentComponent implements OnInit, OnChanges {
|
|||
}
|
||||
|
||||
private async init () {
|
||||
const safeHTML = await this.htmlRenderer.toSafeHtml(this.comment.text)
|
||||
this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(safeHTML)
|
||||
const html = await this.markdownService.textMarkdownToHTML(this.comment.text, true)
|
||||
this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html)
|
||||
this.newParentComments = this.parentComments.concat([ this.comment ])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
(wantedToDelete)="onWantedToDelete($event)"
|
||||
(threadCreated)="onThreadCreated($event)"
|
||||
(resetReply)="onResetReply()"
|
||||
(timestampClicked)="handleTimestampClicked($event)"
|
||||
></my-video-comment>
|
||||
</div>
|
||||
|
||||
|
@ -54,6 +55,7 @@
|
|||
(wantedToDelete)="onWantedToDelete($event)"
|
||||
(threadCreated)="onThreadCreated($event)"
|
||||
(resetReply)="onResetReply()"
|
||||
(timestampClicked)="handleTimestampClicked($event)"
|
||||
></my-video-comment>
|
||||
|
||||
<div *ngIf="comment.totalReplies !== 0 && !threadComments[comment.id]" (click)="viewReplies(comment.id)" class="view-replies">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'
|
||||
import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild, Output, EventEmitter } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ConfirmService, Notifier } from '@app/core'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
|
@ -24,6 +24,8 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
|||
@Input() video: VideoDetails
|
||||
@Input() user: User
|
||||
|
||||
@Output() timestampClicked = new EventEmitter<number>()
|
||||
|
||||
comments: VideoComment[] = []
|
||||
highlightedThread: VideoComment
|
||||
sort: VideoSortField = '-createdAt'
|
||||
|
@ -150,6 +152,10 @@ export class VideoCommentsComponent implements OnInit, OnChanges, OnDestroy {
|
|||
this.viewReplies(commentTree.comment.id)
|
||||
}
|
||||
|
||||
handleTimestampClicked (timestamp: number) {
|
||||
this.timestampClicked.emit(timestamp)
|
||||
}
|
||||
|
||||
async onWantedToDelete (commentToDelete: VideoComment) {
|
||||
let message = 'Do you really want to delete this comment?'
|
||||
|
||||
|
|
|
@ -162,7 +162,12 @@
|
|||
</div>
|
||||
|
||||
<div class="video-info-description">
|
||||
<div class="video-info-description-html" [innerHTML]="videoHTMLDescription"></div>
|
||||
<div
|
||||
class="video-info-description-html"
|
||||
[innerHTML]="videoHTMLDescription"
|
||||
(timestampClicked)="handleTimestampClicked($event)"
|
||||
timestampRouteTransformer
|
||||
></div>
|
||||
|
||||
<div class="video-info-description-more" *ngIf="completeDescriptionShown === false && video.description?.length >= 250" (click)="showMoreDescription()">
|
||||
<ng-container i18n>Show more</ng-container>
|
||||
|
@ -223,7 +228,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<my-video-comments class="border-top" [video]="video" [user]="user"></my-video-comments>
|
||||
<my-video-comments
|
||||
class="border-top"
|
||||
[video]="video"
|
||||
[user]="user"
|
||||
(timestampClicked)="handleTimestampClicked($event)"
|
||||
></my-video-comments>
|
||||
</div>
|
||||
|
||||
<my-recommended-videos
|
||||
|
|
|
@ -330,6 +330,10 @@ $video-info-margin-left: 44px;
|
|||
|
||||
.video-info-description-html {
|
||||
@include peertube-word-wrap;
|
||||
|
||||
/deep/ a {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.glyphicon, .description-loading {
|
||||
|
|
|
@ -38,6 +38,7 @@ import { HooksService } from '@app/core/plugins/hooks.service'
|
|||
import { PlatformLocation } from '@angular/common'
|
||||
import { randomInt } from '@shared/core-utils/miscs/miscs'
|
||||
import { RecommendedVideosComponent } from '../recommendations/recommended-videos.component'
|
||||
import { scrollToTop } from '@app/shared/misc/utils'
|
||||
|
||||
@Component({
|
||||
selector: 'my-video-watch',
|
||||
|
@ -138,9 +139,12 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
if (playlistId) this.loadPlaylist(playlistId)
|
||||
})
|
||||
|
||||
this.queryParamsSub = this.route.queryParams.subscribe(queryParams => {
|
||||
this.queryParamsSub = this.route.queryParams.subscribe(async queryParams => {
|
||||
const videoId = queryParams[ 'videoId' ]
|
||||
if (videoId) this.loadVideo(videoId)
|
||||
if (videoId) await this.loadVideo(videoId)
|
||||
|
||||
const start = queryParams[ 'start' ]
|
||||
if (this.player && start) this.player.currentTime(parseInt(start, 10))
|
||||
})
|
||||
|
||||
this.initHotkeys()
|
||||
|
@ -284,6 +288,11 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
|
|||
)
|
||||
}
|
||||
|
||||
handleTimestampClicked (timestamp: number) {
|
||||
if (this.player) this.player.currentTime(timestamp)
|
||||
scrollToTop()
|
||||
}
|
||||
|
||||
isPlaylistAutoPlayEnabled () {
|
||||
return (
|
||||
(this.user && this.user.autoPlayNextVideoPlaylist) ||
|
||||
|
|
|
@ -13,6 +13,7 @@ import { RecommendationsModule } from '@app/videos/recommendations/recommendatio
|
|||
import { VideoWatchPlaylistComponent } from '@app/videos/+video-watch/video-watch-playlist.component'
|
||||
import { QRCodeModule } from 'angularx-qrcode'
|
||||
import { InputSwitchModule } from 'primeng/inputswitch'
|
||||
import { TimestampRouteTransformerDirective } from '@app/shared/angular/timestamp-route-transformer.directive'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
@ -32,11 +33,15 @@ import { InputSwitchModule } from 'primeng/inputswitch'
|
|||
VideoSupportComponent,
|
||||
VideoCommentsComponent,
|
||||
VideoCommentAddComponent,
|
||||
VideoCommentComponent
|
||||
VideoCommentComponent,
|
||||
|
||||
TimestampRouteTransformerDirective
|
||||
],
|
||||
|
||||
exports: [
|
||||
VideoWatchComponent
|
||||
VideoWatchComponent,
|
||||
|
||||
TimestampRouteTransformerDirective
|
||||
],
|
||||
|
||||
providers: [
|
||||
|
|
Loading…
Reference in New Issue