1153 lines
29 KiB
TypeScript
Raw Normal View History

2018-04-02 05:34:16 +00:00
import Application from "./Application"
import Diff from "./Diff"
import StatusMessage from "./StatusMessage"
import PushManager from "./PushManager"
import TouchController from "./TouchController"
import NotificationManager from "./NotificationManager"
import AudioPlayer from "./AudioPlayer"
import Analytics from "./Analytics"
import SideBar from "./SideBar"
import InfiniteScroller from "./InfiniteScroller"
import ServiceWorkerManager from "./ServiceWorkerManager"
2017-11-22 11:20:57 +00:00
import { displayAiringDate, displayDate, displayTime } from "./DateView"
2018-04-02 05:34:16 +00:00
import { findAll, canUseWebP, requestIdleCallback, swapElements, delay } from "./Utils"
2018-04-08 11:51:12 +00:00
import { checkNewVersionDelayed } from "./NewVersionCheck"
2017-10-20 00:43:02 +00:00
import * as actions from "./Actions"
2017-06-29 06:32:46 +00:00
2018-04-02 05:34:16 +00:00
export default class AnimeNotifier {
2017-06-19 14:49:24 +00:00
app: Application
2017-10-01 05:50:53 +00:00
analytics: Analytics
2017-06-30 15:51:17 +00:00
user: HTMLElement
2017-07-06 20:08:49 +00:00
title: string
2017-07-08 13:40:13 +00:00
webpEnabled: boolean
2017-07-14 02:58:21 +00:00
contentLoadedActions: Promise<any>
2017-07-12 18:37:34 +00:00
statusMessage: StatusMessage
2017-07-05 19:06:38 +00:00
visibilityObserver: IntersectionObserver
2017-07-14 21:50:34 +00:00
pushManager: PushManager
2017-10-20 00:43:02 +00:00
serviceWorkerManager: ServiceWorkerManager
2018-02-28 15:26:49 +00:00
notificationManager: NotificationManager
2017-07-19 16:47:17 +00:00
touchController: TouchController
2018-03-24 22:04:31 +00:00
audioPlayer: AudioPlayer
2017-10-02 00:02:07 +00:00
sideBar: SideBar
2017-10-16 10:56:46 +00:00
infiniteScroller: InfiniteScroller
2017-07-19 04:55:21 +00:00
mainPageLoaded: boolean
2017-10-14 10:45:22 +00:00
isLoading: boolean
2018-03-16 18:39:48 +00:00
diffCompletedForCurrentPath: boolean
2017-07-19 05:39:09 +00:00
lastReloadContentPath: string
2018-03-11 14:43:17 +00:00
currentSoundTrackId: string
2017-07-05 19:06:38 +00:00
2017-06-19 14:49:24 +00:00
constructor(app: Application) {
this.app = app
2017-06-30 15:51:17 +00:00
this.user = null
2017-07-06 20:08:49 +00:00
this.title = "Anime Notifier"
2017-10-14 10:45:22 +00:00
this.isLoading = true
2017-07-19 02:18:56 +00:00
// These classes will never be removed on DOM diffs
Diff.persistentClasses.add("mounted")
2017-10-12 15:52:46 +00:00
Diff.persistentClasses.add("element-found")
Diff.persistentClasses.add("active")
2017-07-19 02:18:56 +00:00
2017-07-19 07:09:55 +00:00
// Never remove src property on diffs
Diff.persistentAttributes.add("src")
// Is intersection observer supported?
if("IntersectionObserver" in window) {
// Enable lazy load
this.visibilityObserver = new IntersectionObserver(
entries => {
for(let entry of entries) {
2017-11-19 12:51:23 +00:00
if(entry.isIntersecting) {
entry.target["became visible"]()
this.visibilityObserver.unobserve(entry.target)
}
2017-06-24 14:17:38 +00:00
}
},
{}
)
} else {
// Disable lazy load feature
this.visibilityObserver = {
disconnect: () => {},
observe: (elem: HTMLElement) => {
elem["became visible"]()
},
unobserve: (elem: HTMLElement) => {}
} as IntersectionObserver
}
2017-06-19 14:49:24 +00:00
}
2017-06-30 15:51:17 +00:00
init() {
2017-07-21 10:55:36 +00:00
// App init
this.app.init()
// Event listeners
2017-06-30 15:51:17 +00:00
document.addEventListener("readystatechange", this.onReadyStateChange.bind(this))
document.addEventListener("DOMContentLoaded", this.onContentLoaded.bind(this))
2018-04-24 11:29:46 +00:00
// If we finished loading the DOM (either "interactive" or "complete" state),
// immediately trigger the event listener functions.
if(document.readyState !== "loading") {
this.app.emit("DOMContentLoaded")
this.run()
}
2017-06-30 15:51:17 +00:00
2017-07-14 02:58:21 +00:00
// Idle
2018-04-02 05:34:16 +00:00
requestIdleCallback(this.onIdle.bind(this))
2017-06-30 15:51:17 +00:00
}
2017-06-19 15:45:27 +00:00
onReadyStateChange() {
if(document.readyState !== "interactive") {
return
}
this.run()
}
2017-06-19 14:49:24 +00:00
run() {
2017-07-08 13:40:13 +00:00
// Check for WebP support
this.webpEnabled = canUseWebP()
2017-07-03 23:20:27 +00:00
// Initiate the elements we need
2018-04-02 05:44:11 +00:00
this.user = document.getElementById("user")
this.app.content = document.getElementById("content")
this.app.loading = document.getElementById("loading")
2017-07-03 23:20:27 +00:00
2017-11-17 11:51:08 +00:00
// Theme
if(this.user && this.user.dataset.pro === "true" && this.user.dataset.theme !== "light") {
2018-04-23 14:51:11 +00:00
actions.applyTheme(this.user.dataset.theme)
2017-11-17 11:51:08 +00:00
}
2017-07-12 18:37:34 +00:00
// Status message
this.statusMessage = new StatusMessage(
2018-04-02 05:44:11 +00:00
document.getElementById("status-message"),
document.getElementById("status-message-text")
2017-07-12 18:37:34 +00:00
)
2018-04-02 15:20:25 +00:00
this.app.onError = (error: Error) => {
this.statusMessage.showError(error, 3000)
}
2017-07-14 21:50:34 +00:00
// Push manager
this.pushManager = new PushManager()
2017-07-19 14:56:02 +00:00
2018-02-28 15:26:49 +00:00
// Notification manager
this.notificationManager = new NotificationManager()
2018-03-24 22:04:31 +00:00
// Audio player
this.audioPlayer = new AudioPlayer(this)
2017-10-01 05:50:53 +00:00
// Analytics
this.analytics = new Analytics()
2017-07-19 14:56:02 +00:00
// Sidebar control
2018-04-02 05:44:11 +00:00
this.sideBar = new SideBar(document.getElementById("sidebar"))
2017-10-16 10:56:46 +00:00
// Infinite scrolling
2018-03-10 19:17:08 +00:00
this.infiniteScroller = new InfiniteScroller(this.app.content.parentElement, 150)
2017-11-09 16:18:15 +00:00
2017-10-14 10:45:22 +00:00
// Loading
this.loading(false)
2017-06-19 14:49:24 +00:00
}
2017-06-19 15:45:27 +00:00
2017-10-01 05:56:30 +00:00
onContentLoaded() {
2017-06-30 15:51:17 +00:00
// Stop watching all the objects from the previous page.
2017-06-24 14:17:38 +00:00
this.visibilityObserver.disconnect()
2017-11-09 16:18:15 +00:00
2017-07-14 02:58:21 +00:00
this.contentLoadedActions = Promise.all([
2017-09-22 04:19:32 +00:00
Promise.resolve().then(() => this.mountMountables()),
2017-10-12 15:52:46 +00:00
Promise.resolve().then(() => this.lazyLoad()),
2017-07-14 02:58:21 +00:00
Promise.resolve().then(() => this.displayLocalDates()),
Promise.resolve().then(() => this.setSelectBoxValue()),
2018-03-11 14:43:17 +00:00
Promise.resolve().then(() => this.markPlayingSoundTrack()),
2017-07-14 23:32:06 +00:00
Promise.resolve().then(() => this.assignActions()),
2017-07-17 01:14:05 +00:00
Promise.resolve().then(() => this.updatePushUI()),
2017-10-04 11:39:59 +00:00
Promise.resolve().then(() => this.dragAndDrop()),
2018-03-21 21:57:06 +00:00
Promise.resolve().then(() => this.colorStripes()),
2018-10-27 02:01:21 +00:00
Promise.resolve().then(() => this.loadCharacterRanking()),
2018-04-18 18:40:45 +00:00
Promise.resolve().then(() => this.assignTooltipOffsets()),
Promise.resolve().then(() => this.countUp())
2017-07-14 02:58:21 +00:00
])
2017-07-06 20:08:49 +00:00
2017-07-14 23:32:06 +00:00
// Apply page title
2018-06-28 06:30:24 +00:00
this.applyPageTitle()
}
applyPageTitle() {
2017-07-06 20:08:49 +00:00
let headers = document.getElementsByTagName("h1")
2018-06-28 06:30:24 +00:00
if(this.app.currentPath === "/" || headers.length === 0 || headers[0].textContent === "NOTIFY.MOE") {
2017-07-06 20:08:49 +00:00
if(document.title !== this.title) {
document.title = this.title
}
} else {
2018-06-28 06:30:24 +00:00
document.title = headers[0].textContent
2017-07-06 20:08:49 +00:00
}
2017-06-29 13:55:04 +00:00
}
async onIdle() {
2018-04-24 11:29:46 +00:00
// Register event listeners
document.addEventListener("keydown", this.onKeyDown.bind(this), false)
window.addEventListener("popstate", this.onPopState.bind(this))
window.addEventListener("error", this.onError.bind(this))
2017-07-19 03:23:06 +00:00
// Service worker
2017-11-07 08:06:04 +00:00
this.serviceWorkerManager = new ServiceWorkerManager(this, "/service-worker")
2017-10-20 00:43:02 +00:00
this.serviceWorkerManager.register()
2017-07-19 03:23:06 +00:00
// Analytics
2017-10-01 05:50:53 +00:00
if(this.user) {
this.analytics.push()
}
2017-07-15 00:01:14 +00:00
2017-07-19 03:23:06 +00:00
// Offline message
2017-07-15 00:01:14 +00:00
if(navigator.onLine === false) {
2018-04-17 23:08:04 +00:00
this.statusMessage.showInfo("You are viewing an offline version of the site now.")
2017-07-15 00:01:14 +00:00
}
2018-02-28 15:26:49 +00:00
// Notification manager
if(this.user) {
this.notificationManager.update()
// Periodically check notifications
2018-04-13 13:26:21 +00:00
setInterval(() => this.notificationManager.update(), 300000)
2018-02-28 15:26:49 +00:00
}
2018-03-19 22:49:58 +00:00
// Bind unload event
window.addEventListener("beforeunload", this.onBeforeUnload.bind(this))
2018-04-08 11:31:58 +00:00
// Periodically check etags of scripts and styles to let the user know about page updates
2018-04-08 11:51:12 +00:00
checkNewVersionDelayed("/scripts", this.statusMessage)
checkNewVersionDelayed("/styles", this.statusMessage)
2018-04-08 10:59:36 +00:00
// Show microphone icon if speech input is available
if(window["SpeechRecognition"] || window["webkitSpeechRecognition"]) {
document.getElementsByClassName("speech-input")[0].classList.add("speech-input-available")
}
2018-10-28 02:31:43 +00:00
// Ensure a minimum size for the desktop app
const minWidth = 1420
const minHeight = 800
if(window.outerWidth <= minWidth || window.outerHeight <= minHeight) {
let finalWidth = window.outerWidth < minWidth ? minWidth : window.outerWidth
let finalHeight = window.outerHeight < minHeight ? minHeight : window.outerHeight
window.resizeTo(finalWidth, finalHeight)
}
2018-04-08 10:59:36 +00:00
// // Download popular anime titles for the search
// let response = await fetch("/api/popular/anime/titles/500")
// let titles = await response.json()
// let titleList = document.createElement("datalist")
// titleList.id = "popular-anime-titles-list"
// for(let title of titles) {
// let option = document.createElement("option")
// option.value = title
// titleList.appendChild(option)
// }
// document.body.appendChild(titleList)
2018-04-02 05:44:11 +00:00
// let search = document.getElementById("search") as HTMLInputElement
// search.setAttribute("list", titleList.id)
2017-07-13 15:56:14 +00:00
}
2018-03-19 22:49:58 +00:00
async onBeforeUnload(e: BeforeUnloadEvent) {
let message = undefined
// Prevent closing tab on new thread page
if(this.app.currentPath === "/new/thread" && document.activeElement.tagName === "TEXTAREA" && (document.activeElement as HTMLTextAreaElement).value.length > 20) {
message = "You have unsaved changes on the current page. Are you sure you want to leave?"
}
if(message) {
e.returnValue = message
return message
}
}
2018-04-18 18:40:45 +00:00
assignTooltipOffsets(elements?: IterableIterator<HTMLElement>) {
requestIdleCallback(() => {
const distanceToBorder = 5
let contentRect: ClientRect
if(!elements) {
elements = findAll("tip")
}
// Assign mouse enter event handler
for(let element of elements) {
2018-04-19 09:31:01 +00:00
Diff.mutations.queue(() => {
element.classList.add("tip-active")
})
2018-04-20 08:13:49 +00:00
element.onmouseenter = () => {
Diff.mutations.queue(() => {
if(!contentRect) {
contentRect = this.app.content.getBoundingClientRect()
}
2018-04-19 09:31:01 +00:00
2018-04-22 12:45:06 +00:00
// Dynamic label assignment to prevent label texts overflowing
// and taking horizontal space at page load.
element.dataset.label = element.getAttribute("aria-label")
// This is the most expensive call in this whole function,
// it consumes about 2-4 ms every time you call it.
let rect = element.getBoundingClientRect()
2018-04-22 12:45:06 +00:00
// Calculate offsets
let tipStyle = window.getComputedStyle(element, ":before")
let tipWidth = parseInt(tipStyle.width) + parseInt(tipStyle.paddingLeft) * 2
let tipStartX = rect.left + rect.width / 2 - tipWidth / 2 - contentRect.left
let tipEndX = tipStartX + tipWidth
let leftOffset = 0
if(tipStartX < distanceToBorder) {
leftOffset = -tipStartX + distanceToBorder
} else if(tipEndX > contentRect.width - distanceToBorder) {
leftOffset = -(tipEndX - contentRect.width + distanceToBorder)
}
if(leftOffset !== 0) {
element.classList.remove("tip-active")
element.classList.add("tip-offset-root")
let tipChild = document.createElement("div")
tipChild.classList.add("tip-offset-child")
2018-04-22 12:45:06 +00:00
tipChild.setAttribute("data-label", element.getAttribute("data-label"))
tipChild.style.left = Math.round(leftOffset) + "px"
tipChild.style.width = rect.width + "px"
tipChild.style.height = rect.height + "px"
element.appendChild(tipChild)
}
2018-04-22 12:45:06 +00:00
// Unassign event listener
element.onmouseenter = null
})
}
2018-04-19 09:31:01 +00:00
}
})
}
2017-10-04 11:39:59 +00:00
dragAndDrop() {
for(let element of findAll("inventory-slot")) {
2017-10-05 07:39:37 +00:00
// Skip elements that have their event listeners attached already
if(element["listeners-attached"]) {
continue
}
2017-10-05 07:54:11 +00:00
element.addEventListener("dragstart", e => {
if(!element.draggable) {
return
}
e.dataTransfer.setData("text", element.dataset.index)
}, false)
element.addEventListener("dblclick", e => {
if(!element.draggable) {
return
}
2018-04-18 11:42:38 +00:00
let itemName = element.getAttribute("aria-label")
2017-10-05 07:54:11 +00:00
if(element.dataset.consumable !== "true") {
return this.statusMessage.showError(itemName + " is not a consumable item.")
}
2017-11-09 16:18:15 +00:00
2017-10-05 07:54:11 +00:00
let apiEndpoint = this.findAPIEndpoint(element)
2017-11-09 16:18:15 +00:00
2017-10-05 07:54:11 +00:00
this.post(apiEndpoint + "/use/" + element.dataset.index, "")
.then(() => this.reloadContent())
.then(() => this.statusMessage.showInfo(`You used ${itemName}.`))
.catch(err => this.statusMessage.showError(err))
}, false)
2017-10-04 11:39:59 +00:00
element.addEventListener("dragenter", e => {
element.classList.add("drag-enter")
}, false)
element.addEventListener("dragleave", e => {
element.classList.remove("drag-enter")
}, false)
element.addEventListener("dragover", e => {
e.preventDefault()
}, false)
element.addEventListener("drop", e => {
let toElement = e.toElement as HTMLElement
2017-10-05 07:54:11 +00:00
toElement.classList.remove("drag-enter")
2017-10-04 11:39:59 +00:00
e.stopPropagation()
e.preventDefault()
2017-10-05 07:54:11 +00:00
let inventory = e.toElement.parentElement
let fromIndex = e.dataTransfer.getData("text")
if(!fromIndex) {
return
}
let fromElement = inventory.childNodes[fromIndex] as HTMLElement
2017-11-09 16:18:15 +00:00
2017-10-05 07:54:11 +00:00
let toIndex = toElement.dataset.index
2017-10-04 11:39:59 +00:00
if(fromElement === toElement || fromIndex === toIndex) {
return
}
2017-10-05 07:39:37 +00:00
// Swap in database
let apiEndpoint = this.findAPIEndpoint(inventory)
2017-11-09 16:18:15 +00:00
2017-10-05 07:39:37 +00:00
this.post(apiEndpoint + "/swap/" + fromIndex + "/" + toIndex, "")
.catch(err => this.statusMessage.showError(err))
// Swap in UI
2017-10-04 11:39:59 +00:00
swapElements(fromElement, toElement)
2017-11-09 16:18:15 +00:00
2017-10-04 11:39:59 +00:00
fromElement.dataset.index = toIndex
toElement.dataset.index = fromIndex
}, false)
2017-10-05 07:39:37 +00:00
// Prevent re-attaching the same listeners
element["listeners-attached"] = true
2017-10-04 11:39:59 +00:00
}
}
async updatePushUI() {
2017-11-07 08:13:48 +00:00
if(!this.app.currentPath.includes("/settings/notifications")) {
return
}
2018-04-02 05:44:11 +00:00
let enableButton = document.getElementById("enable-notifications") as HTMLButtonElement
let disableButton = document.getElementById("disable-notifications") as HTMLButtonElement
let testButton = document.getElementById("test-notification") as HTMLButtonElement
2017-11-07 08:13:48 +00:00
if(!this.pushManager.pushSupported) {
2018-03-15 20:08:30 +00:00
enableButton.classList.add("hidden")
disableButton.classList.add("hidden")
2017-11-07 08:18:57 +00:00
testButton.innerHTML = "Your browser doesn't support push notifications!"
2017-10-04 11:39:59 +00:00
return
}
2017-11-09 16:18:15 +00:00
2017-10-04 11:39:59 +00:00
let subscription = await this.pushManager.subscription()
if(subscription) {
2018-03-15 20:08:30 +00:00
enableButton.classList.add("hidden")
disableButton.classList.remove("hidden")
2017-10-04 11:39:59 +00:00
} else {
2018-03-15 20:08:30 +00:00
enableButton.classList.remove("hidden")
disableButton.classList.add("hidden")
2017-10-04 11:39:59 +00:00
}
}
2018-10-27 02:01:21 +00:00
loadCharacterRanking() {
if(!this.app.currentPath.includes("/character/")) {
return
}
for(let element of findAll("character-ranking")) {
fetch(`/api/character/${element.dataset.characterId}/ranking`).then(async response => {
2018-10-29 10:52:25 +00:00
let ranking = await response.json()
2018-10-27 02:01:21 +00:00
2018-10-29 10:52:25 +00:00
if(!ranking.rank) {
2018-10-27 02:01:21 +00:00
return
}
Diff.mutations.queue(() => {
2018-10-29 10:52:25 +00:00
let percentile = Math.ceil(ranking.percentile * 100)
element.textContent = "#" + ranking.rank.toString()
element.title = "Top " + percentile + "%"
2018-10-27 02:01:21 +00:00
})
})
}
}
2018-03-21 21:57:06 +00:00
colorStripes() {
if(!this.app.currentPath.includes("/explore/color/")) {
return
}
for(let element of findAll("color-stripe")) {
Diff.mutations.queue(() => {
element.style.backgroundColor = element.dataset.color
})
}
}
2017-07-17 01:14:05 +00:00
countUp() {
2017-11-10 07:41:45 +00:00
if(!this.app.currentPath.includes("/paypal/success")) {
return
}
2017-07-17 01:14:05 +00:00
for(let element of findAll("count-up")) {
2018-06-28 06:30:24 +00:00
let final = parseInt(element.textContent)
2017-07-17 01:14:05 +00:00
let duration = 2000.0
let start = Date.now()
2018-06-28 06:30:24 +00:00
element.textContent = "0"
2017-07-17 01:14:05 +00:00
let callback = () => {
let progress = (Date.now() - start) / duration
if(progress > 1) {
progress = 1
}
2018-06-28 06:30:24 +00:00
element.textContent = String(Math.round(progress * final))
2017-07-17 01:14:05 +00:00
if(progress < 1) {
window.requestAnimationFrame(callback)
}
}
window.requestAnimationFrame(callback)
}
}
2018-03-11 14:43:17 +00:00
markPlayingSoundTrack() {
for(let element of findAll("soundtrack-play-area")) {
if(element.dataset.soundtrackId === this.currentSoundTrackId) {
element.classList.add("playing")
}
}
}
2017-06-29 13:55:04 +00:00
setSelectBoxValue() {
for(let element of document.getElementsByTagName("select")) {
element.value = element.getAttribute("value")
}
2017-06-29 06:32:46 +00:00
}
displayLocalDates() {
const now = new Date()
2017-07-07 15:16:40 +00:00
for(let element of findAll("utc-airing-date")) {
displayAiringDate(element, now)
}
2017-06-29 06:32:46 +00:00
for(let element of findAll("utc-date")) {
2017-07-07 15:16:40 +00:00
displayDate(element, now)
2017-06-29 06:32:46 +00:00
}
2017-11-22 11:20:57 +00:00
for(let element of findAll("utc-date-absolute")) {
displayTime(element, now)
}
2017-06-24 14:17:38 +00:00
}
2017-07-19 06:45:41 +00:00
reloadContent(cached?: boolean) {
2017-07-13 22:11:25 +00:00
let headers = new Headers()
2017-07-19 06:45:41 +00:00
2018-04-17 23:08:04 +00:00
if(cached) {
headers.set("X-Force-Cache", "true")
2017-07-19 06:45:41 +00:00
} else {
2018-04-17 23:08:04 +00:00
headers.set("X-No-Cache", "true")
2017-07-19 06:45:41 +00:00
}
2017-07-13 22:11:25 +00:00
2018-04-17 23:08:04 +00:00
let path = this.lastReloadContentPath = this.app.currentPath
2017-07-13 23:07:49 +00:00
2017-07-19 04:55:21 +00:00
return fetch("/_" + path, {
2017-07-13 22:11:25 +00:00
credentials: "same-origin",
headers
})
.then(response => {
2017-07-13 23:07:49 +00:00
if(this.app.currentPath !== path) {
return Promise.reject("old request")
}
return Promise.resolve(response)
2017-06-21 16:44:20 +00:00
})
.then(response => response.text())
.then(html => Diff.innerHTML(this.app.content, html))
2017-06-24 00:10:04 +00:00
.then(() => this.app.emit("DOMContentLoaded"))
2017-06-21 16:44:20 +00:00
}
2017-07-13 22:11:25 +00:00
reloadPage() {
2017-07-19 05:39:09 +00:00
console.log("reload page", this.app.currentPath)
2017-07-19 03:23:06 +00:00
let path = this.app.currentPath
2017-07-19 05:39:09 +00:00
this.lastReloadContentPath = path
2017-07-19 03:23:06 +00:00
return fetch(path, {
2017-07-19 04:32:31 +00:00
credentials: "same-origin"
2017-07-19 03:23:06 +00:00
})
.then(response => {
if(this.app.currentPath !== path) {
return Promise.reject("old request")
}
return Promise.resolve(response)
})
.then(response => response.text())
2017-11-10 07:41:45 +00:00
.then(html => Diff.root(document.documentElement, html))
2017-07-19 03:23:06 +00:00
.then(() => this.app.emit("DOMContentLoaded"))
2017-07-19 03:27:37 +00:00
.then(() => this.loading(false)) // Because our loading element gets reset due to full page diff
2017-07-13 22:11:25 +00:00
}
2017-10-14 10:45:22 +00:00
loading(newState: boolean) {
this.isLoading = newState
if(this.isLoading) {
2017-07-19 07:09:55 +00:00
document.documentElement.style.cursor = "progress"
2017-06-20 10:41:26 +00:00
this.app.loading.classList.remove(this.app.fadeOutClass)
} else {
2017-07-19 07:09:55 +00:00
document.documentElement.style.cursor = "auto"
2017-06-20 10:41:26 +00:00
this.app.loading.classList.add(this.app.fadeOutClass)
}
}
2017-11-09 16:18:15 +00:00
2017-06-26 01:57:29 +00:00
assignActions() {
2017-06-21 13:29:06 +00:00
for(let element of findAll("action")) {
2017-07-08 21:27:24 +00:00
let actionTrigger = element.dataset.trigger
2017-06-20 10:41:26 +00:00
let actionName = element.dataset.action
2018-03-24 20:20:03 +00:00
// Filter out invalid definitions
2017-10-05 19:27:49 +00:00
if(!actionTrigger || !actionName) {
continue
}
2017-07-08 21:27:24 +00:00
let oldAction = element["action assigned"]
if(oldAction) {
if(oldAction.trigger === actionTrigger && oldAction.action === actionName) {
continue
}
element.removeEventListener(oldAction.trigger, oldAction.handler)
}
// This prevents default actions on links
if(actionTrigger === "click" && element.tagName === "A") {
element.onclick = null
}
2018-03-24 20:20:03 +00:00
// Warn us about undefined actions
2017-10-05 11:48:16 +00:00
if(!(actionName in actions)) {
this.statusMessage.showError(`Action '${actionName}' has not been defined`)
2017-10-05 19:27:49 +00:00
continue
2017-10-05 11:48:16 +00:00
}
2018-03-24 20:20:03 +00:00
// Register the actual action handler
2017-07-08 21:27:24 +00:00
let actionHandler = e => {
2017-06-20 20:54:45 +00:00
actions[actionName](this, element, e)
2017-06-26 01:57:29 +00:00
e.stopPropagation()
e.preventDefault()
2017-07-08 21:27:24 +00:00
}
element.addEventListener(actionTrigger, actionHandler)
2017-06-20 20:54:45 +00:00
2017-06-24 15:38:10 +00:00
// Use "action assigned" flag instead of removing the class.
// This will make sure that DOM diffs which restore the class name
// will not assign the action multiple times to the same element.
2017-07-08 21:27:24 +00:00
element["action assigned"] = {
trigger: actionTrigger,
action: actionName,
handler: actionHandler
}
2017-06-20 10:41:26 +00:00
}
2017-06-19 15:45:27 +00:00
}
2018-04-02 14:07:52 +00:00
lazyLoad(elements?: IterableIterator<Element>) {
if(!elements) {
elements = findAll("lazy")
}
for(let element of elements) {
2017-10-12 15:52:46 +00:00
switch(element.tagName) {
case "IMG":
this.lazyLoadImage(element as HTMLImageElement)
break
2017-11-09 16:18:15 +00:00
2018-04-15 09:14:11 +00:00
case "VIDEO":
this.lazyLoadVideo(element as HTMLVideoElement)
break
2017-10-12 15:52:46 +00:00
case "IFRAME":
this.lazyLoadIFrame(element as HTMLIFrameElement)
break
}
2017-06-24 14:17:38 +00:00
}
}
2018-03-20 22:58:37 +00:00
emptyPixel() {
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
}
2018-03-20 20:09:15 +00:00
2018-03-20 22:58:37 +00:00
lazyLoadImage(element: HTMLImageElement) {
2018-04-11 13:06:08 +00:00
let pixelRatio = window.devicePixelRatio
2017-06-24 14:17:38 +00:00
// Once the image becomes visible, load it
2017-10-12 15:52:46 +00:00
element["became visible"] = () => {
2017-11-09 17:10:10 +00:00
let dataSrc = element.dataset.src
let dotPos = dataSrc.lastIndexOf(".")
let base = dataSrc.substring(0, dotPos)
2017-11-09 17:10:10 +00:00
let extension = ""
2017-07-08 13:40:13 +00:00
// Replace URL with WebP if supported
2018-03-09 15:08:35 +00:00
if(this.webpEnabled && element.dataset.webp === "true") {
let queryPos = dataSrc.lastIndexOf("?")
if(queryPos !== -1) {
extension = ".webp" + dataSrc.substring(queryPos)
} else {
extension = ".webp"
}
2017-07-08 13:40:13 +00:00
} else {
extension = dataSrc.substring(dotPos)
2017-11-09 17:10:10 +00:00
}
2018-04-11 13:06:08 +00:00
// Anime and character images on Retina displays
if(pixelRatio > 1) {
if(base.includes("/anime/") || (base.includes("/characters/") && !base.includes("/large/"))) {
base += "@2"
}
2017-07-08 13:40:13 +00:00
}
2017-06-19 15:45:27 +00:00
2018-03-20 22:58:37 +00:00
let finalSrc = base + extension
2018-03-20 23:03:58 +00:00
if(element.src !== finalSrc && element.src !== "https:" + finalSrc && element.src !== "https://notify.moe" + finalSrc) {
2018-03-20 23:31:55 +00:00
// Show average color
if(element.dataset.color) {
element.src = this.emptyPixel()
element.style.backgroundColor = element.dataset.color
2018-03-20 23:43:56 +00:00
Diff.mutations.queue(() => element.classList.add("element-color-preview"))
2018-03-20 23:31:55 +00:00
}
2018-03-20 23:43:56 +00:00
Diff.mutations.queue(() => element.classList.remove("element-found"))
2018-03-20 22:58:37 +00:00
element.src = finalSrc
}
2017-11-09 17:10:10 +00:00
2017-10-12 15:52:46 +00:00
if(element.naturalWidth === 0) {
element.onload = () => {
2018-03-20 22:58:37 +00:00
if(element.src.startsWith("data:")) {
return
}
2018-03-20 23:43:56 +00:00
Diff.mutations.queue(() => element.classList.add("element-found"))
2017-06-19 15:45:27 +00:00
}
2017-10-12 15:52:46 +00:00
element.onerror = () => {
2018-03-20 20:22:16 +00:00
if(element.classList.contains("element-found")) {
return
}
2018-03-20 23:43:56 +00:00
Diff.mutations.queue(() => element.classList.add("element-not-found"))
2017-06-19 15:45:27 +00:00
}
} else {
2018-03-20 23:43:56 +00:00
Diff.mutations.queue(() => element.classList.add("element-found"))
2017-10-12 15:52:46 +00:00
}
}
this.visibilityObserver.observe(element)
}
lazyLoadIFrame(element: HTMLIFrameElement) {
// Once the iframe becomes visible, load it
element["became visible"] = () => {
// If the source is already set correctly, don't set it again to avoid iframe flickering.
2017-10-16 09:53:47 +00:00
if(element.src !== element.dataset.src && element.src !== (window.location.protocol + element.dataset.src)) {
2017-10-12 15:52:46 +00:00
element.src = element.dataset.src
2017-06-19 15:45:27 +00:00
}
2017-10-12 15:52:46 +00:00
2018-03-20 23:43:56 +00:00
Diff.mutations.queue(() => element.classList.add("element-found"))
2017-06-19 15:45:27 +00:00
}
2017-06-24 14:17:38 +00:00
2017-10-12 15:52:46 +00:00
this.visibilityObserver.observe(element)
2017-06-19 15:45:27 +00:00
}
2017-06-20 12:16:23 +00:00
2018-04-15 09:14:11 +00:00
lazyLoadVideo(video: HTMLVideoElement) {
// Once the video becomes visible, load it
video["became visible"] = () => {
video.pause()
2018-04-15 09:26:05 +00:00
// Prevent context menu
video.addEventListener("contextmenu", e => e.preventDefault())
2018-04-16 14:49:20 +00:00
let modified = false
2018-04-15 09:14:11 +00:00
for(let child of video.children) {
2018-04-16 14:49:20 +00:00
let element = child as HTMLSourceElement
2018-04-15 09:14:11 +00:00
2018-04-16 14:49:20 +00:00
if(element.src !== element.dataset.src) {
element.src = element.dataset.src
modified = true
}
2018-04-15 09:14:11 +00:00
}
2018-04-16 14:49:20 +00:00
if(modified) {
Diff.mutations.queue(() => {
video.load()
video.classList.add("element-found")
})
}
2018-04-15 09:14:11 +00:00
}
this.visibilityObserver.observe(video)
}
2018-04-02 14:07:52 +00:00
mountMountables(elements?: IterableIterator<HTMLElement>) {
if(!elements) {
elements = findAll("mountable")
}
this.modifyDelayed(elements, element => element.classList.add("mounted"))
2017-09-22 04:19:32 +00:00
}
2017-06-26 01:57:29 +00:00
2017-09-22 04:19:32 +00:00
unmountMountables() {
for(let element of findAll("mountable")) {
if(element.classList.contains("never-unmount")) {
continue
}
2017-07-03 15:21:00 +00:00
2018-03-20 23:43:56 +00:00
Diff.mutations.queue(() => element.classList.remove("mounted"))
2017-09-22 04:19:32 +00:00
}
}
2017-06-26 01:57:29 +00:00
2018-04-02 14:07:52 +00:00
modifyDelayed(elements: IterableIterator<HTMLElement>, func: (element: HTMLElement) => void) {
2018-04-22 16:14:44 +00:00
const maxDelay = 2500
2018-10-31 05:35:30 +00:00
const delay = 20
2017-11-09 16:18:15 +00:00
2017-09-22 04:19:32 +00:00
let time = 0
let start = Date.now()
let maxTime = start + maxDelay
2017-07-05 13:29:18 +00:00
2017-09-22 04:19:32 +00:00
let mountableTypes = new Map<string, number>()
let mountableTypeMutations = new Map<string, Array<any>>()
2017-06-20 18:13:04 +00:00
2018-04-02 14:07:52 +00:00
for(let element of elements) {
2018-03-14 19:35:00 +00:00
// Skip already mounted elements.
// This helps a lot when dealing with infinite scrolling
// where the first elements are already mounted.
if(element.classList.contains("mounted")) {
continue
}
2017-09-22 04:19:32 +00:00
let type = element.dataset.mountableType || "general"
2017-07-02 15:51:17 +00:00
2017-09-22 04:19:32 +00:00
if(mountableTypes.has(type)) {
time = mountableTypes.get(type) + delay
mountableTypes.set(type, time)
} else {
time = start
mountableTypes.set(type, time)
mountableTypeMutations.set(type, [])
}
2017-06-20 18:13:04 +00:00
2017-09-22 04:19:32 +00:00
if(time > maxTime) {
time = maxTime
}
2017-07-06 01:24:56 +00:00
2017-09-22 04:19:32 +00:00
mountableTypeMutations.get(type).push({
element,
time
})
}
2017-07-05 13:29:18 +00:00
2017-09-22 04:19:32 +00:00
for(let mountableType of mountableTypeMutations.keys()) {
let mutations = mountableTypeMutations.get(mountableType)
let mutationIndex = 0
2017-07-20 12:26:43 +00:00
2017-09-22 04:19:32 +00:00
let updateBatch = () => {
let now = Date.now()
2017-07-05 13:29:18 +00:00
2017-09-22 04:19:32 +00:00
for(; mutationIndex < mutations.length; mutationIndex++) {
let mutation = mutations[mutationIndex]
2017-07-05 13:29:18 +00:00
2017-09-22 04:19:32 +00:00
if(mutation.time > now) {
break
}
2017-07-05 13:29:18 +00:00
2017-09-22 04:19:32 +00:00
func(mutation.element)
}
2017-07-05 13:29:18 +00:00
2017-09-22 04:19:32 +00:00
if(mutationIndex < mutations.length) {
window.requestAnimationFrame(updateBatch)
}
}
2017-07-05 13:29:18 +00:00
2017-09-22 04:19:32 +00:00
window.requestAnimationFrame(updateBatch)
}
}
2017-06-20 18:13:04 +00:00
2018-03-16 18:39:48 +00:00
async diff(url: string) {
2017-07-13 23:50:10 +00:00
if(url === this.app.currentPath) {
2018-03-16 18:39:48 +00:00
return null
2017-07-03 15:21:00 +00:00
}
2017-07-19 04:32:31 +00:00
let path = "/_" + url
2018-03-16 18:39:48 +00:00
try {
// Start the request
let request = fetch(path, {
credentials: "same-origin"
})
.then(response => response.text())
2017-11-09 16:18:15 +00:00
2018-03-16 18:39:48 +00:00
history.pushState(url, null, url)
this.app.currentPath = url
this.diffCompletedForCurrentPath = false
this.app.markActiveLinks()
this.unmountMountables()
this.loading(true)
2017-06-26 17:03:48 +00:00
2018-06-30 03:13:04 +00:00
// Delay by mountable-transition-speed
2018-10-31 05:35:30 +00:00
await delay(150)
2018-03-16 18:39:48 +00:00
let html = await request
// If the response for the correct path has not arrived yet, show this response
if(!this.diffCompletedForCurrentPath) {
// If this response was the most recently requested one, mark the requests as completed
if(this.app.currentPath === url) {
this.diffCompletedForCurrentPath = true
}
// Update contents
await Diff.innerHTML(this.app.content, html)
this.app.emit("DOMContentLoaded")
}
} catch(err) {
console.error(err)
} finally {
this.loading(false)
}
2017-06-26 17:03:48 +00:00
}
2018-03-17 19:41:18 +00:00
innerHTML(element: HTMLElement, html: string) {
return Diff.innerHTML(element, html)
}
2018-04-17 10:27:54 +00:00
post(url: string, body?: any) {
2017-10-14 10:45:22 +00:00
if(this.isLoading) {
2017-10-15 18:19:45 +00:00
return Promise.resolve(null)
2017-10-14 10:45:22 +00:00
}
2018-04-17 10:27:54 +00:00
if(body !== undefined && typeof body !== "string") {
2017-07-21 08:10:48 +00:00
body = JSON.stringify(body)
}
this.loading(true)
2017-06-27 02:15:52 +00:00
return fetch(url, {
method: "POST",
2017-07-21 08:10:48 +00:00
body,
2017-06-27 02:15:52 +00:00
credentials: "same-origin"
})
2017-10-15 18:19:45 +00:00
.then(response => {
2017-07-21 08:10:48 +00:00
this.loading(false)
2017-10-15 18:19:45 +00:00
if(response.status === 200) {
return Promise.resolve(response)
2017-06-27 02:15:52 +00:00
}
2017-10-15 18:19:45 +00:00
return response.text().then(err => {
throw err
})
2017-06-27 02:15:52 +00:00
})
2017-07-21 08:10:48 +00:00
.catch(err => {
this.loading(false)
throw err
})
2017-06-27 02:15:52 +00:00
}
2017-07-06 14:54:10 +00:00
scrollTo(target: HTMLElement) {
const duration = 250.0
const fullSin = Math.PI / 2
const contentPadding = 24
2017-11-09 16:18:15 +00:00
2017-07-06 14:54:10 +00:00
let newScroll = 0
let finalScroll = Math.max(target.offsetTop - contentPadding, 0)
2017-11-10 07:41:45 +00:00
// Calculating scrollTop will force a layout - careful!
let oldScroll = this.app.content.parentElement.scrollTop
2017-07-06 14:54:10 +00:00
let scrollDistance = finalScroll - oldScroll
2017-09-22 15:23:22 +00:00
if(scrollDistance > 0 && scrollDistance < 4) {
return
}
2017-07-06 14:54:10 +00:00
let timeStart = Date.now()
let timeEnd = timeStart + duration
let scroll = () => {
let time = Date.now()
let progress = (time - timeStart) / duration
if(progress > 1.0) {
progress = 1.0
}
newScroll = oldScroll + scrollDistance * Math.sin(progress * fullSin)
this.app.content.parentElement.scrollTop = newScroll
if(time < timeEnd && newScroll != finalScroll) {
window.requestAnimationFrame(scroll)
}
}
window.requestAnimationFrame(scroll)
}
findAPIEndpoint(element: HTMLElement) {
2017-10-05 07:39:37 +00:00
if(element.dataset.api !== undefined) {
return element.dataset.api
}
2017-07-06 14:54:10 +00:00
let apiObject: HTMLElement
let parent = element
while(parent = parent.parentElement) {
if(parent.dataset.api !== undefined) {
apiObject = parent
break
}
}
if(!apiObject) {
throw "API object not found"
}
return apiObject.dataset.api
}
2017-06-21 12:00:52 +00:00
onPopState(e: PopStateEvent) {
if(e.state) {
this.app.load(e.state, {
addToHistory: false
})
} else if(this.app.currentPath !== this.app.originalPath) {
this.app.load(this.app.originalPath, {
addToHistory: false
})
}
}
onKeyDown(e: KeyboardEvent) {
2017-06-30 21:52:42 +00:00
let activeElement = document.activeElement
2017-06-24 14:41:22 +00:00
// Ignore hotkeys on input elements
2017-06-30 21:52:42 +00:00
switch(activeElement.tagName) {
2017-06-24 14:41:22 +00:00
case "INPUT":
// If the active element is the search and we press Enter, re-activate search.
2018-03-22 00:32:04 +00:00
if(activeElement.id === "search" && e.keyCode === 13) {
2018-03-24 22:04:31 +00:00
actions.search(this, activeElement as HTMLInputElement, e)
2018-03-22 00:32:04 +00:00
}
return
2017-06-24 14:41:22 +00:00
case "TEXTAREA":
return
}
// When called, this will prevent the default action for that key.
let preventDefault = () => {
e.preventDefault()
e.stopPropagation()
}
2017-10-12 15:52:46 +00:00
// Ignore hotkeys on contentEditable elements
if(activeElement.getAttribute("contenteditable") === "true") {
// Disallow Enter key in contenteditables and make it blur the element instead
2018-03-22 00:32:04 +00:00
if(e.keyCode === 13) {
2017-10-12 15:52:46 +00:00
if("blur" in activeElement) {
2018-10-11 03:09:12 +00:00
(activeElement["blur"] as Function)()
2017-10-12 15:52:46 +00:00
}
2017-11-09 16:18:15 +00:00
return preventDefault()
2017-06-30 21:52:42 +00:00
}
2017-11-09 16:18:15 +00:00
2017-06-30 21:52:42 +00:00
return
}
2018-03-16 16:34:29 +00:00
// "Ctrl" + "," = Settings
2018-03-22 00:32:04 +00:00
if(e.ctrlKey && e.keyCode === 188) {
2018-03-16 16:34:29 +00:00
this.app.load("/settings")
return preventDefault()
2018-03-16 16:34:29 +00:00
}
// The following keycodes should not be activated while Ctrl or Alt is held down
if(e.ctrlKey || e.altKey) {
2018-03-16 16:34:29 +00:00
return
}
2018-03-07 13:52:56 +00:00
// "F" = Search
2018-03-22 00:32:04 +00:00
if(e.keyCode === 70) {
2018-04-02 05:44:11 +00:00
let search = document.getElementById("search") as HTMLInputElement
2017-06-21 12:00:52 +00:00
search.focus()
search.select()
return preventDefault()
2017-06-21 12:00:52 +00:00
}
2017-10-17 16:16:44 +00:00
2018-03-07 13:52:56 +00:00
// "S" = Toggle sidebar
2018-03-22 00:32:04 +00:00
if(e.keyCode === 83) {
2018-03-07 13:52:56 +00:00
this.sideBar.toggle()
return preventDefault()
2018-03-07 13:52:56 +00:00
}
2018-03-16 16:03:59 +00:00
// "+" = Audio speed up
if(e.key == "+") {
2018-03-24 22:04:31 +00:00
this.audioPlayer.addSpeed(0.05)
return preventDefault()
2018-03-16 16:03:59 +00:00
}
// "-" = Audio speed down
if(e.key == "-") {
2018-03-24 22:04:31 +00:00
this.audioPlayer.addSpeed(-0.05)
return preventDefault()
2018-03-16 16:03:59 +00:00
}
// "J" = Previous track
2018-03-22 00:32:04 +00:00
if(e.keyCode === 74) {
2018-03-24 22:04:31 +00:00
this.audioPlayer.previous()
return preventDefault()
2018-03-16 16:03:59 +00:00
}
// "K" = Play/pause
2018-03-22 00:32:04 +00:00
if(e.keyCode === 75) {
2018-03-24 22:04:31 +00:00
this.audioPlayer.playPause()
return preventDefault()
2018-03-16 16:03:59 +00:00
}
// "L" = Next track
2018-03-22 00:32:04 +00:00
if(e.keyCode === 76) {
2018-03-24 22:04:31 +00:00
this.audioPlayer.next()
return preventDefault()
}
2018-03-16 16:03:59 +00:00
// Number keys activate sidebar menus
for(let i = 48; i <= 57; i++) {
if(e.keyCode === i) {
let index = i === 48 ? 9 : i - 49
let links = [...findAll("sidebar-link")]
if(index < links.length) {
let element = links[index] as HTMLElement
element.click()
return preventDefault()
}
}
2018-03-16 16:03:59 +00:00
}
2017-06-21 12:00:52 +00:00
}
2018-04-17 16:04:16 +00:00
// This is called every time an uncaught JavaScript error is thrown
onError(evt: ErrorEvent) {
let report = {
message: evt.message,
stack: evt.error.stack,
fileName: evt.filename,
lineNumber: evt.lineno,
columnNumber: evt.colno,
}
this.post("/api/new/clienterrorreport", report)
.then(() => console.log("Successfully reported the error to the website staff."))
.catch(() => console.warn("Failed reporting the error to the website staff."))
}
2017-06-19 14:49:24 +00:00
}