Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/content/blume.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,12 +164,12 @@ export default defineConfig({
},
},
{
// Keeps mobile twoslash popups inside the viewport (theme.css anchors
// them below the hovered token; this nudges bottom-of-screen ones up).
name: 'twoslash-mobile',
// Anchors twoslash popups next to their token and keeps them inside
// the viewport (theme.css fixes them so they escape the code scroller).
name: 'twoslash-popups',
hooks: {
'astro:config:setup': ({ injectScript }) => {
const clientPath = fileURLToPath(new URL('./components/blume/twoslash-mobile.ts', import.meta.url))
const clientPath = fileURLToPath(new URL('./components/blume/twoslash-popups.ts', import.meta.url))
injectScript('page', `import '${clientPath.replaceAll('\\', '\\\\').replaceAll('\'', '\\\'')}'`)
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
// Twoslash popups need four behaviors CSS :hover can't provide alone:
// Twoslash popups need three behaviors CSS :hover can't provide alone:
//
// 1. Mobile placement — theme.css makes popups fixed and readable-width;
// this anchors each one just below its hovered or tapped token (or above
// it when the token sits near the bottom of the screen), left-aligned
// with the token as far as the viewport allows. The popup's own height
// is never measured — the available space becomes its max-height and
// longer signatures scroll internally — so late reflows of the nested
// code signature can't push it offscreen. Fixed popups don't follow the
// page, so the visible one is dismissed as soon as scrolling starts —
// composited touch scrolling moves the page before scroll events reach
// the main thread, so JS re-anchoring always trails the finger visibly.
// Placement is only written on reveal, never proactively cleared:
// clearing eagerly (outside tap, touch pointerout) made the
// still-visible popup flash at its fallback position.
// 1. Placement — theme.css makes popups fixed (so they escape the code
// block's horizontal scroller) and readable-width; this anchors each one
// just below its hovered or tapped token (or above it when the token
// sits near the bottom of the screen), left-aligned with the token as
// far as the viewport allows. The popup's own height is never measured
// — the available space becomes its max-height and longer signatures
// scroll internally — so late reflows of the nested code signature
// can't push it offscreen. Fixed popups don't follow the page: with a
// mouse the open one is re-anchored on scroll (wheel steps are small
// and the one-frame lag reads as the popup following its token), while
// touch scrolling dismisses it instead — composited touch scrolling
// moves the page before scroll events reach the main thread, so JS
// re-anchoring always trails the finger visibly. Placement is only
// written on reveal and scroll, never proactively cleared: clearing
// eagerly (outside tap, touch pointerout) made the still-visible popup
// flash at its fallback position.
//
// 2. Hover bridge — the popup sits GAP px away from the token, and :hover
// alone closes it the instant the pointer enters that gap. The popup
Expand All @@ -23,35 +26,22 @@
// crosses, at any speed — no timers. It must never reach into the
// token's own line nor stretch wider than the popup, or it traps
// sideways movement between neighboring tokens, holding the old popup
// open instead of switching. Desktop widths bridge with a CSS ::before
// on the popup instead (theme.css).
// open instead of switching.
//
// 3. Desktop flip — desktop popups drop below the token via CSS alone,
// so one near the viewport bottom runs offscreen. CSS can't see the
// viewport, so reveal() measures and toggles FLIP_CLASS; theme.css
// mirrors the below-placement geometry above the token (popup and
// bridge both).
//
// 4. Tap pin — touch has no hover, and sticky :hover emulation on tap is
// 3. Tap pin — touch has no hover, and sticky :hover emulation on tap is
// unreliable across mobile browsers. A tap pins the popup open via
// OPEN_CLASS (mirrored to the :hover reveal in theme.css); tapping
// anywhere else unpins it. Pins react to click, not pointerdown: a
// scroll gesture that starts on a token fires pointerdown too, but
// only a completed tap produces a click — so dragging across code
// doesn't spawn popups. Mouse clicks are left alone so selecting code
// text doesn't pin popups.
const MOBILE = window.matchMedia('(max-width: 48rem)')
const EDGE = 8
const GAP = 6
const MIN_SPACE = 160
const OPEN_CLASS = 'twoslash-open'
const BRIDGE_CLASS = 'twoslash-mobile-bridge'
const BRIDGE_CLASS = 'twoslash-bridge'
const DISMISSED_CLASS = 'twoslash-dismissed'
const FLIP_CLASS = 'twoslash-flip'
// Desktop gap between token and popup (--twoslash-gap in theme.css) plus
// breathing room, so a popup that would only just graze the viewport
// bottom still flips.
const FLIP_GAP = 16

function popupFor(target: EventTarget | null): { hover: Element, popup: HTMLElement } | null {
const element = target instanceof Element ? target : null
Expand Down Expand Up @@ -80,7 +70,7 @@ function place(hover: Element, popup: HTMLElement): void {
hover.classList.remove(DISMISSED_CLASS)

// Width is only measurable while the popup is displayed. Reveals always
// precede placement (mouse pointerover implies :hover; taps pin before
// precede placement (pointerover implies :hover; taps pin before
// placing), so an unmeasurable popup is a transient pre-reveal event —
// skip it, the next event re-places.
const width = popup.getBoundingClientRect().width
Expand Down Expand Up @@ -112,48 +102,10 @@ function place(hover: Element, popup: HTMLElement): void {
placed = { hover, popup }
}

// The desktop flip side is decided once per open, not on every pointerover
// — pointer movement inside an open popup re-fires pointerover constantly,
// and geometry can't change while the pointer holds the popup open.
let flipDecidedFor: Element | null = null

function reveal(target: EventTarget | null): void {
const found = popupFor(target)
if (!found) {
flipDecidedFor = null
return
}
if (MOBILE.matches) {
if (found) {
place(found.hover, found.popup)
return
}
if (found.popup.style.top) {
// Drop placement left over from a mobile-width session so the
// absolute-positioned popup anchors normally; desktop bridges with a
// CSS ::before, so the strip goes too.
for (const prop of ['top', 'bottom', 'maxHeight', 'marginTop', 'left', 'transform'] as const) {
found.popup.style[prop] = ''
}
found.hover.querySelector(`:scope > .${BRIDGE_CLASS}`)?.remove()
placed = null
}
if (found.hover === flipDecidedFor) {
return
}

// Flip above a token too close to the viewport bottom for the popup to
// fit under it, when above actually fits more. Height is only
// measurable while the popup is displayed (same transient pre-reveal
// case as place()); keep the previous side rather than guessing.
const height = found.popup.getBoundingClientRect().height
if (height) {
const rect = found.hover.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom - EDGE
found.hover.classList.toggle(
FLIP_CLASS,
spaceBelow < height + FLIP_GAP && rect.top - EDGE > spaceBelow,
)
flipDecidedFor = found.hover
}
}

Expand All @@ -171,10 +123,13 @@ function setOpen(hover: Element | null): void {
// Hover shows the popup via CSS :hover (the bridge keeps the chain alive
// on the way in); completed taps pin it via OPEN_CLASS — see the header on
// why click, not pointerdown. Pinning happens before placing so place()
// can measure the freshly displayed popup. click carries no pointerType in
// every browser, so the preceding pointerdown's type stands in for it.
// can measure the freshly displayed popup. click and scroll carry no
// pointerType, so the type of the latest pointer event stands in for it.
let lastPointerType = 'mouse'
document.addEventListener('pointerover', event => reveal(event.target))
document.addEventListener('pointerover', (event) => {
lastPointerType = event.pointerType
reveal(event.target)
})
document.addEventListener('pointerdown', (event) => {
lastPointerType = event.pointerType
})
Expand All @@ -189,19 +144,23 @@ document.addEventListener('click', (event) => {
}
})

// Dismiss the placed popup on the first scroll (capture catches every
// scroller, including the code block's own) — see the header on why it
// can't follow instead. DISMISSED_CLASS hides the popup and bridge even
// where a sticky tap-:hover would keep the CSS reveal alive; the next
// place() lifts it. Scrolling inside the popup itself is the one scroller
// reading depends on, so it never dismisses.
// Follow (mouse) or dismiss (touch) the placed popup on scroll (capture
// catches every scroller, including the code block's own) — see the
// header on why touch can't follow. DISMISSED_CLASS hides the popup and
// bridge even where a sticky tap-:hover would keep the CSS reveal alive;
// the next place() lifts it. Scrolling inside the popup itself is the one
// scroller reading depends on, so it never dismisses.
document.addEventListener('scroll', (event) => {
if (!placed || !MOBILE.matches) {
if (!placed) {
return
}
if (event.target instanceof Node && placed.popup.contains(event.target)) {
return
}
if (lastPointerType === 'mouse') {
place(placed.hover, placed.popup)
return
}
setOpen(null)
placed.hover.classList.add(DISMISSED_CLASS)
placed = null
Expand Down
4 changes: 4 additions & 0 deletions apps/content/docs/client/client-side.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const pong = await client.ping()

client.ping
// ^|

//

//
```

## Client Context
Expand Down
1 change: 1 addition & 0 deletions apps/content/docs/contract/implementation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ implementer.planet.list

//

//
//
//
```
Expand Down
Loading
Loading