forked from Hadiismanto11/docs
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathLinkPreviewPopover.tsx
390 lines (346 loc) · 13.4 KB
/
LinkPreviewPopover.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
import { useEffect } from 'react'
// We postpone the initial delay a bit in case the user didn't mean to
// hover over the link. Perhaps they just dragged the mouse over on their
// way to something else.
const DELAY_SHOW = 300
// The reason the hiding doesn't happens instantly is when the mouse is
// first hovering over the link, then over the popover itself and then
// back to the link. Because there's a slight cap between the popover
// and the link we want to introduce a slight delay so it doesn't flicker.
const DELAY_HIDE = 200
// 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
let popoverStartTimer: number | null = null
// A global for remembering which target was originated the initial opening
// of the popover. It's important to know this when the onmouseover
// of the link is triggered again. If you hover over the popover and back
// to its link, we don't want to immediately open the popover.
// If it's the first time, i.e. a different link, then we want to add a
// slight initial delay.
let currentlyOpen: HTMLLinkElement | null = null
// Number of pixels from the top of the page that implies that we should
// display the popover *underneath* the link.
// The number is based on the height of popovers when they are quite high.
// We can't know the size of the popover on screen until after it's been
// inserted into the visible DOM. So before that, as a `div` element,
// its `offsetHeight` and `.getBoundingClientRect().height` are always 0.
// We *could* "change our mind" and wait till it's been inserted and then
// change accoding to the popover's true height. But this can cause a flicker.
const BOUNDING_TOP_MARGIN = 300
type Info = {
product: string
title: string
intro: string
anchor?: string
}
type APIInfo = {
info: Info
}
function getOrCreatePopoverGlobal() {
let popoverGlobal = document.querySelector('div.Popover') as HTMLDivElement | null
if (!popoverGlobal) {
const wrapper = document.createElement('div')
wrapper.setAttribute('data-testid', 'popover')
wrapper.classList.add('Popover', 'position-absolute')
wrapper.style.display = 'none'
wrapper.style.outline = 'none'
wrapper.style.zIndex = `100`
const inner = document.createElement('div')
// Note that this is lacking the 'Popover-message--bottom-left'
// or 'Popover-message--top-right`. These get set later when we
// know where the popover message should appear on the screen.
inner.classList.add(
...'Popover-message Popover-message--large p-3 Box color-shadow-large'.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'
// If you started the popover by moving over the link, then
// moved the mouse out of the link and into the popover, then
// eventually you move out of the popover. Then, we want to
// reset.
currentlyOpen = null
}, DELAY_HIDE)
})
popoverGlobal = wrapper
}
return popoverGlobal
}
function popoverWrap(element: HTMLLinkElement) {
if (element.parentElement && element.parentElement.classList.contains('Popover')) {
return
}
let title = ''
let product = ''
let intro = ''
let anchor = ''
// 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.split('#')[0]}#`)
) {
const domID = element.href.split('#')[1]
const domElement = document.querySelector(`#${domID}`)
if (domElement && domElement.textContent) {
anchor = domElement.textContent
// Headings will have the `#` character to the right which is to
// indicate that it's a "permalink". It becomes part of the heading's
// text as a DOM element. Strip that.
if (anchor.endsWith(' #')) {
anchor = anchor.slice(0, -2)
}
// 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) {
fillPopover(element, { product, title, intro, anchor })
}
return
}
const { pathname } = new URL(element.href)
fetch(`/api/pageinfo/v1?${new URLSearchParams({ pathname })}`).then(async (response) => {
if (response.ok) {
const { info } = (await response.json()) as APIInfo
fillPopover(element, info)
}
})
}
function fillPopover(element: HTMLLinkElement, info: Info) {
const { product, title, intro, anchor } = info
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)
const [boundingTop] = getBoundingOffset(element)
const popoverMessageElement = popover.querySelector('.Popover-message') as HTMLDivElement
const below = boundingTop < BOUNDING_TOP_MARGIN
if (below) {
// The caret pointing upwards
popoverMessageElement.classList.remove('Popover-message--bottom-left')
popoverMessageElement.classList.add('Popover-message--top-left')
} else {
// Default
popoverMessageElement.classList.remove('Popover-message--top-left')
popoverMessageElement.classList.add('Popover-message--bottom-left')
}
// 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}px`
popover.style.left = `${left}px`
popover.style.display = 'block'
if (below) {
// This moves the popover about the height of the <a> element down.
// You can't use element.getBoundingClientRect() because that could
// give a height that is twice that of a single line of text.
// For example:
//
// <p>Bla bla <a href="...">Link</a> ble and <a href="...">Other
// Link Text</a> yada yada</p>
//
// In this case the second `<a>` element will have a height that is
// twice of the first `<a>` because the second one spans two lines.
const approximateElementHeight = 33
popover.style.top = `${top + approximateElementHeight}px`
} else {
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 getBoundingOffset(element: HTMLElement) {
const { top, left } = element.getBoundingClientRect()
return [top, left]
}
function popoverShow(target: HTMLLinkElement) {
if (popoverStartTimer) {
window.clearTimeout(popoverStartTimer)
}
// The mouse has been moved over a link. If this is the "first time",
// we want to delay showing the popover because it could be that the
// *intention* of the user was not to hover over, but they might have
// just moved the mouse over the link by "accident", or in a hurry
// on their way to something else.
// However, if they hover over the link because the popover is already
// open, which happens when you hover over the popover and back again
// to the link, then we don't want any delay.
if (target === currentlyOpen) {
popoverWrap(target)
} else {
popoverStartTimer = window.setTimeout(() => {
popoverWrap(target)
currentlyOpen = target
}, DELAY_SHOW)
}
}
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.
if (popoverStartTimer) {
window.clearTimeout(popoverStartTimer)
}
popoverCloseTimer = window.setTimeout(() => {
const popover = getOrCreatePopoverGlobal()
popover.style.display = 'none'
// Reset because we're closing the popover, so we have to start from afresh.
currentlyOpen = null
}, DELAY_HIDE)
}
export function LinkPreviewPopover() {
useEffect(() => {
function showPopover(event: MouseEvent) {
// If the current window is too narrow, the popover is not useful.
// Since this is tested on every event callback here in the handler,
// if the window width has changed since the mount, the number
// will change accordingly.
if (window.innerWidth < 767) {
return
}
const target = event.currentTarget as HTMLLinkElement
popoverShow(target)
}
function hidePopover() {
popoverHide()
}
const links = Array.from(
document.querySelectorAll<HTMLLinkElement>(
'#article-contents a[href], #article-intro a[href]',
),
).filter((link) => {
// This filters out links that are not internal or in-page
// and the ones that are in-page anchor links next to the headings.
// Remember that `link.href` is always absolute because it comes
// from the DOM. So to test the pathname, we have to parse it
// and extract the pathname from the whole URL object.
const { pathname } = new URL(link.href)
return (
link.href.startsWith(window.location.origin) &&
!link.classList.contains('heading-link') &&
!pathname.startsWith('/public/') &&
!pathname.startsWith('/assets/') &&
// This skips those ToolPicker links with `data-tool="vscode"`
// attribute, for example.
!link.dataset.tool &&
!link.dataset.platform
)
})
// Ideally, we'd have an event listener for the entire container and
// the filter, at "runtime", within by filtering for the target
// elements we're interested in. However, this is not possible
// because then when you hover over the text in
// a tag like <a href="..."><strong>Link</strong></a> the target
// element is that of the `STRONG` tag.
// The reason it would be better to have a single event listener and
// filter is because it would work even if the DOM changes by
// adding new `<a>` elements.
for (const link of links) {
link.addEventListener('mouseover', showPopover)
link.addEventListener('mouseout', hidePopover)
}
return () => {
for (const link of links) {
link.removeEventListener('mouseover', showPopover)
link.removeEventListener('mouseout', hidePopover)
}
}
}) // Note that this runs on every single mount
return null
}