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/.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/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..9d0210fbcd 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, useLayoutEffect, 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,28 @@ export function MermaidCodeBlock(props: ClientBlockProps) { const { block, mode, style } = props; const source = getPlainCodeBlock(block); const rootRef = useRef(null); + const panelRef = useRef(null); const wrapperRef = useRef(null); const diagramRef = useRef(null); + // A stable container that holds the diagram subtree. We portal the diagram into it and + // only ever move this plain node between the inline slot and the dialog — never the + // React-managed subtree itself — so React stays in control and panzoom/SVG are preserved. + const diagramHostRef = useRef(null); + if (diagramHostRef.current === null && typeof document !== 'undefined') { + const host = document.createElement('div'); + // `display: contents` so the host adds no box of its own (the diagram becomes a + // direct flex child of the dialog panel and can fill it). + host.style.display = 'contents'; + diagramHostRef.current = host; + } 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 +118,114 @@ 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]); + + // Keep the diagram host in the inline slot on mount (and whenever it isn't in the dialog). + useLayoutEffect(() => { + const host = diagramHostRef.current; + const root = rootRef.current; + if (host && root && !host.parentNode) { + root.appendChild(host); + } + }, []); + + // Move the diagram host into the dialog panel (and back) as the panel mounts/unmounts. + // 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 on return. + const setPanel = useCallback((panel: HTMLDivElement | null) => { + panelRef.current = panel; + const host = diagramHostRef.current; + const root = rootRef.current; + if (!host) { + return; + } + + if (panel) { + panel.appendChild(host); + } else if (root) { + root.appendChild(host); + root.style.minHeight = ''; + } + }, []); + if (error) { return ; } - return ( + // The live diagram subtree. It is portaled into a stable host that moves between the + // inline slot and the dialog, so its markup must not depend on where it currently lives. + const diagram = (
{isLoading ? ( @@ -124,9 +233,53 @@ export function MermaidCodeBlock(props: ClientBlockProps) {
) : null} - {!isLoading && panZoom ? : null} + {!isLoading && panZoom ? ( + + ) : null}
); + + return ( + <> + {/* Inline slot: hosts the diagram in the document flow until it goes fullscreen. */} +
+ {diagramHostRef.current ? createPortal(diagram, diagramHostRef.current) : null} + {isPresent + ? createPortal( + + {/* Backdrop: dims and blurs the page, closes on click. */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: a global Escape handler closes the dialog. */} +