Expandable code block (#3753)

This commit is contained in:
Brett Jephson
2025-10-24 14:01:31 +01:00
committed by GitHub
parent 0ae7cfd790
commit b3fa503aff
17 changed files with 108 additions and 12 deletions
+2 -2
View File
@@ -343,7 +343,7 @@
"react-dom": "catalog:",
},
"catalog": {
"@gitbook/api": "0.143.2",
"@gitbook/api": "0.145.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -724,7 +724,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
"@gitbook/api": ["@gitbook/api@0.143.2", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ZIQwIgX+artl0o20ftnzeJyM9icwQ6pFeM55AG/lEdeFbMuUGxTfSnZ90+8Aou8l/3ELR3/tOkU2SAvyKc+ATw=="],
"@gitbook/api": ["@gitbook/api@0.145.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-pq+lqUPdvrVstpojs7uOimaNePg16uE1X2Dx7VDulqResmNp/FiV8a1i99vlYHhfhVA/U7i6wAN0iX4zi0/YZA=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -42,7 +42,7 @@
"catalog": {
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@gitbook/api": "0.143.2",
"@gitbook/api": "0.145.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@@ -1,11 +1,14 @@
'use client';
import type { DocumentBlockCode } from '@gitbook/api';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useAdaptiveVisitor } from '@/components/Adaptive';
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
import { useScrollListener } from '@/components/hooks/useScrollListener';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { useDebounceCallback } from 'usehooks-ts';
import type { BlockProps } from '../Block';
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
@@ -18,6 +21,8 @@ type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> &
inlineExprVariables: InlineExpressionVariables;
};
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
/**
* Render a code-block client-side by loading the highlighter asynchronously.
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
@@ -113,13 +118,82 @@ export function ClientCodeBlock(props: ClientBlockProps) {
setLines(null);
}, [isInViewport, block, inlines, evaluateInlineExpression]);
return (
const expandable = block.data.expandable;
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
const collapsedLineCount =
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
const isExpandable = Boolean(expandable && numberOfLinesOfCode > collapsedLineCount);
const codeBlockBodyId = useId();
const renderer = (
<CodeBlockRenderer
ref={blockRef}
aria-busy={highlighting}
block={block}
style={style}
lines={lines ?? plainLines}
id={codeBlockBodyId}
/>
);
return isExpandable ? (
<CodeBlockExpandable
lines={lines ?? plainLines}
controls={codeBlockBodyId}
collapsedLineCount={collapsedLineCount}
>
{renderer}
</CodeBlockExpandable>
) : (
renderer
);
}
function CodeBlockExpandable(props: {
children: React.ReactNode;
lines: HighlightLine[];
collapsedLineCount: number;
controls?: string;
}) {
const { children, controls, lines = [], collapsedLineCount } = props;
const [isExpanded, setIsExpanded] = useState(false);
const language = useLanguage();
return (
<div className="group/codeblock-expandable relative">
<div
className={tcls(
isExpanded
? '[&_pre]:after:opacity-0'
: '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden [&_pre]:after:opacity-100',
'[&_pre]:after:pointer-events-none [&_pre]:after:absolute [&_pre]:after:inset-0 [&_pre]:after:z-1 [&_pre]:after:bg-gradient-to-t [&_pre]:after:from-0% [&_pre]:after:from-tint-2 [&_pre]:after:to-70% [&_pre]:after:to-transparent [&_pre]:after:content-[""]'
)}
style={
{
'--line-count': collapsedLineCount,
'--line-height': '1.25rem',
} as React.CSSProperties
}
>
{children}
</div>
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
<Button
icon={isExpanded ? 'chevron-up' : 'chevron-down'}
size="xsmall"
variant="blank"
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
aria-expanded={isExpanded}
aria-controls={controls}
>
{isExpanded
? t(language, 'code_block_expanded')
: t(language, 'code_block_collapsed', lines.length)}
</Button>
</div>
</div>
);
}
@@ -50,7 +50,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
return { inline, body };
});
if (!isEstimatedOffscreen && !hasInlineExpression) {
if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
// In v2, we render the code block server-side
const lines = await highlight(block, richInlines);
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
@@ -12,6 +12,7 @@ import type { HighlightLine, HighlightToken } from './highlight';
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
lines: HighlightLine[];
'aria-busy'?: boolean;
id?: string;
};
/**
@@ -23,11 +24,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) {
const { block, style, lines, 'aria-busy': ariaBusy } = props;
const id = useId();
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
const withWrap = block.data.overflow === 'wrap';
const title = block.data.title;
const id = useId();
const codeId = props.id || id;
return (
<div
ref={ref}
@@ -42,7 +44,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
) : null}
</div>
<CopyCodeButton
codeId={id}
codeId={codeId}
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
/>
<pre
@@ -53,7 +55,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
)}
>
<code
id={id}
id={codeId}
className={tcls(
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
withWrap && 'whitespace-pre-wrap'
@@ -8,10 +8,8 @@ import { textColorToStyle } from './utils/colors';
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
const { inline } = props;
const icon = inline.data.icon as IconName;
// @ts-expect-error remove this comment once API is updated
const color = inline.data.color
? // @ts-expect-error remove "as DocumentMarkColor['data']['text']" once API is updated
(inline.data.color as DocumentMarkColor['data']['text'])
? (inline.data.color as DocumentMarkColor['data']['text'])
: undefined;
return (
@@ -45,6 +45,8 @@ export const de = {
annotation_button_label: 'Kommentar öffnen',
code_copied: 'Kopiert!',
code_copy: 'Kopieren',
code_block_collapsed: 'Alle ${1} Zeilen anzeigen',
code_block_expanded: 'Weniger anzeigen',
table_of_contents_button_label: 'Inhaltsverzeichnis öffnen',
cookies_title: 'Cookies',
cookies_prompt:
@@ -45,6 +45,8 @@ export const en = {
annotation_button_label: 'Open annotation',
code_copied: 'Copied!',
code_copy: 'Copy',
code_block_collapsed: 'Show all ${1} lines',
code_block_expanded: 'Show less',
table_of_contents_button_label: 'Open table of contents',
cookies_title: 'Cookies',
cookies_prompt:
@@ -47,6 +47,8 @@ export const es: TranslationLanguage = {
annotation_button_label: 'Abrir anotación',
code_copied: '¡Copiado!',
code_copy: 'Copiar',
code_block_collapsed: 'Mostrar las ${1} líneas',
code_block_expanded: 'Mostrar menos',
table_of_contents_button_label: 'Abrir índice de contenidos',
cookies_title: 'Cookies',
cookies_prompt:
@@ -44,6 +44,8 @@ export const fr = {
annotation_button_label: 'Afficher lannotation',
code_copied: 'Copié !',
code_copy: 'Copier',
code_block_collapsed: 'Afficher les ${1} lignes',
code_block_expanded: 'Afficher moins',
table_of_contents_button_label: 'Afficher le sommaire',
cookies_title: 'Cookies',
cookies_prompt:
@@ -47,6 +47,8 @@ export const it: TranslationLanguage = {
annotation_button_label: 'Apri annotazione',
code_copied: 'Copiato!',
code_copy: 'Copia',
code_block_collapsed: 'Mostra tutte le ${1} righe',
code_block_expanded: 'Mostra meno',
table_of_contents_button_label: 'Apri indice dei contenuti',
cookies_title: 'Cookie',
cookies_prompt:
@@ -47,6 +47,8 @@ export const ja: TranslationLanguage = {
annotation_button_label: '注釈を開く',
code_copied: 'コピーしました!',
code_copy: 'コピー',
code_block_collapsed: 'すべての${1}行を表示',
code_block_expanded: '折りたたむ',
table_of_contents_button_label: '目次を開く',
cookies_title: 'クッキー',
cookies_prompt:
@@ -47,6 +47,8 @@ export const nl: TranslationLanguage = {
annotation_button_label: 'Open annotatie',
code_copied: 'Gekopieerd!',
code_copy: 'Kopiëren',
code_block_collapsed: 'Toon alle ${1} regels',
code_block_expanded: 'Toon minder',
table_of_contents_button_label: 'Open inhoudsopgave',
cookies_title: 'Cookies',
cookies_prompt:
@@ -47,6 +47,8 @@ export const no: TranslationLanguage = {
annotation_button_label: 'Åpne merknad',
code_copied: 'Kopiert!',
code_copy: 'Kopier',
code_block_collapsed: 'Vis alle ${1} linjer',
code_block_expanded: 'Vis færre',
table_of_contents_button_label: 'Åpne innholdsfortegnelse',
cookies_title: 'Informasjonskapsler',
cookies_prompt:
@@ -45,6 +45,8 @@ export const pt_br = {
annotation_button_label: 'Abrir anotação',
code_copied: 'Copiado!',
code_copy: 'Copiar',
code_block_collapsed: 'Mostrar todas as ${1} linhas',
code_block_expanded: 'Mostrar menos',
table_of_contents_button_label: 'Abrir o índice',
cookies_title: 'Cookies',
cookies_prompt:
@@ -45,6 +45,8 @@ export const ru = {
annotation_button_label: 'Открыть аннотацию',
code_copied: 'Скопировано!',
code_copy: 'Копировать',
code_block_collapsed: 'Показать все ${1} строк',
code_block_expanded: 'Показать меньше',
table_of_contents_button_label: 'Открыть оглавление',
cookies_title: 'Файлы cookies',
cookies_prompt:
@@ -46,6 +46,8 @@ export const zh: TranslationLanguage = {
annotation_button_label: '打开批注',
code_copied: '已复制!',
code_copy: '复制',
code_block_collapsed: '显示全部 ${1} 行',
code_block_expanded: '收起',
table_of_contents_button_label: '打开目录',
cookies_title: 'Cookies',
cookies_prompt: '本站使用 cookie 来提供服务并分析流量。浏览本站,即表示您接受${1}。',