mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 15:45:13 +00:00
Merge remote-tracking branch 'origin/main' into stevenh/synced-blocks-new-api
This commit is contained in:
@@ -1,7 +1,3 @@
|
||||
<!-- <p align="center">
|
||||
<img alt="GitBook Open Header" src="./assets/gitbook-open-header.png">
|
||||
</p> -->
|
||||
|
||||
<h1 align="center">GitBook</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -28,8 +24,10 @@
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Contributing](#contributing)
|
||||
- [Types of contributions](#types-of-contributions)
|
||||
- [Types of contributions](#types-of-contributions)
|
||||
- [Licensing](#license)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
- [Legacy GitBook](#legacy-gitbook-deprecated)
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -120,3 +118,7 @@ GitBook wouldn't be possible without these projects:
|
||||
- [Bun](https://bun.sh/)
|
||||
- [Tailwind CSS](https://tailwindcss.com/)
|
||||
- [Framer Motion](https://www.npmjs.com/package/framer-motion)
|
||||
|
||||
## Legacy GitBook (Deprecated)
|
||||
|
||||
Our previous version of GitBook and it's CLI tool are now deprecated. You can still view the old repository and it's commits on this [branch](https://github.com/GitbookIO/gitbook/tree/legacy).
|
||||
|
||||
+15
-14
@@ -52,7 +52,6 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'Search',
|
||||
url: '?q=',
|
||||
fullPage: false,
|
||||
},
|
||||
{
|
||||
name: 'Search Results',
|
||||
@@ -60,7 +59,6 @@ const testCases: TestsCase[] = [
|
||||
run: async (page) => {
|
||||
await page.waitForSelector('[data-test="search-results"]');
|
||||
},
|
||||
fullPage: false,
|
||||
},
|
||||
{
|
||||
name: 'AI Search',
|
||||
@@ -77,17 +75,6 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Snyk',
|
||||
baseUrl: 'https://docs.snyk.io',
|
||||
tests: [
|
||||
{
|
||||
name: 'Home',
|
||||
url: '',
|
||||
run: waitForCookiesDialog,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Versioning',
|
||||
baseUrl: 'https://gitbook.gitbook.io/test-1-1/',
|
||||
@@ -127,6 +114,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'Images',
|
||||
url: 'blocks/block-images',
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Inline Images',
|
||||
@@ -152,6 +140,7 @@ const testCases: TestsCase[] = [
|
||||
name: 'Tables',
|
||||
url: 'blocks/tables',
|
||||
run: waitForCookiesDialog,
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
name: 'Expandables',
|
||||
@@ -197,6 +186,18 @@ const testCases: TestsCase[] = [
|
||||
name: 'Math',
|
||||
url: 'blocks/math',
|
||||
},
|
||||
{
|
||||
name: 'Embeds',
|
||||
url: 'blocks/embeds',
|
||||
},
|
||||
{
|
||||
name: 'Annotations',
|
||||
url: 'blocks/annotations',
|
||||
run: async (page) => {
|
||||
await page.waitForSelector('[data-testid="annotation-button"]');
|
||||
await page.click('[data-testid="annotation-button"]');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -482,7 +483,7 @@ for (const testCase of testCases) {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
fullPage: testEntry.fullPage,
|
||||
fullPage: testEntry.fullPage ?? false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-medium-image-zoom": "^5.1.10",
|
||||
"recoil": "^0.7.7",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-stringify": "^10.0.0",
|
||||
|
||||
@@ -261,7 +261,9 @@ function getSchemaProperties(schema: OpenAPIV3.SchemaObject): null | OpenAPISche
|
||||
|
||||
result.push({
|
||||
propertyName,
|
||||
required: schema.required?.includes(propertyName),
|
||||
required: Array.isArray(schema.required)
|
||||
? schema.required.includes(propertyName)
|
||||
: undefined,
|
||||
schema: propertySchema,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
data-testid="annotation-button"
|
||||
aria-label={tString(language, 'annotation_button_label')}
|
||||
className={tcls(
|
||||
'decoration-dotted',
|
||||
@@ -44,6 +45,7 @@ export function AnnotationPopover(props: { children: React.ReactNode; body: Reac
|
||||
'-outline-offset-2',
|
||||
'outline-2',
|
||||
'outline-primary/8',
|
||||
'z-20',
|
||||
)}
|
||||
sideOffset={5}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DocumentBlockSyncedBlock } from '@gitbook/api';
|
||||
|
||||
import { getSyncedBlockContent } from '@/lib/api';
|
||||
import { resolveContentRefWithFiles } from '@/lib/references';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
import { Blocks } from './Blocks';
|
||||
@@ -18,22 +19,34 @@ export async function BlockSyncedBlock(props: BlockProps<DocumentBlockSyncedBloc
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await getSyncedBlockContent(
|
||||
const syncedBlock = await getSyncedBlockContent(
|
||||
apiToken,
|
||||
context.contentRefContext.space.organization,
|
||||
block.data.ref.syncedBlock,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
if (!syncedBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Blocks
|
||||
nodes={result.document.nodes}
|
||||
document={result.document}
|
||||
nodes={syncedBlock.document.nodes}
|
||||
document={syncedBlock.document}
|
||||
ancestorBlocks={[...ancestorBlocks, block]}
|
||||
context={context}
|
||||
context={{
|
||||
...context,
|
||||
resolveContentRef: async (ref, options) => {
|
||||
if (!syncedBlock?.files) {
|
||||
return context.resolveContentRef(ref, options);
|
||||
}
|
||||
const result = resolveContentRefWithFiles(syncedBlock.files, ref);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
return context.resolveContentRef(ref, options);
|
||||
},
|
||||
}}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export function Caption(
|
||||
children: React.ReactNode;
|
||||
document: JSONDocument;
|
||||
style?: ClassValue;
|
||||
fit?: boolean;
|
||||
wrapperStyle?: ClassValue;
|
||||
block: DocumentBlockImage | DocumentBlockDrawing | DocumentBlockEmbed;
|
||||
} & DocumentContextProps,
|
||||
@@ -28,6 +29,7 @@ export function Caption(
|
||||
document,
|
||||
block,
|
||||
context,
|
||||
fit = false,
|
||||
wrapperStyle = [
|
||||
'relative',
|
||||
'overflow-hidden',
|
||||
@@ -43,6 +45,7 @@ export function Caption(
|
||||
'dark:after:border-light/1',
|
||||
'dark:after:mix-blend-plus-lighter',
|
||||
'after:pointer-events-none',
|
||||
fit ? 'w-fit' : null,
|
||||
],
|
||||
style,
|
||||
} = props;
|
||||
@@ -60,7 +63,7 @@ export function Caption(
|
||||
|
||||
return (
|
||||
<picture className={tcls('relative', style)}>
|
||||
<div className={tcls(wrapperStyle)}>{children}</div>
|
||||
<div className={tcls(wrapperStyle, 'mx-auto')}>{children}</div>
|
||||
<figcaption
|
||||
className={tcls(
|
||||
'text-sm',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DocumentBlockCode, JSONDocument } from '@gitbook/api';
|
||||
import { useId } from 'react';
|
||||
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
|
||||
@@ -8,8 +9,10 @@ import { CodeBlock } from './CodeBlock';
|
||||
*/
|
||||
export function PlainCodeBlock(props: { code: string; syntax: string }) {
|
||||
const { code, syntax } = props;
|
||||
const id = useId();
|
||||
|
||||
const block: DocumentBlockCode = {
|
||||
key: id,
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
|
||||
@@ -35,6 +35,33 @@ it('should parse plain code', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse different code in parallel', async () => {
|
||||
await Promise.all(
|
||||
['shell', 'scss', 'markdown', 'less', 'scss', 'css', 'scss', 'yaml'].map(async (syntax) =>
|
||||
highlight({
|
||||
object: 'block',
|
||||
type: 'code',
|
||||
data: {
|
||||
syntax: syntax,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
object: 'block',
|
||||
type: 'code-line',
|
||||
data: {},
|
||||
nodes: [
|
||||
{
|
||||
object: 'text',
|
||||
leaves: [{ object: 'leaf', marks: [], text: 'Hello world' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse a multilines plain code', async () => {
|
||||
const tokens = await highlight({
|
||||
object: 'block',
|
||||
@@ -561,45 +588,30 @@ it('should support multiple code tokens in an annotation', async () => {
|
||||
type: 'shiki',
|
||||
token: {
|
||||
content: 'const',
|
||||
color: '#000007',
|
||||
start: 0,
|
||||
end: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'shiki',
|
||||
token: {
|
||||
content: ' ',
|
||||
color: '#000001',
|
||||
start: 5,
|
||||
end: 6,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'shiki',
|
||||
token: {
|
||||
content: 'a',
|
||||
color: '#000004',
|
||||
start: 6,
|
||||
end: 7,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'shiki',
|
||||
token: {
|
||||
content: ' ',
|
||||
color: '#000001',
|
||||
start: 7,
|
||||
end: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'shiki',
|
||||
token: {
|
||||
content: '=',
|
||||
color: '#000007',
|
||||
start: 8,
|
||||
end: 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -613,27 +625,18 @@ it('should support multiple code tokens in an annotation', async () => {
|
||||
type: 'shiki',
|
||||
token: {
|
||||
content: 'hello',
|
||||
color: '#000004',
|
||||
start: 9,
|
||||
end: 14,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'shiki',
|
||||
token: {
|
||||
content: '.world',
|
||||
color: '#000009',
|
||||
start: 14,
|
||||
end: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'shiki',
|
||||
token: {
|
||||
content: '(',
|
||||
color: '#000001',
|
||||
start: 20,
|
||||
end: 21,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -642,9 +645,6 @@ it('should support multiple code tokens in an annotation', async () => {
|
||||
type: 'shiki',
|
||||
token: {
|
||||
content: ');',
|
||||
color: '#000001',
|
||||
start: 21,
|
||||
end: 23,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
// @ts-ignore - onigWasm is a Wasm module
|
||||
import onigWasm from 'shiki/onig.wasm?module';
|
||||
|
||||
import { singleton, singletonMap } from '@/lib/async';
|
||||
import { asyncMutexFunction, singleton } from '@/lib/async';
|
||||
import { getNodeText } from '@/lib/document';
|
||||
import { trace } from '@/lib/tracing';
|
||||
|
||||
@@ -314,10 +314,13 @@ const loadHighlighter = singleton(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
const loadHighlighterLanguage = singletonMap(async (lang: keyof typeof bundledLanguages) => {
|
||||
const highlighter = await loadHighlighter();
|
||||
await trace(
|
||||
`highlighting.loadLanguage(${lang})`,
|
||||
async () => await highlighter.loadLanguage(lang),
|
||||
);
|
||||
});
|
||||
const loadLanguagesMutex = asyncMutexFunction();
|
||||
async function loadHighlighterLanguage(lang: keyof typeof bundledLanguages) {
|
||||
await loadLanguagesMutex.runBlocking(async () => {
|
||||
const highlighter = await loadHighlighter();
|
||||
await trace(
|
||||
`highlighting.loadLanguage(${lang})`,
|
||||
async () => await highlighter.loadLanguage(lang),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
|
||||
sources={{
|
||||
light: {
|
||||
src: resolved.href,
|
||||
size: resolved.fileDimensions,
|
||||
size: resolved.file?.dimensions,
|
||||
},
|
||||
}}
|
||||
alt="Drawing"
|
||||
|
||||
@@ -4,7 +4,6 @@ import IconImage from '@geist-ui/icons/image';
|
||||
import IconPaperClip from '@geist-ui/icons/paperclip';
|
||||
import { DocumentBlockFile } from '@gitbook/api';
|
||||
|
||||
import { getRevisionFile } from '@/lib/api';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { BlockProps } from './Block';
|
||||
@@ -12,13 +11,9 @@ import { BlockProps } from './Block';
|
||||
export async function File(props: BlockProps<DocumentBlockFile>) {
|
||||
const { block, context, style } = props;
|
||||
|
||||
const file = context.content
|
||||
? await getRevisionFile(
|
||||
context.content.spaceId,
|
||||
context.content.revisionId,
|
||||
block.data.ref.file,
|
||||
)
|
||||
: null;
|
||||
const contentRef = await context.resolveContentRef(block.data.ref);
|
||||
const file = contentRef?.file;
|
||||
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -82,19 +82,19 @@ async function ImageBlock(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<Caption {...props}>
|
||||
<Caption {...props} fit>
|
||||
<Image
|
||||
alt={block.data.alt ?? ''}
|
||||
sizes={imageBlockSizes}
|
||||
sources={{
|
||||
light: {
|
||||
src: src.href,
|
||||
size: src.fileDimensions,
|
||||
size: src.file?.dimensions,
|
||||
},
|
||||
dark: darkSrc
|
||||
? {
|
||||
src: darkSrc.href,
|
||||
size: darkSrc.fileDimensions,
|
||||
size: darkSrc.file?.dimensions,
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
|
||||
@@ -32,12 +32,12 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
|
||||
sources={{
|
||||
light: {
|
||||
src: src.href,
|
||||
size: src.fileDimensions,
|
||||
size: src.file?.dimensions,
|
||||
},
|
||||
dark: darkSrc
|
||||
? {
|
||||
src: darkSrc.href,
|
||||
size: darkSrc.fileDimensions,
|
||||
size: darkSrc.file?.dimensions,
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
@@ -55,7 +55,7 @@ async function getImageSizes(size: 'original' | 'line', src: ResolvedContentRef)
|
||||
switch (size) {
|
||||
case 'line': {
|
||||
const imageSize =
|
||||
src.fileDimensions ??
|
||||
src.file?.dimensions ??
|
||||
(await getImageSize(src.href, {
|
||||
dpr: 3,
|
||||
}));
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
/** Method tag */
|
||||
|
||||
.openapi-method {
|
||||
@apply rounded font-mono font-bold text-[11.5px] px-1 py-0.5 mr-2 text-dark-1/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-dark-1/1 dark:ring-light-1/1;
|
||||
@apply rounded font-mono font-bold text-[11.5px] px-1 py-0.5 mr-2 text-dark-1/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-dark-1/1 dark:ring-light-1/1 whitespace-nowrap;
|
||||
}
|
||||
|
||||
.openapi-method-get {
|
||||
@@ -106,8 +106,14 @@
|
||||
@apply mt-3;
|
||||
}
|
||||
|
||||
.openapi-schema-name {
|
||||
/* To make double click on the property name select only the name,
|
||||
we disable selection on the parent and re-enable it on the children. */
|
||||
@apply select-none;
|
||||
}
|
||||
|
||||
.openapi-schema-propertyname {
|
||||
@apply font-medium text-base text-dark/10 dark:text-light/10;
|
||||
@apply select-all font-medium text-base text-dark/10 dark:text-light/10;
|
||||
}
|
||||
|
||||
.openapi-schema-required {
|
||||
@@ -115,7 +121,7 @@
|
||||
}
|
||||
|
||||
.openapi-schema-type {
|
||||
@apply font-mono ml-3 text-base text-dark-4/8 dark:text-light-4;
|
||||
@apply font-mono ml-3 text-base text-dark-4/8 dark:text-light-4 select-text;
|
||||
}
|
||||
|
||||
.openapi-schema-type:only-child {
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function RecordCard(
|
||||
sources={{
|
||||
light: {
|
||||
src: cover.href,
|
||||
size: cover.fileDimensions,
|
||||
size: cover.file?.dimensions,
|
||||
},
|
||||
}}
|
||||
sizes={[
|
||||
@@ -61,6 +61,7 @@ export async function RecordCard(
|
||||
},
|
||||
]}
|
||||
className={tcls(
|
||||
'min-w-0',
|
||||
'w-full',
|
||||
'h-full',
|
||||
'object-cover',
|
||||
@@ -73,10 +74,12 @@ export async function RecordCard(
|
||||
) : null}
|
||||
<div
|
||||
className={tcls(
|
||||
'min-w-0',
|
||||
'w-full',
|
||||
'flex',
|
||||
'flex-col',
|
||||
'place-self-start',
|
||||
'gap-3',
|
||||
'p-4',
|
||||
'text-sm',
|
||||
target
|
||||
|
||||
@@ -172,7 +172,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
case 'select': {
|
||||
return (
|
||||
<Tag className={tcls()}>
|
||||
<span className={tcls('inline-flex', 'gap-2')}>
|
||||
<span className={tcls('inline-flex', 'gap-2', 'flex-wrap')}>
|
||||
{(value as string[]).map((selectId) => {
|
||||
const option = definition.options.find(
|
||||
(option) => option.value === selectId,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ContentRefContext, resolveContentRef } from '@/lib/references';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import defaultPageCover from './default-page-cover.svg';
|
||||
import { PAGE_COVER_HEIGHT } from '../layout';
|
||||
|
||||
const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 };
|
||||
|
||||
@@ -43,7 +42,7 @@ export async function PageCover(props: {
|
||||
light: resolved
|
||||
? {
|
||||
src: resolved.href,
|
||||
size: resolved.fileDimensions,
|
||||
size: resolved.file?.dimensions,
|
||||
}
|
||||
: {
|
||||
src: defaultPageCover.src,
|
||||
|
||||
@@ -45,12 +45,10 @@ export function SearchModal(props: SearchModalProps) {
|
||||
const isSearchOpened = state !== null;
|
||||
React.useEffect(() => {
|
||||
if (isSearchOpened) {
|
||||
document.body.classList.add('search-open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('search-open');
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, [isSearchOpened]);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { checkIsHttpURL, getImageSize, getResizedImageURL } from '@/lib/images';
|
||||
import { ClassValue, tcls } from '@/lib/tailwind';
|
||||
|
||||
import { PolymorphicComponentProp } from './types';
|
||||
import { Zoom } from './Zoom';
|
||||
import { ZoomImage } from './ZoomImage';
|
||||
|
||||
export type ImageSize = { width: number; height: number };
|
||||
|
||||
@@ -87,6 +87,10 @@ interface ImageCommonProps {
|
||||
inlineStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface ImgDOMPropsWithSrc extends React.ComponentPropsWithoutRef<'img'> {
|
||||
src: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an image that will be swapped depending on the theme.
|
||||
* We don't use the `next/image` component because we need to load images from external sources,
|
||||
@@ -271,16 +275,14 @@ async function ImagePicture(
|
||||
});
|
||||
}
|
||||
|
||||
const img = (
|
||||
<img
|
||||
alt={alt}
|
||||
style={style}
|
||||
loading={loading}
|
||||
fetchPriority={fetchPriority}
|
||||
{...rest}
|
||||
{...attrs}
|
||||
/>
|
||||
);
|
||||
const imgProps: ImgDOMPropsWithSrc = {
|
||||
alt,
|
||||
style,
|
||||
loading,
|
||||
fetchPriority,
|
||||
...rest,
|
||||
...attrs,
|
||||
};
|
||||
|
||||
return zoom ? <Zoom wrapElement={inline ? 'span' : 'div'}>{img}</Zoom> : img;
|
||||
return zoom ? <ZoomImage {...imgProps} /> : <img {...imgProps} alt={imgProps.alt ?? ''} />;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
html.dark [data-rmiz-modal-overlay='visible'] {
|
||||
background-color: rgb(var(--dark-base));
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import ReactZoom, { UncontrolledProps } from 'react-medium-image-zoom';
|
||||
import 'react-medium-image-zoom/dist/styles.css';
|
||||
import './Zoom.css';
|
||||
|
||||
/**
|
||||
* Client component to zoom on an image.
|
||||
* See https://github.com/rpearce/react-medium-image-zoom
|
||||
*/
|
||||
export function Zoom(props: UncontrolledProps) {
|
||||
return <ReactZoom {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
html:has(.zoomModal) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.zoomImg {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.zoomImageActive {
|
||||
view-transition-name: zoom-image;
|
||||
}
|
||||
|
||||
.zoomModal {
|
||||
}
|
||||
|
||||
.zoomModal img {
|
||||
view-transition-name: zoom-image;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import IconMinimize from '@geist-ui/icons/minimize';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import styles from './ZoomImage.module.css';
|
||||
|
||||
/**
|
||||
* Replacement for an <img> tag that allows zooming.
|
||||
* The implementation uses the experimental View Transition API in Chrome for a smooth transition.
|
||||
*/
|
||||
export function ZoomImage(
|
||||
props: React.ComponentPropsWithoutRef<'img'> & {
|
||||
src: string;
|
||||
},
|
||||
) {
|
||||
const { src, alt, width } = props;
|
||||
|
||||
const imgRef = React.useRef<HTMLImageElement>(null);
|
||||
const [zoomable, setZoomable] = React.useState(false);
|
||||
const [active, setActive] = React.useState(false);
|
||||
const [opened, setOpened] = React.useState(false);
|
||||
const [placeholderRect, setPlaceholderRect] = React.useState<DOMRect | null>(null);
|
||||
|
||||
// Only allow zooming when image will not actually be larger and on mobile
|
||||
React.useEffect(() => {
|
||||
if (isTouchDevice()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageWidth = typeof width === 'number' ? width : 0;
|
||||
let viewWidth = 0;
|
||||
|
||||
const mediaQueryList = window.matchMedia('(min-width: 768px)');
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver((entries) => {
|
||||
const imgEntry = entries[0];
|
||||
|
||||
// Since the image is removed from the DOM when the modal is opened,
|
||||
// We only care when the size is defined.
|
||||
if (imgEntry && imgEntry.contentRect.width !== 0) {
|
||||
viewWidth = entries[0]?.contentRect.width;
|
||||
setPlaceholderRect(entries[0].contentRect);
|
||||
onChange();
|
||||
}
|
||||
})
|
||||
: null;
|
||||
|
||||
const onChange = () => {
|
||||
if (!mediaQueryList.matches) {
|
||||
// Don't allow zooming on mobile
|
||||
setZoomable(false);
|
||||
} else if (resizeObserver && imageWidth && viewWidth && imageWidth <= viewWidth) {
|
||||
// Image can't be zoomed if it's already rendered as it's largest size
|
||||
setZoomable(false);
|
||||
} else {
|
||||
setZoomable(true);
|
||||
}
|
||||
};
|
||||
|
||||
mediaQueryList.addEventListener('change', onChange);
|
||||
if (imgRef.current) {
|
||||
resizeObserver?.observe(imgRef.current);
|
||||
}
|
||||
|
||||
if (!resizeObserver) {
|
||||
// When resizeObserver is available, it'll take care of calling the changelog as soon as the element is observed
|
||||
onChange();
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
mediaQueryList.removeEventListener('change', onChange);
|
||||
};
|
||||
}, [imgRef, width]);
|
||||
|
||||
// Preload the image that will be displayed in the modal
|
||||
if (zoomable) {
|
||||
ReactDOM.preload(src, {
|
||||
as: 'image',
|
||||
});
|
||||
}
|
||||
const preloadImage = React.useCallback(
|
||||
(onLoad?: () => void) => {
|
||||
const image = new Image();
|
||||
image.src = src;
|
||||
|
||||
image.onload = () => {
|
||||
onLoad?.();
|
||||
};
|
||||
},
|
||||
[src],
|
||||
);
|
||||
|
||||
// When closing the modal, animate the transition back to the original image
|
||||
const onClose = React.useCallback(() => {
|
||||
startViewTransition(
|
||||
() => {
|
||||
setOpened(false);
|
||||
},
|
||||
() => {
|
||||
setActive(false);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{opened ? (
|
||||
<>
|
||||
{placeholderRect ? (
|
||||
// Placeholder to keep the layout stable when the image is removed from the DOM
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
width: placeholderRect.width,
|
||||
height: placeholderRect.height,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{ReactDOM.createPortal(
|
||||
<ZoomImageModal
|
||||
src={src}
|
||||
crossOrigin={props.crossOrigin}
|
||||
alt={alt ?? ''}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// When zooming, remove the image from the DOM to let the browser animates it with View Transition.
|
||||
<img
|
||||
ref={imgRef}
|
||||
{...props}
|
||||
alt={alt ?? ''}
|
||||
onMouseEnter={() => {
|
||||
if (zoomable) {
|
||||
preloadImage();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!zoomable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Preload the image before opening the modal to ensure the animation is smooth
|
||||
preloadImage(() => {
|
||||
const change = () => {
|
||||
setOpened(true);
|
||||
};
|
||||
|
||||
ReactDOM.flushSync(() => setActive(true));
|
||||
startViewTransition(change);
|
||||
});
|
||||
}}
|
||||
className={classNames(
|
||||
props.className,
|
||||
zoomable ? styles.zoomImg : null,
|
||||
active ? styles.zoomImageActive : null,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomImageModal(props: {
|
||||
src: string;
|
||||
alt: string;
|
||||
crossOrigin: React.ComponentPropsWithoutRef<'img'>['crossOrigin'];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { src, alt, crossOrigin, onClose } = props;
|
||||
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
React.useEffect(() => {
|
||||
buttonRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.zoomModal,
|
||||
tcls(
|
||||
'fixed',
|
||||
'inset-0',
|
||||
'z-50',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
'p-8',
|
||||
),
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
crossOrigin={crossOrigin}
|
||||
className={tcls(
|
||||
'max-w-full',
|
||||
'max-h-full',
|
||||
'object-contain',
|
||||
'bg-light',
|
||||
'dark:bg-dark',
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={tcls(
|
||||
'absolute',
|
||||
'top-5',
|
||||
'right-5',
|
||||
'flex',
|
||||
'flex-row',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'text-sm',
|
||||
'text-dark/6',
|
||||
'dark:text-light/5',
|
||||
'hover:text-primary',
|
||||
'p-4',
|
||||
'dark:text-light/5',
|
||||
'rounded-full',
|
||||
'bg-white',
|
||||
'dark:bg-dark/3',
|
||||
'shadow-sm',
|
||||
'hover:shadow-md',
|
||||
'border-slate-300',
|
||||
'dark:border-dark/2',
|
||||
'border',
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconMinimize />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function startViewTransition(callback: () => void, onEnd?: () => void) {
|
||||
// @ts-ignore
|
||||
if (document.startViewTransition) {
|
||||
// @ts-ignore
|
||||
const transition = document.startViewTransition(() => {
|
||||
ReactDOM.flushSync(() => callback());
|
||||
});
|
||||
transition.finished.then(() => {
|
||||
if (onEnd) {
|
||||
onEnd();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
callback();
|
||||
onEnd?.();
|
||||
}
|
||||
}
|
||||
|
||||
function isTouchDevice(): boolean {
|
||||
return (
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
// @ts-ignore
|
||||
navigator.msMaxTouchPoints > 0
|
||||
);
|
||||
}
|
||||
+86
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { race } from './async';
|
||||
import { asyncMutexFunction, race } from './async';
|
||||
import { flushWaitUntil } from './waitUntil';
|
||||
|
||||
describe('race', () => {
|
||||
@@ -380,3 +380,88 @@ describe('race', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('asyncMutexFunction', () => {
|
||||
describe('self', () => {
|
||||
it('should run only once', async () => {
|
||||
let running = 0;
|
||||
|
||||
const fn = async () => {
|
||||
running += 1;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
};
|
||||
|
||||
const mutexFn = asyncMutexFunction();
|
||||
|
||||
await Promise.all([
|
||||
mutexFn(fn),
|
||||
mutexFn(fn),
|
||||
mutexFn(fn),
|
||||
mutexFn(fn),
|
||||
mutexFn(fn),
|
||||
mutexFn(fn),
|
||||
]);
|
||||
|
||||
expect(running).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runBlocking', () => {
|
||||
it('should run only one function at a time', async () => {
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
|
||||
const fn = async () => {
|
||||
running += 1;
|
||||
maxRunning = Math.max(running, maxRunning);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
running -= 1;
|
||||
};
|
||||
|
||||
const mutexFn = asyncMutexFunction();
|
||||
|
||||
await Promise.all([
|
||||
mutexFn.runBlocking(fn),
|
||||
mutexFn.runBlocking(fn),
|
||||
mutexFn.runBlocking(fn),
|
||||
mutexFn.runBlocking(fn),
|
||||
mutexFn.runBlocking(fn),
|
||||
mutexFn.runBlocking(fn),
|
||||
]);
|
||||
|
||||
expect(maxRunning).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAfter', () => {
|
||||
it('should run only one function at a time', async () => {
|
||||
let running = 0;
|
||||
let maxRunning = 0;
|
||||
|
||||
const fn = async () => {
|
||||
running += 1;
|
||||
maxRunning = Math.max(running, maxRunning);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
running -= 1;
|
||||
};
|
||||
|
||||
const mutexFn = asyncMutexFunction();
|
||||
|
||||
await Promise.all([
|
||||
mutexFn.runAfter(fn),
|
||||
mutexFn.runAfter(fn),
|
||||
mutexFn.runAfter(fn),
|
||||
mutexFn.runAfter(fn),
|
||||
mutexFn.runAfter(fn),
|
||||
mutexFn.runAfter(fn),
|
||||
]);
|
||||
|
||||
expect(maxRunning).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -361,3 +361,112 @@ export function batch<Args extends any[], R>(
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
type MutexOperationOptions = {
|
||||
/**
|
||||
* If true, fail the operation if a pending operation on the mutex fails.
|
||||
* Defaults to true.
|
||||
*/
|
||||
failOnMutexError?: boolean;
|
||||
};
|
||||
|
||||
export type AsyncMutexFunction<T> = ((fn: () => Promise<T>) => Promise<T>) & {
|
||||
/**
|
||||
* Wait for a pending operation to complete.
|
||||
*/
|
||||
wait: () => Promise<unknown>;
|
||||
|
||||
/**
|
||||
* Execute a function after the previous operation completes.
|
||||
*/
|
||||
runAfter: (fn: () => Promise<T>, options?: MutexOperationOptions) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Execute a function that blocks the mutex, but does not influence the return value of the mutex.
|
||||
*/
|
||||
runBlocking: <ReturnType>(
|
||||
fn: () => Promise<ReturnType>,
|
||||
options?: MutexOperationOptions,
|
||||
) => Promise<ReturnType>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a function that will only call the given function once at a time.
|
||||
*/
|
||||
export function asyncMutexFunction<T>(): AsyncMutexFunction<T> {
|
||||
let pending:
|
||||
| undefined
|
||||
| { kind: 'value'; promise: Promise<T> }
|
||||
| { kind: 'blocking'; promise: Promise<unknown> };
|
||||
|
||||
const mutex: AsyncMutexFunction<T> = async (fn) => {
|
||||
if (pending?.kind === 'value') {
|
||||
return pending.promise;
|
||||
}
|
||||
|
||||
while (pending) {
|
||||
await pending.promise;
|
||||
}
|
||||
|
||||
const promise = fn();
|
||||
pending = { kind: 'value', promise };
|
||||
try {
|
||||
const result = await promise;
|
||||
return result;
|
||||
} finally {
|
||||
pending = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
mutex.wait = async () => {
|
||||
return pending?.promise;
|
||||
};
|
||||
|
||||
mutex.runBlocking = async (fn, options) => {
|
||||
const failOnMutexError = options?.failOnMutexError ?? true;
|
||||
|
||||
while (pending) {
|
||||
try {
|
||||
await pending.promise;
|
||||
} catch (err) {
|
||||
if (failOnMutexError) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const promise = fn();
|
||||
pending = { kind: 'blocking', promise };
|
||||
try {
|
||||
const result = await promise;
|
||||
return result;
|
||||
} finally {
|
||||
pending = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
mutex.runAfter = async (fn, options) => {
|
||||
const failOnMutexError = options?.failOnMutexError ?? true;
|
||||
|
||||
while (pending) {
|
||||
try {
|
||||
await pending.promise;
|
||||
} catch (err) {
|
||||
if (failOnMutexError) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const promise = fn();
|
||||
pending = { kind: 'value', promise };
|
||||
try {
|
||||
const result = await pending.promise;
|
||||
return result;
|
||||
} finally {
|
||||
pending = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return mutex;
|
||||
}
|
||||
|
||||
+23
-4
@@ -1,4 +1,4 @@
|
||||
import { ContentRef, Revision, RevisionPageDocument, Space } from '@gitbook/api';
|
||||
import { ContentRef, Revision, RevisionFile, RevisionPageDocument, Space } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
|
||||
import {
|
||||
@@ -24,8 +24,8 @@ export interface ResolvedContentRef {
|
||||
href: string;
|
||||
/** True if the content ref is active */
|
||||
active: boolean;
|
||||
/** Image size, if the reference is a image file */
|
||||
fileDimensions?: { width: number; height: number };
|
||||
/** File, if the reference is a file */
|
||||
file?: RevisionFile;
|
||||
}
|
||||
|
||||
export interface ContentRefContext extends PageHrefContext {
|
||||
@@ -90,7 +90,7 @@ export async function resolveContentRef(
|
||||
href: file.downloadURL,
|
||||
text: file.name,
|
||||
active: false,
|
||||
fileDimensions: file.dimensions,
|
||||
file,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
@@ -251,3 +251,22 @@ async function resolveContentRefInSpace(spaceId: string, contentRef: ContentRef)
|
||||
baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveContentRefWithFiles(
|
||||
files: RevisionFile[],
|
||||
contentRef: ContentRef,
|
||||
): ResolvedContentRef | null | undefined {
|
||||
if (contentRef.kind === 'file') {
|
||||
const file = files.find((file) => file.id === contentRef.file);
|
||||
if (file) {
|
||||
return {
|
||||
href: file.downloadURL,
|
||||
text: file.name,
|
||||
active: false,
|
||||
file,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+23
-1
@@ -569,7 +569,29 @@ async function lookupSpaceByAPI(
|
||||
}
|
||||
|
||||
if ('redirect' in data) {
|
||||
if (alternative.url === url.toString()) {
|
||||
if (alternative.primary) {
|
||||
// Append the path to the redirect URL
|
||||
// because we might have matched a shorter path and the redirect is relative to it
|
||||
if (alternative.extraPath) {
|
||||
if (data.target === 'content') {
|
||||
const redirect = new URL(data.redirect);
|
||||
redirect.pathname = joinPath(redirect.pathname, alternative.extraPath);
|
||||
data.redirect = redirect.toString();
|
||||
} else {
|
||||
const redirect = new URL(data.redirect);
|
||||
if (redirect.searchParams.has('location')) {
|
||||
redirect.searchParams.set(
|
||||
'location',
|
||||
joinPath(
|
||||
redirect.searchParams.get('location') ?? '',
|
||||
alternative.extraPath,
|
||||
),
|
||||
);
|
||||
data.redirect = redirect.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -199,11 +199,6 @@ const config: Config = {
|
||||
*/
|
||||
addVariant('navigation-open', 'body.navigation-open &');
|
||||
|
||||
/**
|
||||
* Variant when the search overlay is open.
|
||||
*/
|
||||
addVariant('search-open', 'body.search-open &');
|
||||
|
||||
/**
|
||||
* Variant when a header is displayed.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user