Improved tooltips
This commit is contained in:
parent
79d04cf55a
commit
c281f87335
@ -14,6 +14,7 @@ import ServerEvents from "./ServerEvents"
|
||||
import { displayAiringDate, displayDate, displayTime } from "./DateView"
|
||||
import { findAll, supportsWebP, requestIdleCallback, swapElements, delay, findAllInside } from "./Utils"
|
||||
import * as actions from "./Actions"
|
||||
import ToolTip from "./Elements/tool-tip/tool-tip"
|
||||
|
||||
export default class AnimeNotifier {
|
||||
app: Application
|
||||
@ -39,6 +40,7 @@ export default class AnimeNotifier {
|
||||
lastReloadContentPath: string
|
||||
currentMediaId: string
|
||||
serverEvents: ServerEvents
|
||||
tip: ToolTip
|
||||
|
||||
constructor(app: Application) {
|
||||
this.app = app
|
||||
@ -53,31 +55,6 @@ export default class AnimeNotifier {
|
||||
|
||||
// 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) {
|
||||
if(entry.isIntersecting) {
|
||||
entry.target["became visible"]()
|
||||
this.visibilityObserver.unobserve(entry.target)
|
||||
}
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
} else {
|
||||
// Disable lazy load feature
|
||||
this.visibilityObserver = {
|
||||
disconnect: () => {},
|
||||
observe: (elem: HTMLElement) => {
|
||||
elem["became visible"]()
|
||||
},
|
||||
unobserve: (_: HTMLElement) => {}
|
||||
} as IntersectionObserver
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
@ -124,6 +101,39 @@ export default class AnimeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Web components
|
||||
this.registerWebComponents()
|
||||
|
||||
// Tooltip
|
||||
this.tip = new ToolTip()
|
||||
document.body.appendChild(this.tip)
|
||||
document.addEventListener("linkclicked", () => this.tip.classList.add("fade-out"))
|
||||
|
||||
// Intersection observer
|
||||
if("IntersectionObserver" in window) {
|
||||
// Enable lazy load
|
||||
this.visibilityObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
for(let entry of entries) {
|
||||
if(entry.isIntersecting) {
|
||||
entry.target["became visible"]()
|
||||
this.visibilityObserver.unobserve(entry.target)
|
||||
}
|
||||
}
|
||||
},
|
||||
{}
|
||||
)
|
||||
} else {
|
||||
// Disable lazy load feature
|
||||
this.visibilityObserver = {
|
||||
disconnect: () => {},
|
||||
observe: (elem: HTMLElement) => {
|
||||
elem["became visible"]()
|
||||
},
|
||||
unobserve: (_: HTMLElement) => {}
|
||||
} as IntersectionObserver
|
||||
}
|
||||
|
||||
// Status message
|
||||
this.statusMessage = new StatusMessage(
|
||||
document.getElementById("status-message") as HTMLElement,
|
||||
@ -183,7 +193,7 @@ export default class AnimeNotifier {
|
||||
Promise.resolve().then(() => this.dragAndDrop()),
|
||||
Promise.resolve().then(() => this.colorBoxes()),
|
||||
Promise.resolve().then(() => this.loadCharacterRanking()),
|
||||
Promise.resolve().then(() => this.assignTooltipOffsets()),
|
||||
Promise.resolve().then(() => this.prepareTooltips()),
|
||||
Promise.resolve().then(() => this.countUp())
|
||||
])
|
||||
|
||||
@ -200,6 +210,23 @@ export default class AnimeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
registerWebComponents() {
|
||||
if(!("customElements" in window)) {
|
||||
console.warn("Web components not supported in your current browser")
|
||||
return
|
||||
}
|
||||
|
||||
// Custom element names must have a dash in their name
|
||||
const elements = new Map<string, Function>([
|
||||
["tool-tip", ToolTip]
|
||||
])
|
||||
|
||||
// Register all custom elements
|
||||
for(const [tag, definition] of elements.entries()) {
|
||||
window.customElements.define(tag, definition)
|
||||
}
|
||||
}
|
||||
|
||||
applyPageTitle() {
|
||||
let headers = document.getElementsByTagName("h1")
|
||||
|
||||
@ -313,80 +340,24 @@ export default class AnimeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
assignTooltipOffsets(elements?: IterableIterator<HTMLElement>) {
|
||||
requestIdleCallback(() => {
|
||||
const distanceToBorder = 5
|
||||
let contentRect: ClientRect
|
||||
|
||||
prepareTooltips(elements?: IterableIterator<HTMLElement>) {
|
||||
if(!elements) {
|
||||
elements = findAll("tip")
|
||||
}
|
||||
|
||||
this.tip.setAttribute("active", "false")
|
||||
|
||||
// Assign mouse enter event handler
|
||||
for(let element of elements) {
|
||||
Diff.mutations.queue(() => {
|
||||
element.classList.add("tip-active")
|
||||
})
|
||||
|
||||
element.onmouseenter = () => {
|
||||
Diff.mutations.queue(() => {
|
||||
if(!contentRect) {
|
||||
contentRect = this.app.content.getBoundingClientRect()
|
||||
this.tip.classList.remove("fade-out")
|
||||
this.tip.show(element)
|
||||
}
|
||||
|
||||
// Dynamic label assignment to prevent label texts overflowing
|
||||
// and taking horizontal space at page load.
|
||||
let label = element.getAttribute("aria-label")
|
||||
|
||||
if(!label) {
|
||||
console.error("Tooltip without a label:", element)
|
||||
return
|
||||
}
|
||||
|
||||
element.dataset.label = 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()
|
||||
|
||||
// Calculate offsets
|
||||
let tipStyle = window.getComputedStyle(element, ":before")
|
||||
|
||||
if(!tipStyle.width || !tipStyle.paddingLeft) {
|
||||
console.error("Tooltip with incorrect computed style:", element)
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
tipChild.setAttribute("data-label", element.dataset.label)
|
||||
tipChild.style.left = Math.round(leftOffset) + "px"
|
||||
tipChild.style.width = rect.width + "px"
|
||||
tipChild.style.height = rect.height + "px"
|
||||
element.appendChild(tipChild)
|
||||
}
|
||||
|
||||
// Unassign event listener
|
||||
element.onmouseenter = null
|
||||
})
|
||||
element.onmouseleave = () => {
|
||||
this.tip.hide()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dragAndDrop() {
|
||||
@ -1272,7 +1243,7 @@ export default class AnimeNotifier {
|
||||
this.app.ajaxify(element.getElementsByTagName("a"))
|
||||
this.lazyLoad(findAllInside("lazy", element))
|
||||
this.mountMountables(findAllInside("mountable", element))
|
||||
this.assignTooltipOffsets(findAllInside("tip", element))
|
||||
this.prepareTooltips(findAllInside("tip", element))
|
||||
this.textAreaFocus()
|
||||
}
|
||||
|
||||
|
@ -237,6 +237,7 @@ export default class Application {
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
// Prevent loading the same page
|
||||
let url = (this as HTMLAnchorElement).getAttribute("href")
|
||||
|
||||
if(!url || url === self.currentPath) {
|
||||
@ -245,6 +246,7 @@ export default class Application {
|
||||
|
||||
// Load requested page
|
||||
self.load(url)
|
||||
self.emit("linkclicked")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
38
scripts/Elements/tool-tip/tool-tip.scarlet
Normal file
38
scripts/Elements/tool-tip/tool-tip.scarlet
Normal file
@ -0,0 +1,38 @@
|
||||
const tip-opacity = 1
|
||||
const tip-transform-hidden = translateY(20%)
|
||||
const tip-transform-visible = translateY(0)
|
||||
const tip-offset = -0.6rem
|
||||
|
||||
tool-tip
|
||||
position absolute
|
||||
z-index 100000
|
||||
opacity 0
|
||||
transform tip-transform-hidden
|
||||
transition opacity transition-speed ease, transform transition-speed ease
|
||||
pointer-events none
|
||||
margin-top tip-offset
|
||||
|
||||
[active="true"]
|
||||
opacity tip-opacity
|
||||
transform tip-transform-visible
|
||||
|
||||
.box
|
||||
color text-color
|
||||
text-shadow none
|
||||
padding 0.6rem 0.8rem
|
||||
background tip-bg-color
|
||||
font-size 0.92rem
|
||||
border 1px solid ui-border-color
|
||||
border-radius ui-element-border-radius
|
||||
box-shadow shadow-light
|
||||
white-space nowrap
|
||||
|
||||
.arrow
|
||||
position relative
|
||||
width 6px
|
||||
height 6px
|
||||
margin-top -1px
|
||||
transform translateX(-50%)
|
||||
border-style solid
|
||||
border-width 6px 6px 0 6px
|
||||
border-color tip-bg-color transparent transparent transparent
|
60
scripts/Elements/tool-tip/tool-tip.ts
Normal file
60
scripts/Elements/tool-tip/tool-tip.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import Diff from "scripts/Diff"
|
||||
|
||||
export default class ToolTip extends HTMLElement {
|
||||
anchor: HTMLElement
|
||||
box: HTMLElement
|
||||
arrow: HTMLElement
|
||||
|
||||
connectedCallback() {
|
||||
this.box = document.createElement("div")
|
||||
this.box.classList.add("box")
|
||||
this.appendChild(this.box)
|
||||
|
||||
this.arrow = document.createElement("div")
|
||||
this.arrow.classList.add("arrow")
|
||||
this.appendChild(this.arrow)
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.setAttribute("active", "false")
|
||||
}
|
||||
|
||||
show(anchor: HTMLElement) {
|
||||
const distanceToBorder = 5
|
||||
|
||||
this.anchor = anchor
|
||||
this.box.textContent = this.anchor.getAttribute("aria-label")
|
||||
|
||||
let anchorRect = this.anchor.getBoundingClientRect()
|
||||
let boxRect = this.box.getBoundingClientRect()
|
||||
|
||||
let finalX = anchorRect.left + anchorRect.width / 2 - boxRect.width / 2
|
||||
let finalY = anchorRect.top - boxRect.height
|
||||
|
||||
let contentRect = {
|
||||
left: distanceToBorder,
|
||||
top: distanceToBorder,
|
||||
right: document.body.clientWidth - distanceToBorder,
|
||||
bottom: document.body.clientHeight - distanceToBorder
|
||||
}
|
||||
|
||||
let offsetX = 0
|
||||
|
||||
if(finalX < contentRect.left) {
|
||||
offsetX = contentRect.left - finalX
|
||||
finalX += offsetX
|
||||
} else if(finalX + boxRect.width > contentRect.right) {
|
||||
offsetX = contentRect.right - (finalX + boxRect.width)
|
||||
finalX += offsetX
|
||||
}
|
||||
|
||||
let arrowX = boxRect.width / 2 - offsetX
|
||||
|
||||
Diff.mutations.queue(() => {
|
||||
this.style.left = finalX + "px"
|
||||
this.style.top = finalY + "px"
|
||||
this.arrow.style.left = arrowX + "px"
|
||||
this.setAttribute("active", "true")
|
||||
})
|
||||
}
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
const tip-opacity = 0.97
|
||||
const tip-transform-hidden = translateX(-50%) translateY(-80%)
|
||||
const tip-transform-visible = translateX(-50%) translateY(-100%)
|
||||
|
||||
mixin tip-before
|
||||
content attr(data-label)
|
||||
position absolute
|
||||
top -10px
|
||||
left 50%
|
||||
z-index 100000
|
||||
pointer-events none
|
||||
opacity 0
|
||||
transform tip-transform-hidden
|
||||
font-size 0.92rem
|
||||
color text-color
|
||||
text-shadow none
|
||||
padding 0.2rem 0.7rem
|
||||
background tip-bg-color
|
||||
border 1px solid ui-border-color
|
||||
border-radius ui-element-border-radius
|
||||
box-shadow shadow-light
|
||||
white-space nowrap
|
||||
default-transition
|
||||
|
||||
mixin tip-after
|
||||
content ""
|
||||
position absolute
|
||||
top -4px
|
||||
left 50%
|
||||
z-index 100001
|
||||
pointer-events none
|
||||
opacity 0
|
||||
width 0
|
||||
height 0
|
||||
border-style solid
|
||||
border-width 8px 8px 0 8px
|
||||
border-color tip-bg-color transparent transparent transparent
|
||||
transform tip-transform-hidden
|
||||
default-transition
|
||||
|
||||
.tip-offset-child
|
||||
position absolute !important
|
||||
left 0
|
||||
top 0
|
||||
pointer-events none
|
||||
|
||||
// Tooltips are not activated by default.
|
||||
// This helps loading times as the browser
|
||||
// doesn't need to create pseudo elements on page load.
|
||||
.tip
|
||||
// ...
|
||||
|
||||
// When the browser is idle, we give all tips the "tip-active" class.
|
||||
.tip-active
|
||||
position relative
|
||||
|
||||
:before
|
||||
tip-before
|
||||
|
||||
:after
|
||||
tip-after
|
||||
|
||||
:hover
|
||||
:before
|
||||
opacity tip-opacity
|
||||
transform tip-transform-visible
|
||||
|
||||
:after
|
||||
opacity tip-opacity
|
||||
transform tip-transform-visible
|
||||
|
||||
.tip-offset-root
|
||||
position relative
|
||||
|
||||
:after
|
||||
tip-after
|
||||
|
||||
.tip-offset-child
|
||||
:before
|
||||
tip-before
|
||||
|
||||
:hover
|
||||
:after
|
||||
opacity tip-opacity
|
||||
transform tip-transform-visible
|
||||
|
||||
.tip-offset-child
|
||||
:before
|
||||
opacity tip-opacity
|
||||
transform tip-transform-visible
|
Loading…
Reference in New Issue
Block a user