1
0
mirror of synced 2025-12-19 18:10:59 -05:00
Files
docs/components/LinkPreviewPopover.tsx
Peter Bengtsson 07a3e2a48c Preview hover cards (#34702)
Co-authored-by: Grace Park <gracepark@github.com>
Co-authored-by: Joe Oak <41307427+joeoak@users.noreply.github.com>
2023-03-21 19:59:49 +00:00

249 lines
8.0 KiB
TypeScript

import { useEffect } from 'react'
// We delay the closing over the popover slightly in case the mouse
// movement either comes back (mouseover, mouseout, and back to mouseover)
// or if the user moves the mouse from the link to the popover itself
// and vice versa.
const DELAY = 300
// A global that is used for a slow/delayed closing of the popovers.
// It can be global because there's only 1 popover DOM node that gets
// created the first time it's needed.
let popoverCloseTimer: number | null = null
function getOrCreatePopoverGlobal() {
let popoverGlobal = document.querySelector('div.Popover') as HTMLDivElement | null
if (!popoverGlobal) {
const wrapper = document.createElement('div')
wrapper.classList.add('Popover', 'position-absolute')
wrapper.style.display = 'none'
wrapper.style.outline = 'none'
wrapper.style.zIndex = `100`
const inner = document.createElement('div')
inner.classList.add(
...'Popover-message Popover-message--large p-3 Box color-shadow-large Popover-message--bottom-left'.split(
/\s+/g
)
)
inner.style.width = `360px`
const product = document.createElement('p')
product.classList.add('product')
product.classList.add('f6')
product.classList.add('color-fg-muted')
inner.appendChild(product)
inner.appendChild(product)
const title = document.createElement('h4')
title.classList.add('h5')
title.classList.add('my-1')
inner.appendChild(title)
const intro = document.createElement('p')
intro.classList.add('intro')
intro.classList.add('f6')
intro.classList.add('color-fg-muted')
inner.appendChild(intro)
const anchor = document.createElement('p')
anchor.classList.add('anchor')
anchor.classList.add('hover-card-anchor')
anchor.classList.add('f6')
anchor.classList.add('color-fg-muted')
inner.appendChild(anchor)
wrapper.appendChild(inner)
document.body.appendChild(wrapper)
wrapper.addEventListener('mouseover', () => {
if (popoverCloseTimer) {
window.clearTimeout(popoverCloseTimer)
}
})
wrapper.addEventListener('mouseout', () => {
popoverCloseTimer = window.setTimeout(() => {
wrapper.style.display = 'none'
}, DELAY)
})
popoverGlobal = wrapper
}
return popoverGlobal
}
function popoverWrap(element: HTMLLinkElement) {
if (element.parentElement && element.parentElement.classList.contains('Popover')) {
return
}
let title = element.dataset.title
let product = element.dataset.productTitle || ''
let intro = element.dataset.intro || ''
let anchor = ''
if (!title) {
// But, is it an in-page anchor link? If so, get the title, intro
// and product from within the DOM. But only if we can use the anchor
// destination to find a DOM node that has text.
if (
element.href.includes('#') &&
element.href.split('#')[1] &&
element.href.startsWith(`${window.location.href}#`)
) {
const domID = element.href.split('#')[1]
const domElement = document.querySelector(`#${domID}`)
if (domElement && domElement.textContent) {
anchor = domElement.textContent
// Now we have to make up the product, intro, and title
const domTitle = document.querySelector('h1')
if (domTitle && domTitle.textContent) {
title = domTitle.textContent
intro = ''
product = ''
const domProduct = document.querySelector('._product-title')
if (domProduct && domProduct.textContent) {
product = domProduct.textContent
}
const domIntro = document.querySelector('._page-intro')
if (domIntro && domIntro.textContent) {
intro = domIntro.textContent
}
}
}
}
}
if (!title) return
const popover = getOrCreatePopoverGlobal()
const productHead = popover.querySelector('p.product') as HTMLParagraphElement | null
if (productHead) {
if (product) {
productHead.textContent = product
productHead.style.display = 'block'
} else {
productHead.style.display = 'none'
}
}
const anchorElement = popover.querySelector('p.anchor') as HTMLParagraphElement | null
if (anchorElement) {
if (anchor) {
anchorElement.textContent = anchor
anchorElement.style.display = 'block'
} else {
anchorElement.style.display = 'none'
}
}
if (popoverCloseTimer) {
window.clearTimeout(popoverCloseTimer)
}
const header = popover.querySelector('h4')
if (header) header.textContent = title
const paragraph: HTMLParagraphElement | null = popover.querySelector('p.intro')
if (paragraph) {
if (intro) {
paragraph.textContent = intro
paragraph.style.display = 'block'
} else {
paragraph.style.display = 'none'
}
}
const [top, left] = getOffset(element)
// We can't know what the height of the popover element is when it's
// `display:none` so we guess offset to the offset and adjust it later.
popover.style.top = `${top - 100}px`
popover.style.left = `${left}px`
popover.style.display = 'block'
popover.style.top = `${top - popover.offsetHeight - 10}px`
}
// The top/left offset of an element is only relative to its parent.
// So if you have...
//
// <body>
// <div id="main">
// <div id="sub" style="position:relative">
// <a href="...">Link</a>
//
// The `<a>` element's offset is based on the `<div id="sub" style="position:relative">`
// and not the body as the user sees it relative to the viewport.
// So you have to traverse the offsets till you get to the root.
function getOffset(element: HTMLElement) {
let top = element.offsetTop
let left = element.offsetLeft
let offsetParent = element.offsetParent as HTMLElement | null
while (offsetParent) {
left += offsetParent.offsetLeft
top += offsetParent.offsetTop
offsetParent = offsetParent.offsetParent as HTMLElement | null
}
return [top, left]
}
function popoverHide() {
// Important to use `window.setTimeout` instead of `setTimeout` so
// that TypeScript knows which kind of timeout we're talking about.
// If you use plain `setTimeout` TypeScript might think it's a
// Node eventloop kinda timer.
popoverCloseTimer = window.setTimeout(() => {
const popover = getOrCreatePopoverGlobal()
popover.style.display = 'none'
}, DELAY)
}
function testTarget(target: HTMLLinkElement) {
// Return true if the element is an A tag, whose `href` starts with
// a `/`, and it's not one of those permalink ones next to headings
// (with the chain looking icon).
return (
target.tagName === 'A' &&
target.href.startsWith(window.location.origin) &&
!target.classList.contains('doctocat-link')
)
}
export function LinkPreviewPopover() {
useEffect(() => {
// This event handler function is used for clicks anywhere in
// the `#article-contents` div. So we need to filter within.
function showPopover(event: MouseEvent) {
const target = event.target as HTMLLinkElement
if (testTarget(target)) {
popoverWrap(target)
}
}
function hidePopover(event: MouseEvent) {
const target = event.target as HTMLLinkElement
if (testTarget(target)) {
popoverHide()
}
}
// The reason we have an event listener for ALL things within the
// <div>, instead of one for every `a[href]` element, is because
// this way we're prepared for the fact that new `a` elements
// might get introduced some other way. For example, if there's
// some any other code that does a `container.appendChild(newLink)`
const container = document.querySelector<HTMLDivElement>('#article-contents')
if (container) {
container.addEventListener('mouseover', showPopover)
container.addEventListener('mouseout', hidePopover)
}
return () => {
if (container) {
container.removeEventListener('mouseover', showPopover)
container.removeEventListener('mouseout', hidePopover)
}
}
}) // Note that this runs on every single mount
return null
}