From cc7458b20b29a47e08ce7b5c50353c29aac7b42f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 27 May 2026 11:09:50 +0200 Subject: [PATCH 1/5] Make Tooltip non-interactive when disableHoverableContent is set The portaled popper wrapper would otherwise sit over the trigger and steal pointer events (e.g. hover-revealed controls). A scoped global rule makes that wrapper pointer-transparent for these tooltips only. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/tooltip-non-interactive.md | 5 +++++ .../src/components/RootLayout/globals.css | 11 +++++++++++ .../src/components/primitives/Button.tsx | 2 +- .../src/components/primitives/Tooltip.tsx | 19 ++++++++++++++++++- 4 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 .changeset/tooltip-non-interactive.md diff --git a/.changeset/tooltip-non-interactive.md b/.changeset/tooltip-non-interactive.md new file mode 100644 index 0000000000..eedec7b391 --- /dev/null +++ b/.changeset/tooltip-non-interactive.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Make `Tooltip` content non-interactive when `disableHoverableContent` is set, so its portaled popper wrapper no longer steals pointer events (e.g. hover-revealed controls) from the trigger. diff --git a/packages/gitbook/src/components/RootLayout/globals.css b/packages/gitbook/src/components/RootLayout/globals.css index 7928c23cf3..1b6e8e5eab 100644 --- a/packages/gitbook/src/components/RootLayout/globals.css +++ b/packages/gitbook/src/components/RootLayout/globals.css @@ -47,6 +47,17 @@ } } +/* + Radix renders tooltip content inside a portaled `[data-radix-popper-content-wrapper]` attached + to . A non-interactive tooltip (Tooltip with `disableHoverableContent`) marks its content + with `data-non-interactive`; without this rule the pointer-interactive wrapper could sit over + the trigger and steal its hover (e.g. breaking hover-revealed controls). We can't style the + wrapper from React, so let pointer events pass through it for these tooltips only. +*/ +[data-radix-popper-content-wrapper]:has([data-non-interactive]) { + pointer-events: none; +} + @utility linear-mask-gradient { mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0)); mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 96px, rgba(0, 0, 0, 0)); diff --git a/packages/gitbook/src/components/primitives/Button.tsx b/packages/gitbook/src/components/primitives/Button.tsx index 940a940bfc..ebe422e30f 100644 --- a/packages/gitbook/src/components/primitives/Button.tsx +++ b/packages/gitbook/src/components/primitives/Button.tsx @@ -219,7 +219,7 @@ export const Button = React.forwardRef< }} label={label} triggerProps={{ disabled, ...tooltipProps?.triggerProps }} - contentProps={{ ...tooltipProps?.contentProps }} + contentProps={tooltipProps?.contentProps} > {button} diff --git a/packages/gitbook/src/components/primitives/Tooltip.tsx b/packages/gitbook/src/components/primitives/Tooltip.tsx index 2a9b8d463d..72ffb7b2df 100644 --- a/packages/gitbook/src/components/primitives/Tooltip.tsx +++ b/packages/gitbook/src/components/primitives/Tooltip.tsx @@ -36,6 +36,23 @@ export function Tooltip(props: { const [open, setOpen] = useState(false); const [clicked, setClicked] = useState(false); + // When hoverable content is disabled, the content is purely informational: make it + // non-interactive so its (portaled) popper wrapper can't steal hover/clicks from the + // trigger. The `data-non-interactive` marker lets a scoped global rule (globals.css) + // set `pointer-events: none` on the wrapper, which we can't reach from React. + const nonInteractive = rootProps?.disableHoverableContent ?? false; + const resolvedContentProps = nonInteractive + ? { + ...contentProps, + 'data-non-interactive': '', + style: { + pointerEvents: 'none' as const, + userSelect: 'none' as const, + ...contentProps?.style, + }, + } + : contentProps; + return ( setClicked(true)} {...triggerProps}> @@ -50,7 +67,7 @@ export function Tooltip(props: { className )} onPointerDownOutside={() => setClicked(false)} - {...contentProps} + {...resolvedContentProps} > {label} {arrow && } From afa81912b8dbe9fc0537c8dc48e56dd29ab01df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 27 May 2026 11:09:56 +0200 Subject: [PATCH 2/5] Add fullscreen view for Mermaid diagrams Enlarge a Mermaid diagram into a centered dialog from a control in the bottom-right toolbar. Closes on click outside, Escape, or the reduce control, with enter/exit animations. The live diagram is reparented (not re-rendered) so panzoom and the SVG are preserved, and the inline slot reserves its height to avoid a layout jump. Uses react-aria FocusScope and usePreventScroll. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/mermaid-fullscreen.md | 5 + bun.lock | 3 +- packages/gitbook/package.json | 1 + .../CodeBlock/MermaidCodeBlock.tsx | 155 ++++++++++++++++-- .../CodeBlock/MermaidPanZoomControls.tsx | 119 ++++++++++---- 5 files changed, 242 insertions(+), 41 deletions(-) create mode 100644 .changeset/mermaid-fullscreen.md diff --git a/.changeset/mermaid-fullscreen.md b/.changeset/mermaid-fullscreen.md new file mode 100644 index 0000000000..112eef8d8b --- /dev/null +++ b/.changeset/mermaid-fullscreen.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Allow Mermaid diagrams to be enlarged into a fullscreen dialog from a control in the bottom-right corner. Clicking outside the dialog, pressing Escape, or using the reduce control returns to the inline view. diff --git a/bun.lock b/bun.lock index 5ce64721dc..6ac92a972f 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "devDependencies": { "@biomejs/biome": "^1.9.4", "@changesets/cli": "^2.31.0", - "turbo": "^2.9.12", + "turbo": "^2.9.14", "vercel": "50.37.3", }, }, @@ -175,6 +175,7 @@ "p-retry": "^8.0.0", "quick-lru": "^7.0.1", "react": "19.2.4", + "react-aria": "^3.44.0", "react-dom": "19.2.4", "react-hotkeys-hook": "^4.4.1", "rehype-raw": "^7.0.0", diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index d192e47706..a0ded46c15 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -67,6 +67,7 @@ "p-retry": "^8.0.0", "quick-lru": "^7.0.1", "react": "19.2.4", + "react-aria": "^3.44.0", "react-dom": "19.2.4", "react-hotkeys-hook": "^4.4.1", "rehype-raw": "^7.0.0", diff --git a/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx b/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx index 9e351a6716..908566fd21 100644 --- a/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/CodeBlock/MermaidCodeBlock.tsx @@ -1,17 +1,22 @@ 'use client'; import { useTheme } from 'next-themes'; -import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useHasBeenInViewport } from '@/components/hooks/useHasBeenInViewport'; import { Loading } from '@/components/primitives/Loading'; import { tcls } from '@/lib/tailwind'; import Panzoom from '@panzoom/panzoom'; import type { RenderResult } from 'mermaid'; +import { FocusScope, usePreventScroll } from 'react-aria'; import { type ClientBlockProps, ClientCodeBlock } from './ClientCodeBlock'; import { MermaidPanZoomControls } from './MermaidPanZoomControls'; import { getPlainCodeBlock } from './highlight'; +/** Duration of the fullscreen dialog enter/exit animation, must match `animate-blur-in/out`. */ +const DIALOG_ANIMATION_MS = 200; + /** * Used to render a Mermaid diagram from a CodeBlock. */ @@ -19,11 +24,18 @@ export function MermaidCodeBlock(props: ClientBlockProps) { const { block, mode, style } = props; const source = getPlainCodeBlock(block); const rootRef = useRef(null); + const movableRef = useRef(null); + const panelRef = useRef(null); const wrapperRef = useRef(null); const diagramRef = useRef(null); const [panZoom, setPanZoom] = useState | null>(null); const [error, setError] = useState(false); const [isLoading, setIsLoading] = useState(true); + // `isFullscreen` is the open intent; `isExiting` keeps the dialog mounted while it + // animates closed. `isPresent` is true whenever the diagram lives in the dialog. + const [isFullscreen, setIsFullscreen] = useState(false); + const [isExiting, setIsExiting] = useState(false); + const isPresent = isFullscreen || isExiting; const { resolvedTheme } = useTheme(); const darkMode = resolvedTheme === 'dark'; const id = useSafeId(); @@ -96,27 +108,105 @@ export function MermaidCodeBlock(props: ClientBlockProps) { }; }, [source, id, darkMode, shouldRender]); + // Lock the page scroll while the dialog is on screen (handles scrollbar width and iOS). + usePreventScroll({ isDisabled: !isPresent }); + + const openFullscreen = useCallback(() => { + // Reserve the inline slot's current height before the diagram is detached, so the + // page layout does not jump. Measured here while still inline and un-restyled. + const root = rootRef.current; + if (root) { + root.style.minHeight = `${root.offsetHeight}px`; + } + setIsExiting(false); + setIsFullscreen(true); + // Re-center the diagram for the larger view. + panZoom?.reset(); + }, [panZoom]); + + const closeFullscreen = useCallback(() => { + setIsFullscreen(false); + setIsExiting(true); + }, []); + + // Keep the dialog mounted until the exit animation finishes, then unmount it. + useEffect(() => { + if (!isExiting) { + return; + } + + const timer = window.setTimeout(() => { + setIsExiting(false); + panZoom?.reset(); + }, DIALOG_ANIMATION_MS); + return () => window.clearTimeout(timer); + }, [isExiting, panZoom]); + + // Allow Escape to close the dialog. + useEffect(() => { + if (!isFullscreen) { + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeFullscreen(); + } + }; + + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [isFullscreen, closeFullscreen]); + + // Move the live diagram into the fullscreen dialog (and back) without re-rendering it, + // so panzoom and the rendered SVG are preserved. Done in the panel's ref callback so it + // happens during commit, before FocusScope reads focus. The inline slot's reserved + // height (set in openFullscreen) is cleared once the diagram returns to it. + const setPanel = useCallback((panel: HTMLDivElement | null) => { + panelRef.current = panel; + const root = rootRef.current; + const movable = movableRef.current; + if (!root || !movable) { + return; + } + + if (panel) { + panel.appendChild(movable); + } else { + root.appendChild(movable); + root.style.minHeight = ''; + } + }, []); + if (error) { return ; } - return ( + // The live diagram subtree. It is reparented between the inline slot and the + // fullscreen dialog, so its markup must not depend on where it currently lives. + const diagram = (
{isLoading ? ( @@ -124,9 +214,54 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
) : null} - {!isLoading && panZoom ? : null} + {!isLoading && panZoom ? ( + + ) : null}
); + + return ( + <> + {/* Inline slot: keeps the diagram in the document flow until it goes fullscreen. */} +
+ {diagram} +
+ {isPresent + ? createPortal( + + {/* Backdrop: dims and blurs the page, closes on click. */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: a global Escape handler closes the dialog. */} +