Skip to content

Commit

Permalink
Merge pull request #24593 from github/repo-sync
Browse files Browse the repository at this point in the history
repo sync
  • Loading branch information
Octomerger authored Mar 21, 2023
2 parents 284e643 + 07a3e2a commit abfe99b
Show file tree
Hide file tree
Showing 19 changed files with 557 additions and 9 deletions.
1 change: 1 addition & 0 deletions assets/images/site/hash.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
248 changes: 248 additions & 0 deletions components/LinkPreviewPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
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
}
50 changes: 45 additions & 5 deletions components/article/ArticlePage.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import cx from 'classnames'
import { Box, Flash } from '@primer/react'
import { LinkExternalIcon, BeakerIcon } from '@primer/octicons-react'

import { Callout } from 'components/ui/Callout'

import { DefaultLayout } from 'components/DefaultLayout'
import { ArticleTitle } from 'components/article/ArticleTitle'
import { useArticleContext } from 'components/context/ArticleContext'
Expand All @@ -21,8 +22,7 @@ import { RestRedirect } from 'components/RestRedirect'
import { Breadcrumbs } from 'components/page-header/Breadcrumbs'
import { Link } from 'components/Link'
import { useTranslation } from 'components/hooks/useTranslation'

import { LinkExternalIcon } from '@primer/octicons-react'
import { LinkPreviewPopover } from 'components/LinkPreviewPopover'

const ClientSideRefresh = dynamic(() => import('components/ClientSideRefresh'), {
ssr: false,
Expand All @@ -49,6 +49,7 @@ export const ArticlePage = () => {

return (
<DefaultLayout>
<LinkPreviewPopover />
{isDev && <ClientSideRefresh />}
<ClientSideHighlight />
{router.pathname.includes('/rest/') && <RestRedirect />}
Expand All @@ -57,11 +58,50 @@ export const ArticlePage = () => {
<Breadcrumbs />
</div>
<ArticleGridLayout
topper={<ArticleTitle>{title}</ArticleTitle>}
topper={
<>
{/* This is a temporary thing for the duration of the
feature-flagged release of hover preview cards on /$local/pages/
articles.
Delete this whole thing when hover preview cards is
available on all articles independent of path.
*/}
{router.query.productId === 'pages' && (
<Flash variant="default" className="mb-3">
<Box sx={{ display: 'flex' }}>
<Box
sx={{
p: 1,
textAlign: 'center',
}}
>
<BeakerIcon className="mr-2 color-fg-muted" />
</Box>
<Box
sx={{
flexGrow: 1,
p: 0,
}}
>
<p>
Hover over a link to another article to get more details. If you have ideas
for how we can improve this page, let us know in the{' '}
<a href="https://github.com/github/docs/discussions/24591">discussion</a>.
</p>
</Box>
</Box>
</Flash>
)}

<ArticleTitle>{title}</ArticleTitle>
</>
}
intro={
<>
{intro && (
<Lead data-testid="lead" data-search="lead">
// Note the `_page-intro` is used by the popover preview cards
// when it needs this text for in-page links.
<Lead data-testid="lead" data-search="lead" className="_page-intro">
{intro}
</Lead>
)}
Expand Down
4 changes: 3 additions & 1 deletion components/sidebar/SidebarNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export const SidebarNav = ({ variant = 'full' }: Props) => {
<Link
data-testid="sidebar-product-xl"
href={currentProductTree.href}
className="d-block pl-1 mb-2 h3 color-fg-default no-underline"
// Note the `_product-title` is used by the popover preview cards
// when it needs this text for in-page links.
className="d-block pl-1 mb-2 h3 color-fg-default no-underline _product-title"
>
{productTitle}
</Link>
Expand Down
Loading

0 comments on commit abfe99b

Please sign in to comment.