mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-05 12:54:22 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5d1bbfafe | |||
| dac5e9d444 | |||
| f647af2003 | |||
| 96e24a1f84 | |||
| 2a0f37f4b3 | |||
| 17d935dd4c | |||
| df07fbe242 | |||
| 1e53376151 | |||
| f294818775 | |||
| eed386a133 | |||
| e172b25ba8 | |||
| f7f6f9d06d |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add sidesheet component, use it for TOC and AIChat
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Set <html lang> based on translated language
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gitbook/embed": patch
|
||||
---
|
||||
|
||||
Fix signed JWT token usage
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Serve a markdown version of the page when the "Accept" header contains "text/markdown".
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Grow InlineActionButton to take available width on line
|
||||
@@ -56,6 +56,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "catalog:",
|
||||
"bun-types": "catalog:",
|
||||
"react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
@@ -156,6 +157,7 @@
|
||||
"micromark-extension-frontmatter": "^2.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"motion": "^12.23.24",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "15.4.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.2.3",
|
||||
@@ -195,6 +197,7 @@
|
||||
"@types/jsontoxml": "^1.0.5",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/negotiator": "^0.6.4",
|
||||
"@types/node": "^20",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/parse-cache-control": "^1.0.4",
|
||||
@@ -345,7 +348,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.155.0",
|
||||
"@gitbook/api": "0.158.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -726,7 +729,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.1.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.1.0" } }, "sha512-fNxRUk1KhjSbnbuBxlWSnBLKLBNun52ZBTcs22H/xEEzM6Ap81ZFTQ4bZBxVQGQgVY0xugKGoRcCbaKjLQ3XZA=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.155.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-g4UfJRnej/GZ9pORHcS9mV0kKiJLRvhMlGOeOoEVU/LSIz6s35VDqauSBs7rH00AOaMABDg7uEko8tUSA4aaww=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.158.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-oWrUY0gQ4rVKyNciHaXQrvi+EoiLCWHhAD0QyAhsHJp9+ndaLRMV6WfK2KbiGQmH8kiWwwBmwmQfMkZFmm1eyQ=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
@@ -1532,6 +1535,8 @@
|
||||
|
||||
"@types/ms": ["@types/ms@0.7.34", "", {}, "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="],
|
||||
|
||||
"@types/negotiator": ["@types/negotiator@0.6.4", "", {}, "sha512-elf6BsTq+AkyNsb2h5cGNst2Mc7dPliVoAPm1fXglC/BM3f2pFA40BaSSv3E5lyHteEawVKLP+8TwiY1DMNb3A=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="],
|
||||
|
||||
"@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="],
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.155.0",
|
||||
"@gitbook/api": "0.158.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"react": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
@@ -33,6 +34,7 @@
|
||||
"build-lib": "tsdown",
|
||||
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||
"clean": "rm -rf ./dist",
|
||||
"unit": "bun test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "bun run build -- --watch ./src",
|
||||
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import { createGitBook } from './createGitBook';
|
||||
|
||||
describe('createGitBook.getFrameURL', () => {
|
||||
it('builds the embed URL when the site URL has no trailing slash', () => {
|
||||
const client = createGitBook({ siteURL: 'https://example.com/docs' });
|
||||
|
||||
const url = new URL(client.getFrameURL({}));
|
||||
|
||||
expect(url.origin).toBe('https://example.com');
|
||||
expect(url.pathname).toBe('/docs/~gitbook/embed');
|
||||
expect(url.searchParams.toString()).toBe('');
|
||||
});
|
||||
|
||||
it('handles a trailing slash and adds visitor parameters', () => {
|
||||
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
|
||||
|
||||
const url = new URL(
|
||||
client.getFrameURL({
|
||||
visitor: {
|
||||
token: 'signed-token',
|
||||
unsignedClaims: {
|
||||
role: 'editor',
|
||||
count: 3,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(url.pathname).toBe('/docs/~gitbook/embed');
|
||||
expect(url.searchParams.get('jwt_token')).toBe('signed-token');
|
||||
expect(url.searchParams.get('visitor.role')).toBe('editor');
|
||||
expect(url.searchParams.get('visitor.count')).toBe('3');
|
||||
expect(url.searchParams.get('visitor.enabled')).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export function createGitBook(options: CreateGitBookOptions) {
|
||||
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
|
||||
|
||||
if (frameOptions.visitor?.token) {
|
||||
url.searchParams.set('token', frameOptions.visitor.token);
|
||||
url.searchParams.set('jwt_token', frameOptions.visitor.token);
|
||||
}
|
||||
|
||||
if (frameOptions.visitor?.unsignedClaims) {
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": []
|
||||
"types": [
|
||||
"bun-types" // add Bun global
|
||||
]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -677,70 +677,6 @@ const testCases: TestsCase[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Markdown page',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'Text page',
|
||||
url: 'text-page.md',
|
||||
screenshot: false,
|
||||
run: async (_page, response) => {
|
||||
expect(response?.status()).toBe(200);
|
||||
expect(response?.headers()['content-type']).toContain('text/markdown');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'llms.txt',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'llms.txt',
|
||||
url: 'llms.txt',
|
||||
screenshot: false,
|
||||
run: async (_page, response) => {
|
||||
expect(response?.status()).toBe(200);
|
||||
expect(response?.headers()['content-type']).toContain('text/markdown');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'llms-full.txt',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'llms-full.txt',
|
||||
url: 'llms-full.txt',
|
||||
screenshot: false,
|
||||
run: async (_page, response) => {
|
||||
expect(response?.status()).toBe(200);
|
||||
expect(response?.headers()['content-type']).toContain('text/markdown');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '[page].md',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
||||
tests: [
|
||||
{
|
||||
name: 'blocks.md',
|
||||
url: 'blocks.md',
|
||||
screenshot: false,
|
||||
run: async (_page, response) => {
|
||||
expect(response?.status()).toBe(200);
|
||||
expect(response?.headers()['content-type']).toContain('text/markdown');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site subdirectory (proxy)',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
|
||||
@@ -355,6 +355,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
||||
url: 'https://www.gitbook.com/privacy',
|
||||
},
|
||||
socialPreview: {},
|
||||
socialAccounts: [],
|
||||
};
|
||||
|
||||
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
"usehooks-ts": "catalog:",
|
||||
"warn-once": "^0.1.1",
|
||||
"zod": "^3",
|
||||
"zustand": "^5.0.3"
|
||||
"zustand": "^5.0.3",
|
||||
"negotiator": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@argos-ci/playwright": "^5.0.9",
|
||||
@@ -94,6 +95,7 @@
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/rison": "^0.0.9",
|
||||
"@types/negotiator": "^0.6.4",
|
||||
"bun-types": "catalog:",
|
||||
"deepmerge": "^4.3.1",
|
||||
"env-cmd": "^10.1.0",
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ export default async function SiteDynamicLayout({
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout
|
||||
className="site-background"
|
||||
htmlClassName="sheet-open:gutter-stable"
|
||||
bodyClassName="site-background"
|
||||
forcedTheme={forcedTheme}
|
||||
context={context}
|
||||
>
|
||||
|
||||
+5
-1
@@ -19,7 +19,11 @@ export default async function SiteStaticLayout({
|
||||
const withTracking = shouldTrackEvents();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout className="site-background" context={context}>
|
||||
<CustomizationRootLayout
|
||||
htmlClassName="sheet-open:gutter-stable"
|
||||
bodyClassName="site-background"
|
||||
context={context}
|
||||
>
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useTrackEvent } from '../Insights';
|
||||
import { useNow } from '../hooks';
|
||||
import { Button } from '../primitives';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
import { AIChatControlButton } from './AIChatControlButton';
|
||||
import { AIChatIcon } from './AIChatIcon';
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
@@ -69,15 +70,22 @@ export function AIChat() {
|
||||
}, [chat.opened, trackEvent]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<SideSheet
|
||||
side="right"
|
||||
open={chat.opened}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
chatController.open();
|
||||
} else {
|
||||
chatController.close();
|
||||
}
|
||||
}}
|
||||
withOverlay={true}
|
||||
className={tcls(
|
||||
'ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl scroll-mt-36 px-4 py-4 transition-[width,opacity,margin,display] transition-discrete duration-300 sm:px-6 lg:fixed lg:w-80 lg:p-0 xl:w-96',
|
||||
chat.opened
|
||||
? 'lg:starting:ml-0 lg:starting:w-0 lg:starting:opacity-0'
|
||||
: 'hidden lg:ml-0 lg:w-0! lg:opacity-0'
|
||||
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
|
||||
)}
|
||||
>
|
||||
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base transition-all duration-300 max-lg:circular-corners:rounded-3xl max-lg:rounded-corners:rounded-md max-lg:border lg:w-80 xl:w-96">
|
||||
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
|
||||
<EmbeddableFrameMain data-testid="ai-chat">
|
||||
<EmbeddableFrameHeader>
|
||||
<AIChatDynamicIcon trademark={config.trademark} />
|
||||
@@ -107,7 +115,7 @@ export function AIChat() {
|
||||
</EmbeddableFrameBody>
|
||||
</EmbeddableFrameMain>
|
||||
</EmbeddableFrame>
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,8 +226,8 @@ export function AIChatBody(props: {
|
||||
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
|
||||
contentClassName="p-4 gutter-stable flex flex-col gap-4"
|
||||
orientation="vertical"
|
||||
fadeEdges={['leading']}
|
||||
active={`message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
|
||||
trailing={{ fade: false, button: true }}
|
||||
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex grow flex-col">
|
||||
|
||||
@@ -4,7 +4,6 @@ import { MotionConfig } from 'motion/react';
|
||||
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||
import { useVisitorSession } from '../Insights';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { DateRelative } from '../primitives';
|
||||
import { HideToolbarButton } from './HideToolbarButton';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshContentButton } from './RefreshContentButton';
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
type ToolbarControlsContextValue,
|
||||
ToolbarControlsProvider,
|
||||
} from './ToolbarControlsContext';
|
||||
import { ToolbarDate } from './ToolbarDate';
|
||||
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
|
||||
import { useToolbarVisibility } from './utils';
|
||||
|
||||
@@ -132,7 +132,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle
|
||||
prefix={`Change #${changeRequest.number}:`}
|
||||
@@ -141,7 +141,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
<DateRelative value={changeRequest.updatedAt} /> by {author}
|
||||
<ToolbarDate value={changeRequest.updatedAt} /> by {author}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -207,13 +207,13 @@ function RevisionToolbar(props: ToolbarViewProps) {
|
||||
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Created <DateRelative value={revision.createdAt} />
|
||||
Created <ToolbarDate value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -280,20 +280,10 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
minified={minified}
|
||||
onMinifiedChange={onMinifiedChange}
|
||||
label="Only visible to your GitBook organization"
|
||||
>
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Updated <DateRelative value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarActions>
|
||||
@@ -305,13 +295,13 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
|
||||
{/* Open site in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Open site in GitBook"
|
||||
title="View site configuration"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'site',
|
||||
})}
|
||||
icon="gears"
|
||||
icon="folder-gear"
|
||||
/>
|
||||
|
||||
{/* Customize in GitBook */}
|
||||
@@ -361,13 +351,13 @@ function EditPageButton(props: {
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Edit in GitBook"
|
||||
title="Edit this page"
|
||||
href={getToolbarHref({
|
||||
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
||||
siteId,
|
||||
buttonId: 'edit',
|
||||
})}
|
||||
icon="pencil"
|
||||
icon="pen-to-square"
|
||||
motionValues={motionValues}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,10 +8,20 @@ import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
|
||||
import styles from './Toolbar.module.css';
|
||||
import { useToolbarControls } from './ToolbarControlsContext';
|
||||
|
||||
const ARC_DURATION_SECONDS = 0.4;
|
||||
const ARC_STAGGER_MS = 80;
|
||||
const BASE_ROTATION_DEG = 95;
|
||||
const ROTATION_STEP_DEG = 18;
|
||||
const ARC_PARAMS = {
|
||||
arcWidth: 505,
|
||||
arcHeight: 400,
|
||||
arcRadius: 34,
|
||||
startDistance: -240,
|
||||
spreadDistance: 45,
|
||||
fromDistance: -286,
|
||||
fromSpread: 0,
|
||||
durationSeconds: 0.6,
|
||||
staggerMs: 80,
|
||||
baseRotationDeg: 95,
|
||||
rotationStepDeg: 18,
|
||||
offsetAnchorY: 40,
|
||||
} as const;
|
||||
|
||||
interface HideToolbarButtonProps {
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
@@ -23,30 +33,75 @@ interface HideToolbarButtonProps {
|
||||
export function HideToolbarButton(props: HideToolbarButtonProps) {
|
||||
const { motionValues } = props;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [closing, setClosing] = React.useState(false);
|
||||
const [shouldMirror, setShouldMirror] = React.useState(false);
|
||||
const [shouldFlipVertical, setShouldFlipVertical] = React.useState(false);
|
||||
const controls = useToolbarControls();
|
||||
const closingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
if (!open || closing) return;
|
||||
setClosing(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (closingTimeoutRef.current) {
|
||||
clearTimeout(closingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Wait for the exit animation to complete before unmounting
|
||||
const totalDuration = ARC_PARAMS.durationSeconds * 1000 + 3 * ARC_PARAMS.staggerMs;
|
||||
closingTimeoutRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
setClosing(false);
|
||||
closingTimeoutRef.current = null;
|
||||
}, totalDuration);
|
||||
}, [open, closing]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (closingTimeoutRef.current) {
|
||||
clearTimeout(closingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClickOutsideArcMenu = (event: Event) => {
|
||||
// Don't close the arc if we are clicking on the button itself
|
||||
if (buttonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
close();
|
||||
};
|
||||
// @ts-expect-error wrong type for ref
|
||||
useOnClickOutside(ref, handleClickOutsideArcMenu);
|
||||
|
||||
// Close arc menu on scroll
|
||||
// Measure button position to decide arc direction when opening
|
||||
React.useEffect(() => {
|
||||
if (open && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const distanceFromRight = window.innerWidth - rect.right;
|
||||
setShouldMirror(distanceFromRight < 250);
|
||||
setShouldFlipVertical(rect.top < 250);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close arc menu on scroll or resize
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleScroll = () => setOpen(false);
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
const handleClose = () => close();
|
||||
window.addEventListener('scroll', handleClose, { passive: true });
|
||||
window.addEventListener('resize', handleClose, { passive: true });
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [open]);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleClose);
|
||||
window.removeEventListener('resize', handleClose);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
const items = [
|
||||
controls?.minimize
|
||||
@@ -84,36 +139,80 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
|
||||
return (
|
||||
<ToolbarButton
|
||||
ref={buttonRef}
|
||||
title={open ? 'Hide options' : 'Hide toolbar'}
|
||||
title={open ? undefined : 'Hide toolbar'}
|
||||
className={
|
||||
open || closing
|
||||
? 'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
if (open || closing) {
|
||||
close();
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
motionValues={motionValues}
|
||||
icon="eye-slash"
|
||||
icon="gear"
|
||||
>
|
||||
{/* Expanding arc menu */}
|
||||
{open && (
|
||||
{(open || closing) && (
|
||||
<motion.div
|
||||
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
|
||||
style={sharedMotionStyle as React.CSSProperties | undefined}
|
||||
style={
|
||||
{
|
||||
...sharedMotionStyle,
|
||||
'--arc-width': `${ARC_PARAMS.arcWidth}px`,
|
||||
'--arc-height': `${ARC_PARAMS.arcHeight}px`,
|
||||
'--arc-radius': `${ARC_PARAMS.arcRadius}%`,
|
||||
'--start-distance': `${ARC_PARAMS.startDistance}px`,
|
||||
'--spread-distance': `${ARC_PARAMS.spreadDistance}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'pointer-events-none absolute left-0 overflow-visible',
|
||||
'pointer-events-none absolute overflow-visible',
|
||||
shouldMirror ? '' : 'left-0',
|
||||
styles.arcMenuPath
|
||||
)}
|
||||
style={
|
||||
{
|
||||
...(shouldMirror
|
||||
? {
|
||||
'--arc-mirror-offset': '-345px',
|
||||
'--spread-distance': '37px',
|
||||
right: 'var(--arc-mirror-offset)',
|
||||
}
|
||||
: undefined),
|
||||
...(shouldFlipVertical
|
||||
? {
|
||||
bottom: 'auto',
|
||||
top: 'calc(var(--arc-height) / -2)',
|
||||
transform: 'scaleY(-1)',
|
||||
}
|
||||
: undefined),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
ref={ref}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<ArcToolbarButton
|
||||
index={index}
|
||||
staggerIndex={items.length - 1 - index}
|
||||
key={item.icon}
|
||||
staggerIndex={closing ? index : items.length - 1 - index}
|
||||
key={item.id}
|
||||
mirrored={shouldMirror}
|
||||
flippedVertical={shouldFlipVertical}
|
||||
closing={closing}
|
||||
{...item}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onClick?.();
|
||||
}}
|
||||
onClick={
|
||||
item.isLabel
|
||||
? undefined
|
||||
: () => {
|
||||
close();
|
||||
item.onClick?.();
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -127,13 +226,17 @@ type ArcMenuItem = {
|
||||
id: string;
|
||||
icon: IconName;
|
||||
label: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
onClick?: () => void;
|
||||
isLabel?: boolean;
|
||||
};
|
||||
|
||||
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
|
||||
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick' | 'isLabel'> & {
|
||||
index: number;
|
||||
staggerIndex?: number;
|
||||
mirrored?: boolean;
|
||||
flippedVertical?: boolean;
|
||||
closing?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
@@ -143,40 +246,41 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
|
||||
const {
|
||||
index,
|
||||
staggerIndex = index,
|
||||
mirrored = false,
|
||||
flippedVertical = false,
|
||||
closing = false,
|
||||
label,
|
||||
disabled,
|
||||
className,
|
||||
onClick = () => {},
|
||||
onClick,
|
||||
icon,
|
||||
iconClassName,
|
||||
isLabel = false,
|
||||
} = props;
|
||||
|
||||
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
|
||||
const fromOffset = `calc(${ARC_PARAMS.fromDistance}px + ${index} * ${ARC_PARAMS.fromSpread}px)`;
|
||||
|
||||
// Calculate rotation based on position along the arc
|
||||
const calculateRotation = () => {
|
||||
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
|
||||
};
|
||||
const itemRotation = ARC_PARAMS.baseRotationDeg - index * ARC_PARAMS.rotationStepDeg;
|
||||
|
||||
const itemRotation = calculateRotation();
|
||||
const Tag = isLabel ? 'div' : 'button';
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
<Tag
|
||||
{...(Tag === 'button' ? { type: 'button' as const } : {})}
|
||||
onClick={onClick ? () => onClick() : undefined}
|
||||
style={
|
||||
{
|
||||
'--from-offset-distance': fromOffset,
|
||||
'--target-offset-distance': targetOffset,
|
||||
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
|
||||
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
|
||||
'--arc-duration': `${ARC_PARAMS.durationSeconds}s`,
|
||||
'--arc-delay': `${(staggerIndex ?? 0) * ARC_PARAMS.staggerMs}ms`,
|
||||
'--rotation-offset': `${itemRotation}deg`,
|
||||
offsetPath: 'border-box',
|
||||
offsetDistance: targetOffset,
|
||||
offsetAnchor: '0% 40%',
|
||||
offsetRotate: `auto ${itemRotation}deg`,
|
||||
offsetDistance: fromOffset,
|
||||
offsetAnchor: `0% ${ARC_PARAMS.offsetAnchorY}%`,
|
||||
offsetRotate: 'auto 90deg',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={tcls(
|
||||
@@ -185,49 +289,61 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
|
||||
'top-0',
|
||||
'left-0',
|
||||
'w-40',
|
||||
'opacity-0',
|
||||
'pointer-events-auto',
|
||||
'flex',
|
||||
'items-center',
|
||||
'gap-2',
|
||||
styles.arcMenuItem,
|
||||
mirrored ? 'flex-row-reverse' : '',
|
||||
closing ? styles.arcMenuItemExit : styles.arcMenuItem,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex shrink-0 items-center justify-center gap-1',
|
||||
'h-8 w-8 rounded-full border',
|
||||
'truncate text-sm',
|
||||
'cursor-pointer transition-colors',
|
||||
'group-hover:-rotate-5 group-hover:scale-105',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'text-tint-1 dark:text-tint-12',
|
||||
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
|
||||
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
|
||||
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
|
||||
)}
|
||||
style={{
|
||||
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
style={flippedVertical ? { transform: 'scaleY(-1)' } : undefined}
|
||||
>
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
|
||||
/>
|
||||
{isLabel ? (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-[rgba(79,139,255,0.1)] px-2 py-1 text-[#4F8BFF] backdrop-blur-sm">
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="size-3.5 shrink-0"
|
||||
/>
|
||||
<span className="whitespace-nowrap font-normal text-sm">{label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'group-hover:scale-105',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'bg-[var(--toolbar-bg)] text-tint-7 hover:text-tint-1 dark:text-tint-12',
|
||||
'group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'size-3.5 shrink-0 group-hover:scale-110',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={tcls(
|
||||
'whitespace-nowrap rounded-lg border-[0.5px] border-neutral-5 border-solid bg-[var(--toolbar-bg)] px-3 py-1 font-normal text-neutral-1 text-sm transition-[background-color,transform] group-hover:scale-105 group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)] dark:border-neutral-8 dark:text-neutral-12',
|
||||
closing ? styles.arcLabelFadeOut : styles.arcLabelFadeIn
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={tcls(
|
||||
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
|
||||
'group-hover:rotate-2 group-hover:scale-105',
|
||||
'text-neutral-1 dark:text-neutral-12',
|
||||
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</Tag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
.arcMenuItem {
|
||||
animation-name: hide-toolbar-arc-enter;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation-fill-mode: forwards;
|
||||
animation-fill-mode: both;
|
||||
animation-duration: var(--arc-duration, 0.4s);
|
||||
animation-delay: var(--arc-delay, 0s);
|
||||
transform-origin: center left;
|
||||
@@ -28,13 +28,101 @@
|
||||
|
||||
@keyframes hide-toolbar-arc-enter {
|
||||
from {
|
||||
offset-distance: var(--start-distance);
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
offset-distance: var(--from-offset-distance);
|
||||
offset-rotate: auto 90deg;
|
||||
}
|
||||
to {
|
||||
offset-distance: var(--target-offset-distance);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
offset-rotate: auto var(--rotation-offset);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hide-toolbar-arc-exit {
|
||||
from {
|
||||
offset-distance: var(--target-offset-distance);
|
||||
offset-rotate: auto var(--rotation-offset);
|
||||
}
|
||||
to {
|
||||
offset-distance: var(--from-offset-distance);
|
||||
offset-rotate: auto 90deg;
|
||||
}
|
||||
}
|
||||
|
||||
.arcMenuItemExit {
|
||||
animation-name: hide-toolbar-arc-exit;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation-fill-mode: both;
|
||||
animation-duration: var(--arc-duration, 0.3s);
|
||||
animation-delay: var(--arc-delay, 0s);
|
||||
transform-origin: center left;
|
||||
offset-path: border-box;
|
||||
offset-anchor: 0% 0%;
|
||||
offset-rotate: auto var(--rotation-offset, 0deg);
|
||||
}
|
||||
|
||||
/* ── Arc label fade ── */
|
||||
|
||||
@keyframes arc-label-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes arc-label-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.arcLabelFadeIn {
|
||||
animation: arc-label-fade-in 0.3s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.arcLabelFadeOut {
|
||||
animation: arc-label-fade-out 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ── Glass effect for the toolbar pill ── */
|
||||
|
||||
.glassLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.glassLayer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
pointer-events: none;
|
||||
-webkit-backdrop-filter: blur(9px);
|
||||
backdrop-filter: blur(9px);
|
||||
filter: url(#glass-distortion);
|
||||
}
|
||||
|
||||
.glassLayer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: rgba(39, 39, 39, 0.55);
|
||||
box-shadow:
|
||||
inset 2px 2px 1px rgba(255, 255, 255, 0.08),
|
||||
inset -1px -1px 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:global(.dark) .glassLayer::after {
|
||||
background: rgba(30, 30, 30, 0.6);
|
||||
box-shadow:
|
||||
inset 2px 2px 1px rgba(255, 255, 255, 0.05),
|
||||
inset -1px -1px 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glassLayer::before {
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,39 +2,71 @@
|
||||
import {
|
||||
AnimatePresence,
|
||||
type MotionValue,
|
||||
animate,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
} from 'motion/react';
|
||||
import React, { isValidElement } from 'react';
|
||||
import { AnimatedLogo } from './AnimatedLogo';
|
||||
import { useToolbarControls } from './ToolbarControlsContext';
|
||||
import {
|
||||
getStoredPosition,
|
||||
getVisibilityHintDismissed,
|
||||
setStoredPosition,
|
||||
setVisibilityHintDismissed,
|
||||
} from './utils';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import { Tooltip } from '../primitives';
|
||||
// import styles from './Toolbar.module.css';
|
||||
import { getCopyVariants, toolbarEasings } from './transitions';
|
||||
import { useMagnificationEffect } from './useMagnificationEffect';
|
||||
|
||||
const DEBUG = true;
|
||||
|
||||
const DURATION_LOGO_APPEARANCE = 2000;
|
||||
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
|
||||
|
||||
const ToolbarDraggingContext = React.createContext(false);
|
||||
|
||||
interface ToolbarProps {
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
minified: boolean;
|
||||
onMinifiedChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function Toolbar(props: ToolbarProps) {
|
||||
const { children, label, minified, onMinifiedChange } = props;
|
||||
const { children, minified, onMinifiedChange } = props;
|
||||
const controls = useToolbarControls();
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
const autoExpandTriggeredRef = React.useRef(false);
|
||||
const constraintsRef = React.useRef<HTMLDivElement>(null);
|
||||
const innerRef = React.useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const prevWidthRef = React.useRef<number | undefined>(undefined);
|
||||
const [hintDismissed, setHintDismissed] = React.useState(() =>
|
||||
typeof window !== 'undefined' ? getVisibilityHintDismissed() : false
|
||||
);
|
||||
|
||||
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
|
||||
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
|
||||
|
||||
// Restore saved drag position (synchronous read — no flash)
|
||||
const storedPos = React.useMemo(
|
||||
() => (typeof window !== 'undefined' ? getStoredPosition() : null),
|
||||
[]
|
||||
);
|
||||
const x = useMotionValue(storedPos?.x ?? 0);
|
||||
const y = useMotionValue(storedPos?.y ?? 0);
|
||||
|
||||
const savePosition = React.useCallback(() => {
|
||||
setStoredPosition({ x: x.get(), y: y.get() });
|
||||
}, [x, y]);
|
||||
|
||||
// Wait for page to be ready, then show the toolbar
|
||||
React.useEffect(() => {
|
||||
const handleLoad = () => {
|
||||
@@ -80,58 +112,432 @@ export function Toolbar(props: ToolbarProps) {
|
||||
React.useEffect(() => {
|
||||
if (!minified) {
|
||||
// Any manual expansion should stop the logo animation so the icon stays in its
|
||||
// “settled” state once the toolbar is open.
|
||||
// "settled" state once the toolbar is open.
|
||||
setShouldAnimateLogo(false);
|
||||
}
|
||||
}, [minified]);
|
||||
|
||||
// Detect pinning state based on actual viewport position
|
||||
const debugRef = React.useRef<HTMLDivElement>(null);
|
||||
const debugLeftEdgeRef = React.useRef<HTMLDivElement>(null);
|
||||
const debugRightEdgeRef = React.useRef<HTMLDivElement>(null);
|
||||
const debugArrowRef = React.useRef<HTMLDivElement>(null);
|
||||
const lastDecisionRef = React.useRef<'left' | 'right' | 'center'>('center');
|
||||
|
||||
const computePinState = React.useCallback(
|
||||
(overrideWidth?: number) => {
|
||||
if (!innerRef.current) return 'center' as const;
|
||||
const rect = innerRef.current.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const edgeThreshold = 80; // px from viewport edge
|
||||
|
||||
// When overrideWidth is provided (e.g. prevWidth), reconstruct where the
|
||||
// edges WERE before the resize. The center stays the same because x hasn't
|
||||
// been compensated yet and flexbox centering is width-independent.
|
||||
let left = rect.left;
|
||||
let right = rect.right;
|
||||
if (overrideWidth !== undefined) {
|
||||
const center = (rect.left + rect.right) / 2;
|
||||
left = center - overrideWidth / 2;
|
||||
right = center + overrideWidth / 2;
|
||||
}
|
||||
|
||||
const pin =
|
||||
right > viewportWidth - edgeThreshold
|
||||
? ('right' as const)
|
||||
: left < edgeThreshold
|
||||
? ('left' as const)
|
||||
: ('center' as const);
|
||||
|
||||
// Update debug overlays directly via DOM to avoid React re-render lag
|
||||
if (DEBUG) {
|
||||
// Text overlay
|
||||
if (debugRef.current) {
|
||||
const el = debugRef.current;
|
||||
el.textContent = '';
|
||||
const decisionColor = lastDecisionRef.current === 'center' ? '#facc15' : '#4ade80';
|
||||
const liveColor = pin === 'center' ? '#facc15' : '#4ade80';
|
||||
const preInfo =
|
||||
overrideWidth !== undefined
|
||||
? ` | pre: L${Math.round(left)} R${Math.round(right)}`
|
||||
: '';
|
||||
el.innerHTML = `decision: <span style="color:${decisionColor}">${lastDecisionRef.current}</span> | live: <span style="color:${liveColor}">${pin}</span> | x: ${Math.round(x.get())} y: ${Math.round(y.get())} | rect: L${Math.round(rect.left)} R${Math.round(rect.right)} (vw: ${viewportWidth})${preInfo}`;
|
||||
}
|
||||
|
||||
// Edge anchor indicators on the toolbar pill (track live state)
|
||||
if (debugLeftEdgeRef.current) {
|
||||
debugLeftEdgeRef.current.style.backgroundColor =
|
||||
pin === 'left' ? '#4ade80' : '#ffffff20';
|
||||
debugLeftEdgeRef.current.style.boxShadow =
|
||||
pin === 'left' ? '0 0 8px #4ade80' : 'none';
|
||||
}
|
||||
if (debugRightEdgeRef.current) {
|
||||
debugRightEdgeRef.current.style.backgroundColor =
|
||||
pin === 'right' ? '#4ade80' : '#ffffff20';
|
||||
debugRightEdgeRef.current.style.boxShadow =
|
||||
pin === 'right' ? '0 0 8px #4ade80' : 'none';
|
||||
}
|
||||
|
||||
// Arrow showing expansion direction (tracks live state so it
|
||||
// updates as you drag, showing what WOULD happen on click)
|
||||
if (debugArrowRef.current) {
|
||||
debugArrowRef.current.textContent =
|
||||
pin === 'left'
|
||||
? '→'
|
||||
: pin === 'right'
|
||||
? '←'
|
||||
: '↔';
|
||||
debugArrowRef.current.style.color =
|
||||
pin === 'center' ? '#facc15' : '#4ade80';
|
||||
}
|
||||
}
|
||||
|
||||
return pin;
|
||||
},
|
||||
[x, y]
|
||||
);
|
||||
|
||||
// Update debug display reactively when x/y change (direct DOM updates, no re-renders)
|
||||
React.useEffect(() => {
|
||||
if (!DEBUG) return;
|
||||
const unsubX = x.on('change', () => computePinState());
|
||||
const unsubY = y.on('change', () => computePinState());
|
||||
// Initial computation
|
||||
computePinState();
|
||||
return () => {
|
||||
unsubX();
|
||||
unsubY();
|
||||
};
|
||||
}, [x, y, computePinState]);
|
||||
|
||||
// Compensate drag x position when the toolbar width changes (expand/collapse) so the
|
||||
// pinned edge stays anchored. Uses viewport-aware edge detection instead of a fixed
|
||||
// x-threshold so it works correctly at any viewport width.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!innerRef.current) return;
|
||||
|
||||
const newWidth = innerRef.current.offsetWidth;
|
||||
const prevWidth = prevWidthRef.current;
|
||||
|
||||
if (prevWidth !== undefined && prevWidth !== newWidth) {
|
||||
const delta = newWidth - prevWidth;
|
||||
const currentX = x.get();
|
||||
const currentY = y.get();
|
||||
const expanding = newWidth > prevWidth;
|
||||
|
||||
// Grab the raw post-resize rect for logging
|
||||
const rawRect = innerRef.current.getBoundingClientRect();
|
||||
const center = (rawRect.left + rawRect.right) / 2;
|
||||
const preResizeLeft = center - prevWidth / 2;
|
||||
const preResizeRight = center + prevWidth / 2;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const edgeThreshold = 80;
|
||||
|
||||
// Use prevWidth to reconstruct where the edges WERE before the resize.
|
||||
const pin = computePinState(prevWidth);
|
||||
lastDecisionRef.current = pin;
|
||||
// Refresh debug overlay so "decision" label updates immediately
|
||||
computePinState(prevWidth);
|
||||
|
||||
let compensation = 0;
|
||||
if (pin === 'right') {
|
||||
compensation = -delta / 2;
|
||||
} else if (pin === 'left') {
|
||||
compensation = delta / 2;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console -- temporary debug logging
|
||||
console.warn(
|
||||
`[Toolbar Pin] ${expanding ? 'EXPAND' : 'COLLAPSE'}\n` +
|
||||
` Action: ${expanding ? 'minified → expanded' : 'expanded → minified'}\n` +
|
||||
` Width: ${prevWidth}px → ${newWidth}px (delta: ${delta}px)\n` +
|
||||
` Motion: x=${Math.round(currentX)}, y=${Math.round(currentY)}\n` +
|
||||
` Viewport: ${viewportWidth}px, edgeThreshold: ${edgeThreshold}px\n` +
|
||||
` Post-resize rect (raw): L=${Math.round(rawRect.left)} R=${Math.round(rawRect.right)}\n` +
|
||||
` Pre-resize rect (reconstructed): L=${Math.round(preResizeLeft)} R=${Math.round(preResizeRight)}\n` +
|
||||
` Center: ${Math.round(center)}\n` +
|
||||
` Pin check: left=${Math.round(preResizeLeft)} < ${edgeThreshold}? ${preResizeLeft < edgeThreshold} | right=${Math.round(preResizeRight)} > ${viewportWidth - edgeThreshold}? ${preResizeRight > viewportWidth - edgeThreshold}\n` +
|
||||
` → Decision: ${pin}\n` +
|
||||
` → Compensation: ${compensation}px (x: ${Math.round(currentX)} → ${Math.round(currentX + compensation)})`
|
||||
);
|
||||
|
||||
if (compensation !== 0) {
|
||||
animate(x, currentX + compensation, {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 30,
|
||||
mass: 1,
|
||||
}).then(savePosition);
|
||||
}
|
||||
}
|
||||
|
||||
prevWidthRef.current = newWidth;
|
||||
}, [minified, x, savePosition, computePinState]);
|
||||
|
||||
// Don't render anything until page is ready
|
||||
if (!isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
if (minified) {
|
||||
setShouldAnimateLogo(false);
|
||||
onMinifiedChange(false);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'backdrop-blur-sm',
|
||||
'origin-center',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
|
||||
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
|
||||
)}
|
||||
style={{
|
||||
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||
}}
|
||||
>
|
||||
{/* Logo with stroke segments animation in blue-tints */}
|
||||
<motion.div layout>
|
||||
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
|
||||
<ToolbarDraggingContext.Provider value={isDragging}>
|
||||
{/* Hidden SVG filter for glass distortion */}
|
||||
<svg aria-hidden="true" className="pointer-events-none fixed size-0">
|
||||
<defs>
|
||||
<filter id="glass-distortion">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.016 0.012"
|
||||
numOctaves={3}
|
||||
seed={39}
|
||||
result="noise"
|
||||
/>
|
||||
<feGaussianBlur in="noise" stdDeviation={6} result="softNoise" />
|
||||
<feDisplacementMap
|
||||
in="SourceGraphic"
|
||||
in2="softNoise"
|
||||
scale={160}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<div
|
||||
ref={constraintsRef}
|
||||
className="pointer-events-none fixed inset-2 z-40 flex items-end justify-center"
|
||||
>
|
||||
<motion.div
|
||||
drag
|
||||
dragConstraints={constraintsRef}
|
||||
dragElastic={0.04}
|
||||
dragTransition={{
|
||||
power: 0.2,
|
||||
timeConstant: 200,
|
||||
bounceStiffness: 800,
|
||||
bounceDamping: 60,
|
||||
}}
|
||||
style={{ x, y }}
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setIsDragging(false);
|
||||
requestAnimationFrame(() => {
|
||||
isDraggingRef.current = false;
|
||||
});
|
||||
}}
|
||||
onDragTransitionEnd={savePosition}
|
||||
className="pointer-events-auto relative w-auto max-w-xl cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
{/* Visibility peek label — rendered as a sibling before the pill so it paints behind it */}
|
||||
{!hintDismissed && (
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
y: !minified && !isDragging ? 0 : 10,
|
||||
opacity: !minified && !isDragging ? 1 : 0,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
}}
|
||||
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3"
|
||||
style={{ paddingBlock: '2px' }}
|
||||
>
|
||||
<span className="whitespace-nowrap text-[11px] text-neutral-9">
|
||||
Only visible to your GitBook organization
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto cursor-pointer rounded border border-tint-5 bg-tint-2 px-1 py-px text-[10px] text-tint-12 transition-colors hover:scale-102 hover:bg-tint-3 dark:border-tint-11/50 dark:bg-white dark:text-tint-1 dark:hover:bg-tint-11/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setVisibilityHintDismissed();
|
||||
setHintDismissed(true);
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!minified ? children : null}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
ref={innerRef}
|
||||
onClick={() => {
|
||||
if (isDraggingRef.current) return;
|
||||
if (minified) {
|
||||
setShouldAnimateLogo(false);
|
||||
onMinifiedChange(false);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
|
||||
'relative',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'origin-center',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'bg-[var(--toolbar-bg)]'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--toolbar-bg': '#1f1d1b',
|
||||
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||
zIndex: 1, // Ensure pill stacks above the peek label sibling
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Glass effect layer (disabled for now) */}
|
||||
{/* <div className={styles.glassLayer} /> */}
|
||||
|
||||
{/* Debug: anchor edge indicators */}
|
||||
{DEBUG && (
|
||||
<>
|
||||
<div
|
||||
ref={debugLeftEdgeRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -1,
|
||||
top: '15%',
|
||||
bottom: '15%',
|
||||
width: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ffffff20',
|
||||
transition: 'background-color 0.2s, box-shadow 0.2s',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={debugRightEdgeRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -1,
|
||||
top: '15%',
|
||||
bottom: '15%',
|
||||
width: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ffffff20',
|
||||
transition: 'background-color 0.2s, box-shadow 0.2s',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={debugArrowRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Logo — double-click to minimize without opening the arc menu */}
|
||||
<motion.div
|
||||
layout
|
||||
onDoubleClick={(e) => {
|
||||
if (isDraggingRef.current || minified) return;
|
||||
e.stopPropagation();
|
||||
onMinifiedChange(true);
|
||||
}}
|
||||
>
|
||||
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
|
||||
</motion.div>
|
||||
|
||||
{!minified ? children : null}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Debug overlay for pinning state — updated via DOM ref to avoid re-render lag */}
|
||||
{DEBUG && (
|
||||
<>
|
||||
<div
|
||||
ref={debugRef}
|
||||
className="pointer-events-none whitespace-nowrap rounded bg-black/80 px-2 py-1 font-mono text-[10px] text-white"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
{/* Viewport edge threshold zones */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 80,
|
||||
height: '100vh',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
borderRight: '1px dashed rgba(74, 222, 128, 0.3)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 39,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 80,
|
||||
right: 4,
|
||||
fontSize: 9,
|
||||
color: 'rgba(74, 222, 128, 0.5)',
|
||||
writingMode: 'vertical-rl',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
pin-left zone (80px)
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 80,
|
||||
height: '100vh',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
borderLeft: '1px dashed rgba(74, 222, 128, 0.3)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 39,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 80,
|
||||
left: 4,
|
||||
fontSize: 9,
|
||||
color: 'rgba(74, 222, 128, 0.5)',
|
||||
writingMode: 'vertical-rl',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
pin-right zone (80px)
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ToolbarDraggingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,63 +603,53 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
|
||||
children,
|
||||
} = props;
|
||||
const reduceMotion = useReducedMotion();
|
||||
const isDragging = React.useContext(ToolbarDraggingContext);
|
||||
|
||||
const anchor = (
|
||||
<motion.a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: motionValues?.scale,
|
||||
x: motionValues?.x,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||
...style,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={tcls(
|
||||
'toolbar-button',
|
||||
className,
|
||||
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
|
||||
'text-tint-7 hover:text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'bg-[var(--toolbar-bg)]',
|
||||
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
|
||||
/>
|
||||
</motion.a>
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
|
||||
{children ? children : null}
|
||||
<Tooltip label={title}>
|
||||
<motion.a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: motionValues?.scale,
|
||||
x: motionValues?.x,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||
...style,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={tcls(
|
||||
'toolbar-button',
|
||||
className,
|
||||
'flex',
|
||||
'relative',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
'cursor-pointer',
|
||||
'transition-colors',
|
||||
'size-8',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
|
||||
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</motion.a>
|
||||
</Tooltip>
|
||||
{title && !isDragging ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
@@ -339,7 +735,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
|
||||
className="inline-flex items-center gap-1 text-neutral-1/80 text-xxs dark:text-neutral-12/80"
|
||||
>
|
||||
{props.subtitle}
|
||||
</motion.span>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
type DateFormat = 'relative' | 'weekday' | 'full';
|
||||
|
||||
const DATE_FORMATS: DateFormat[] = ['relative', 'weekday', 'full'];
|
||||
|
||||
/**
|
||||
* Toolbar-specific date display that cycles between relative, weekday, and full formats on click.
|
||||
*/
|
||||
export function ToolbarDate(props: { value: string }) {
|
||||
const { value } = props;
|
||||
const language = useLanguage();
|
||||
const [now, setNow] = React.useState<number>(Date.now());
|
||||
const [formatIndex, setFormatIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
setNow(Date.now());
|
||||
},
|
||||
30 * 60 * 1000
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const date = new Date(value);
|
||||
const format = DATE_FORMATS[formatIndex] as DateFormat;
|
||||
|
||||
const formatted = React.useMemo(() => {
|
||||
return formatDateValue(format, language.locale, now, date);
|
||||
}, [format, language.locale, now, date]);
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
{/* Vertical dots indicating cycleable formats */}
|
||||
<div className="flex flex-col items-center gap-[1px]">
|
||||
{DATE_FORMATS.map((fmt, i) => (
|
||||
<motion.span
|
||||
key={fmt}
|
||||
animate={{
|
||||
opacity: i === formatIndex ? 1 : 0.3,
|
||||
scale: i === formatIndex ? 1 : 0.75,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||
className="block size-[3px] rounded-full bg-current"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<time
|
||||
data-visual-test="transparent"
|
||||
suppressHydrationWarning={true}
|
||||
dateTime={value}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex min-w-24 cursor-pointer overflow-hidden font-semibold transition-colors hover:text-white"
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={formatIndex}
|
||||
initial={{ y: '-100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||
>
|
||||
{formatted}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateValue(format: DateFormat, locale: string, now: number, date: Date): string {
|
||||
switch (format) {
|
||||
case 'relative':
|
||||
return formatRelative(locale, now - date.getTime());
|
||||
case 'weekday':
|
||||
return date.toLocaleDateString(locale, {
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
case 'full':
|
||||
return date.toLocaleString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(locale: string, diff: number) {
|
||||
if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat === 'undefined') {
|
||||
const days = Math.floor(diff / 24 / 60 / 60 / 1000);
|
||||
return `${days} days ago`;
|
||||
}
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { style: 'long' });
|
||||
|
||||
if (diff < 60 * 60 * 1000) {
|
||||
const minutes = Math.floor(diff / 60 / 1000);
|
||||
return rtf.format(-minutes, 'minute');
|
||||
}
|
||||
|
||||
if (diff < 24 * 60 * 60 * 1000) {
|
||||
const hours = Math.floor(diff / 60 / 60 / 1000);
|
||||
return rtf.format(-hours, 'hour');
|
||||
}
|
||||
|
||||
if (diff < 30 * 24 * 60 * 60 * 1000) {
|
||||
const days = Math.floor(diff / 24 / 60 / 60 / 1000);
|
||||
return rtf.format(-days, 'day');
|
||||
}
|
||||
|
||||
if (diff < 365 * 24 * 60 * 60 * 1000) {
|
||||
const months = Math.floor(diff / 30 / 24 / 60 / 60 / 1000);
|
||||
return rtf.format(-months, 'month');
|
||||
}
|
||||
|
||||
const years = Math.floor(diff / 365 / 24 / 60 / 60 / 1000);
|
||||
return rtf.format(-years, 'year');
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
const STORAGE_KEY = 'gitbook_toolbar_closed';
|
||||
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
|
||||
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
|
||||
const SESSION_POSITION_KEY = 'gitbook_toolbar_position';
|
||||
const VISIBILITY_HINT_DISMISSED_KEY = 'gitbook_toolbar_hint_dismissed';
|
||||
|
||||
type SessionHideReason = 'session' | 'persistent';
|
||||
|
||||
@@ -57,6 +59,35 @@ export const setStoredMinified = (value: boolean) => {
|
||||
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve the last drag position from session storage. Returns `null` when no position has been
|
||||
* stored, meaning the toolbar should use its default center-bottom placement.
|
||||
*/
|
||||
export const getStoredPosition = (): { x: number; y: number } | null => {
|
||||
return getSessionStorageItem<{ x: number; y: number } | null>(SESSION_POSITION_KEY, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the current drag position for the ongoing session.
|
||||
*/
|
||||
export const setStoredPosition = (position: { x: number; y: number }) => {
|
||||
setSessionStorageItem(SESSION_POSITION_KEY, position);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether the user has dismissed the "only you can see this" hint.
|
||||
*/
|
||||
export const getVisibilityHintDismissed = (): boolean => {
|
||||
return getLocalStorageItem(VISIBILITY_HINT_DISMISSED_KEY, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist that the user dismissed the visibility hint.
|
||||
*/
|
||||
export const setVisibilityHintDismissed = () => {
|
||||
setLocalStorageItem(VISIBILITY_HINT_DISMISSED_KEY, true);
|
||||
};
|
||||
|
||||
interface UseToolbarVisibilityOptions {
|
||||
onPersistentClose?: () => void;
|
||||
onSessionClose?: () => void;
|
||||
|
||||
@@ -41,7 +41,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
aria-describedby={describedById}
|
||||
className={tcls(
|
||||
'fixed',
|
||||
'z-10',
|
||||
'z-50',
|
||||
'bg-tint-base',
|
||||
'rounded-sm',
|
||||
'straight-corners:rounded-none',
|
||||
@@ -52,9 +52,9 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
||||
'depth-flat:shadow-none',
|
||||
'p-4',
|
||||
'pr-8',
|
||||
'bottom-4',
|
||||
'right-4',
|
||||
'left-16',
|
||||
'bottom-[max(env(safe-area-inset-bottom),1rem)]',
|
||||
'right-[max(env(safe-area-inset-right),1rem)]',
|
||||
'left-[max(env(safe-area-inset-left),4rem)]',
|
||||
'max-w-md',
|
||||
'text-balance',
|
||||
'sm:left-auto',
|
||||
|
||||
@@ -43,7 +43,7 @@ export function InlineActionButton(
|
||||
inline
|
||||
label={buttonProps.label as string}
|
||||
sizing="medium"
|
||||
className="inline-flex max-w-full leading-normal [transition-property:translate,opacity,box-shadow,background,border]"
|
||||
className="inline-flex max-w-full grow"
|
||||
submitButton={{
|
||||
label: tString(language, action === 'ask' ? 'send' : 'search'),
|
||||
}}
|
||||
|
||||
@@ -14,7 +14,6 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
variant: inline.data.kind,
|
||||
icon: inline.data.icon as IconName | undefined,
|
||||
size: 'medium',
|
||||
className: 'leading-normal',
|
||||
};
|
||||
|
||||
const ButtonImplementation = () => {
|
||||
@@ -37,9 +36,7 @@ export function InlineButton(props: InlineProps<api.DocumentInlineButton>) {
|
||||
|
||||
const inlineElement = (
|
||||
// Set the leading to have some vertical space between adjacent buttons
|
||||
<span className="inline-button leading-12 [&:has(+.inline-button)]:mr-2">
|
||||
<ButtonImplementation />
|
||||
</span>
|
||||
<ButtonImplementation />
|
||||
);
|
||||
|
||||
return inlineElement;
|
||||
|
||||
@@ -9,8 +9,12 @@ import { getTextAlignment } from './utils';
|
||||
export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
|
||||
const { block, style, ...contextProps } = props;
|
||||
|
||||
// InlineActionButtons use flex-grow to take the available width. This requires the parent to be a flex container.
|
||||
const inlineButtonStyle =
|
||||
'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center';
|
||||
|
||||
return (
|
||||
<p className={tcls(style, getTextAlignment(block.data?.align))}>
|
||||
<p className={tcls(inlineButtonStyle, style, getTextAlignment(block.data?.align))}>
|
||||
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -18,17 +18,9 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
|
||||
const { block, ancestorBlocks, document } = props;
|
||||
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
|
||||
|
||||
const records: TableRecordKV[] = Object.entries(block.data.records).sort(
|
||||
([aKey, aValue], [bKey, bValue]) => {
|
||||
const orderComparison = aValue.orderIndex.localeCompare(bValue.orderIndex);
|
||||
|
||||
if (orderComparison !== 0) {
|
||||
return orderComparison;
|
||||
}
|
||||
|
||||
return aKey.localeCompare(bKey);
|
||||
}
|
||||
);
|
||||
const records: TableRecordKV[] = Object.entries(block.data.records).sort((a, b) => {
|
||||
return a[1].orderIndex.localeCompare(b[1].orderIndex);
|
||||
});
|
||||
|
||||
switch (block.data.view.type) {
|
||||
case 'cards':
|
||||
|
||||
@@ -9,11 +9,11 @@ export function getTextAlignment(textAlignment: TextAlignment | undefined): Clas
|
||||
switch (textAlignment) {
|
||||
case undefined:
|
||||
case TextAlignment.Start:
|
||||
return ['text-start', 'self-start'];
|
||||
return ['text-start', 'self-start', 'justify-start'];
|
||||
case TextAlignment.Center:
|
||||
return ['text-center', 'self-center'];
|
||||
return ['text-center', 'self-center', 'justify-center'];
|
||||
case TextAlignment.End:
|
||||
return ['text-end', 'self-end'];
|
||||
return ['text-end', 'self-end', 'justify-end'];
|
||||
default:
|
||||
return nullIfNever(textAlignment);
|
||||
}
|
||||
|
||||
@@ -79,9 +79,10 @@ export async function EmbeddableDocsPage(
|
||||
orientation="vertical"
|
||||
className="not-hydrated:animate-blur-in-slow"
|
||||
contentClassName="p-4"
|
||||
fadeEdges={context.sections ? [] : ['leading']}
|
||||
leading={{ fade: !context.sections, button: true }}
|
||||
trailing={{ fade: false, button: true }}
|
||||
>
|
||||
<TableOfContents className="pt-0" context={context} />
|
||||
<TableOfContents context={context} withTrademark={false} />
|
||||
<PageBody
|
||||
context={context}
|
||||
page={page}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { GitBookEmbeddableConfiguration, ParentToFrameMessage } from '@gitbook/embed';
|
||||
import { createChannel } from 'bidc';
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { useAI, useAIChatController } from '@/components/AI';
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
@@ -19,6 +19,12 @@ const embeddableConfiguration = createStore<GitBookEmbeddableConfiguration>(() =
|
||||
tools: [],
|
||||
}));
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: expected
|
||||
function log(...data: any[]) {
|
||||
// biome-ignore lint/suspicious/noConsole: expected
|
||||
console.log(...data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the API to communicate with the parent window.
|
||||
*/
|
||||
@@ -30,24 +36,32 @@ export function EmbeddableIframeAPI(props: {
|
||||
const router = useRouter();
|
||||
const chatController = useAIChatController();
|
||||
|
||||
// Live ref to avoid adding them as dependencies
|
||||
const refs = useRef({ router, chatController, baseURL });
|
||||
useEffect(() => {
|
||||
refs.current = { router, chatController, baseURL };
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
return chatController.on('open', () => {
|
||||
const { baseURL, router } = refs.current;
|
||||
router.push(`${baseURL}/assistant`);
|
||||
});
|
||||
}, [router, baseURL, chatController]);
|
||||
}, [chatController]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (window.parent === window) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[gitbook] create channel with parent window');
|
||||
log('[gitbook] create channel with parent window');
|
||||
const channel = createChannel();
|
||||
|
||||
channel.receive((payload) => {
|
||||
const { baseURL, router, chatController } = refs.current;
|
||||
const message = payload as ParentToFrameMessage;
|
||||
|
||||
console.log('[gitbook] received message', message);
|
||||
log('[gitbook] received message', message);
|
||||
|
||||
switch (message.type) {
|
||||
case 'clearChat': {
|
||||
@@ -79,10 +93,10 @@ export function EmbeddableIframeAPI(props: {
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log('[gitbook] cleanup');
|
||||
log('[gitbook] cleanup');
|
||||
channel.cleanup();
|
||||
};
|
||||
}, [chatController, router, baseURL]);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
|
||||
import { SpaceLayoutServerContext } from '../SpaceLayout';
|
||||
import { TrademarkLink } from '../TableOfContents/Trademark';
|
||||
import { Trademark } from '../TableOfContents/Trademark';
|
||||
import { NavigationLoader } from '../primitives/NavigationLoader';
|
||||
import { EmbeddableIframeAPI } from './EmbeddableIframeAPI';
|
||||
|
||||
@@ -57,8 +57,8 @@ export async function EmbeddableRootLayout({
|
||||
<div className="fixed inset-0 flex flex-col">
|
||||
{children}
|
||||
{context.customization.trademark.enabled ? (
|
||||
<TrademarkLink
|
||||
className="border-tint-solid/3 border-t bg-tint-solid/1 px-4 py-2.5 text-tint/8 ring-0"
|
||||
<Trademark
|
||||
className="rounded-none! border-x-0 border-t border-b-0 bg-tint-solid/1 depth-flat:bg-tint-solid/1 px-4 py-2.5 text-tint/8"
|
||||
context={context}
|
||||
placement={SiteInsightsTrademarkPlacement.Embed}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,7 @@ export function Header(props: {
|
||||
`h-[${HEADER_HEIGHT_DESKTOP}px]`,
|
||||
'sticky',
|
||||
'top-0',
|
||||
'pt-[env(safe-area-inset-top)]',
|
||||
'z-30',
|
||||
'w-full',
|
||||
'flex-none',
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
'use client';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
|
||||
import { useScrollListener } from '../hooks/useScrollListener';
|
||||
import { Button, type ButtonProps } from '../primitives';
|
||||
|
||||
const globalClassName = 'navigation-open';
|
||||
|
||||
const SCROLL_DISTANCE = 320;
|
||||
|
||||
/**
|
||||
* Button to show/hide the table of content on mobile.
|
||||
*/
|
||||
@@ -18,25 +15,6 @@ export function HeaderMobileMenu(props: ButtonProps) {
|
||||
const language = useLanguage();
|
||||
|
||||
const pathname = usePathname();
|
||||
const hasScrollRef = useRef(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const toggleNavigation = () => {
|
||||
if (!hasScrollRef.current && document.body.classList.contains(globalClassName)) {
|
||||
document.body.classList.remove(globalClassName);
|
||||
setIsOpen(false);
|
||||
} else {
|
||||
document.body.classList.add(globalClassName);
|
||||
window.scrollTo(0, 0);
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const windowRef = useRef(typeof window === 'undefined' ? null : window);
|
||||
useScrollListener(() => {
|
||||
hasScrollRef.current = window.scrollY >= SCROLL_DISTANCE;
|
||||
}, windowRef);
|
||||
|
||||
// Close the navigation when navigating to a page
|
||||
useEffect(() => {
|
||||
@@ -50,8 +28,10 @@ export function HeaderMobileMenu(props: ButtonProps) {
|
||||
iconOnly
|
||||
variant="blank"
|
||||
label={tString(language, 'table_of_contents_button_label')}
|
||||
onClick={toggleNavigation}
|
||||
active={isOpen}
|
||||
onClick={() => {
|
||||
document.body.classList.toggle(globalClassName);
|
||||
}}
|
||||
// Since the button is hidden behind the TOC after toggling, we don't need to keep track of its active state.
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { notFound } from 'next/navigation';
|
||||
import * as React from 'react';
|
||||
|
||||
import { DocumentView } from '@/components/DocumentView';
|
||||
import { TrademarkLink } from '@/components/TableOfContents/Trademark';
|
||||
import { Trademark } from '@/components/TableOfContents/Trademark';
|
||||
import type { PolymorphicComponentProp } from '@/components/utils/types';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { tString } from '@/intl/translate';
|
||||
@@ -148,7 +148,7 @@ export async function PDFPage(props: {
|
||||
total={total}
|
||||
trademark={
|
||||
customization.trademark.enabled ? (
|
||||
<TrademarkLink
|
||||
<Trademark
|
||||
context={context}
|
||||
placement={SiteInsightsTrademarkPlacement.Pdf}
|
||||
/>
|
||||
|
||||
@@ -65,6 +65,8 @@ export function PageBody(props: {
|
||||
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
|
||||
).length > 0;
|
||||
|
||||
const pageHasToc = page.layout.tableOfContents && hasVisibleTOCItems;
|
||||
|
||||
return (
|
||||
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
|
||||
<main
|
||||
@@ -76,12 +78,10 @@ export function PageBody(props: {
|
||||
'@container',
|
||||
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
|
||||
siteWidthWide ? 'site-width-wide' : 'site-width-default',
|
||||
page.layout.tableOfContents && hasVisibleTOCItems
|
||||
? 'page-has-toc'
|
||||
: 'page-no-toc'
|
||||
pageHasToc ? 'page-has-toc' : 'page-no-toc'
|
||||
)}
|
||||
>
|
||||
<PreservePageLayout siteWidthWide={siteWidthWide} />
|
||||
<PreservePageLayout siteWidthWide={siteWidthWide} pageHasToc={pageHasToc} />
|
||||
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||
) : null}
|
||||
|
||||
@@ -22,10 +22,6 @@ export async function PageHeader(props: {
|
||||
const { context, page, ancestors, withRSSFeed } = props;
|
||||
const { revision, linker } = context;
|
||||
|
||||
if (!page.layout.title && !page.layout.description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAncestors = ancestors.length > 0;
|
||||
|
||||
// Show page actions if *any* of the actions are enabled
|
||||
@@ -36,6 +32,21 @@ export async function PageHeader(props: {
|
||||
withRSSFeed,
|
||||
].some(Boolean);
|
||||
|
||||
/* When title and description are hidden, only display the page actions if there are any. */
|
||||
if (!page.layout.title && !page.layout.description) {
|
||||
if (!hasPageActions) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<PageActionsDropdown
|
||||
siteTitle={context.site.title}
|
||||
urls={getPageActionsURLs({ context, page, withRSSFeed })}
|
||||
actions={context.customization.pageActions}
|
||||
className="absolute top-8 right-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className={tcls(
|
||||
@@ -49,7 +60,7 @@ export async function PageHeader(props: {
|
||||
hasAncestors ? 'page-has-ancestors' : 'page-no-ancestors'
|
||||
)}
|
||||
>
|
||||
{page.layout.tableOfContents && hasPageActions ? (
|
||||
{hasPageActions ? (
|
||||
<PageActionsDropdown
|
||||
siteTitle={context.site.title}
|
||||
urls={getPageActionsURLs({ context, page, withRSSFeed })}
|
||||
|
||||
@@ -11,9 +11,10 @@ import * as React from 'react';
|
||||
* 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
|
||||
*
|
||||
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
|
||||
* It also preserves the page TOC state (page-has-toc/page-no-toc) to prevent logo sizing issues during navigation.
|
||||
*/
|
||||
export function PreservePageLayout(props: { siteWidthWide: boolean }) {
|
||||
const { siteWidthWide } = props;
|
||||
export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc: boolean }) {
|
||||
const { siteWidthWide, pageHasToc } = props;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// We use the header as it's an element preserved between page transitions
|
||||
@@ -28,7 +29,15 @@ export function PreservePageLayout(props: { siteWidthWide: boolean }) {
|
||||
} else {
|
||||
header.classList.remove('site-width-wide');
|
||||
}
|
||||
}, [siteWidthWide]);
|
||||
|
||||
if (pageHasToc) {
|
||||
header.classList.add('page-has-toc');
|
||||
header.classList.remove('page-no-toc');
|
||||
} else {
|
||||
header.classList.add('page-no-toc');
|
||||
header.classList.remove('page-has-toc');
|
||||
}
|
||||
}, [siteWidthWide, pageHasToc]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import * as ReactDOM from 'react-dom';
|
||||
|
||||
import { type FontData, getFontData } from '@/fonts';
|
||||
import { fontNotoColorEmoji, fonts } from '@/fonts/default';
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import { getSpaceLanguage, getSpaceLocale } from '@/intl/server';
|
||||
import { getAssetURL } from '@/lib/assets';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
@@ -56,16 +56,19 @@ function preloadFont(fontData: FontData) {
|
||||
* It takes care of setting the theme and the language.
|
||||
*/
|
||||
export async function CustomizationRootLayout(props: {
|
||||
/** The class name to apply to the html element. */
|
||||
htmlClassName?: string;
|
||||
/** The class name to apply to the body element. */
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
forcedTheme?: CustomizationThemeMode | null;
|
||||
context: GitBookAnyContext;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { className, context, forcedTheme, children } = props;
|
||||
const { htmlClassName, bodyClassName, context, forcedTheme, children } = props;
|
||||
const customization =
|
||||
'customization' in context ? context.customization : defaultCustomization();
|
||||
|
||||
const locale = getSpaceLocale(context);
|
||||
const language = getSpaceLanguage(context);
|
||||
const tintColor = getTintColor(customization);
|
||||
const mixColor = getTintMixColor(customization.styling.primaryColor, tintColor);
|
||||
@@ -88,7 +91,7 @@ export async function CustomizationRootLayout(props: {
|
||||
return (
|
||||
<html
|
||||
suppressHydrationWarning
|
||||
lang={customization.internationalization.locale}
|
||||
lang={locale}
|
||||
className={tcls(
|
||||
customization.styling.corners && `${customization.styling.corners}-corners`,
|
||||
'theme' in customization.styling && `theme-${customization.styling.theme}`,
|
||||
@@ -106,7 +109,8 @@ export async function CustomizationRootLayout(props: {
|
||||
// Set the dark/light class statically to avoid flashing and make it work when JS is disabled
|
||||
(forcedTheme ?? customization.themes.default) === CustomizationThemeMode.Dark
|
||||
? 'dark'
|
||||
: ''
|
||||
: '',
|
||||
htmlClassName
|
||||
)}
|
||||
>
|
||||
<head>
|
||||
@@ -178,7 +182,7 @@ export async function CustomizationRootLayout(props: {
|
||||
}
|
||||
`}</style>
|
||||
</head>
|
||||
<body className={className}>
|
||||
<body className={tcls(bodyClassName, 'sheet-open:overflow-hidden')}>
|
||||
<IconsProvider
|
||||
assetsURL={GITBOOK_ICONS_URL}
|
||||
assetsURLToken={GITBOOK_ICONS_TOKEN}
|
||||
|
||||
@@ -101,6 +101,7 @@ export async function generateSiteLayoutViewport(context: GitBookSiteContext): P
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: 'cover',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export function SiteSectionList(props: { sections: ClientSiteSections; className
|
||||
orientation="vertical"
|
||||
style={{ maxHeight: `${MAX_ITEMS * 3 + 2}rem` }}
|
||||
className="pb-4"
|
||||
active={currentSection.id}
|
||||
active={`#${currentSection.id}`}
|
||||
>
|
||||
<div className="flex w-full flex-col px-2">
|
||||
{sectionsAndGroups.map((item) => {
|
||||
|
||||
@@ -76,8 +76,12 @@ export function SiteSectionTabs(props: {
|
||||
? 'md:-mr-8 -mr-4 sm:-mr-6'
|
||||
: 'after:contents[] after:absolute after:inset-y-2 after:right-0 after:border-transparent after:border-r after:transition-colors'
|
||||
)}
|
||||
active={currentSection.id}
|
||||
trailingEdgeScrollClassName={children ? 'after:border-tint' : ''}
|
||||
active={`#${currentSection.id}`}
|
||||
trailing={{
|
||||
fade: true,
|
||||
button: true,
|
||||
className: children ? 'after:border-tint' : '',
|
||||
}}
|
||||
>
|
||||
<NavigationMenu.List
|
||||
className={tcls(
|
||||
|
||||
@@ -128,7 +128,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
'lg:justify-center',
|
||||
CONTAINER_STYLE,
|
||||
'site-width-wide:max-w-screen-4xl',
|
||||
'hydrated:transition-[max-width] duration-300',
|
||||
'transition-[max-width] duration-300',
|
||||
|
||||
// Ensure the footer is display below the viewport even if the content is not enough
|
||||
withFooter && [
|
||||
@@ -141,79 +141,82 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
<TableOfContents
|
||||
context={context}
|
||||
header={
|
||||
withTopHeader ? null : (
|
||||
<div
|
||||
className={tcls(
|
||||
'hidden',
|
||||
'pr-4',
|
||||
'mt-2',
|
||||
'lg:flex',
|
||||
'grow-0',
|
||||
'dark:shadow-light/1',
|
||||
'text-base/tight',
|
||||
'items-center'
|
||||
)}
|
||||
>
|
||||
<HeaderLogo context={context} />
|
||||
{variants.translations.length > 1 ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={
|
||||
variants.translations.find(
|
||||
(space) => space.id === siteSpace.id
|
||||
) ?? siteSpace
|
||||
}
|
||||
siteSpaces={variants.translations}
|
||||
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
<div
|
||||
className={tcls(
|
||||
'pr-4',
|
||||
'flex',
|
||||
withTopHeader ? 'lg:hidden' : '',
|
||||
'grow-0',
|
||||
'dark:shadow-light/1',
|
||||
'text-base/tight',
|
||||
'items-center'
|
||||
)}
|
||||
>
|
||||
<HeaderLogo context={context} />
|
||||
{variants.translations.length > 1 ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={
|
||||
variants.translations.find(
|
||||
(space) => space.id === siteSpace.id
|
||||
) ?? siteSpace
|
||||
}
|
||||
siteSpaces={variants.translations}
|
||||
className="[&_.button-leading-icon]:block! ml-auto py-2 [&_.button-content]:hidden"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
// Displays the search button and/or the space dropdown in the ToC
|
||||
// according to the header/variant settings.
|
||||
// E.g if there is no header, the search button will be displayed in the ToC.
|
||||
innerHeader={
|
||||
<>
|
||||
{!withTopHeader && (
|
||||
<div className="flex gap-2">
|
||||
<SearchContainer
|
||||
style={CustomizationSearchStyle.Subtle}
|
||||
withVariants={variants.generic.length > 1}
|
||||
withSiteVariants={
|
||||
visibleSections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' &&
|
||||
s.siteSpaces.length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={withSections}
|
||||
section={visibleSections?.current}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={visibleSiteSpaces}
|
||||
className="max-lg:hidden"
|
||||
viewport="desktop"
|
||||
!withTopHeader || variants.generic.length > 1 ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'my-5 sidebar-default:mt-2 flex flex-col gap-2 px-5 empty:hidden',
|
||||
variants.generic.length > 1 ? '' : 'max-lg:hidden'
|
||||
)}
|
||||
>
|
||||
{!withTopHeader && (
|
||||
<div className="flex gap-2 max-lg:hidden">
|
||||
<SearchContainer
|
||||
style={CustomizationSearchStyle.Subtle}
|
||||
withVariants={variants.generic.length > 1}
|
||||
withSiteVariants={
|
||||
visibleSections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' &&
|
||||
s.siteSpaces.length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={withSections}
|
||||
section={visibleSections?.current}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={visibleSiteSpaces}
|
||||
viewport="desktop"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!withTopHeader && withSections && visibleSections && (
|
||||
<SiteSectionList
|
||||
className="hidden lg:block"
|
||||
sections={encodeClientSiteSections(
|
||||
context,
|
||||
visibleSections
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!withTopHeader && withSections && visibleSections && (
|
||||
<SiteSectionList
|
||||
className={tcls('hidden', 'lg:block')}
|
||||
sections={encodeClientSiteSections(
|
||||
context,
|
||||
visibleSections
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{variants.generic.length > 1 ? (
|
||||
<SpacesDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={variants.generic}
|
||||
className="w-full px-3 py-2"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{variants.generic.length > 1 ? (
|
||||
<SpacesDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={variants.generic}
|
||||
className="w-full px-3"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -32,7 +32,8 @@ export function PageDocumentItem(props: { page: ClientTOCPageDocument }) {
|
||||
'my-2',
|
||||
'border-tint-subtle',
|
||||
'sidebar-list-default:border-l',
|
||||
'sidebar-list-line:border-l'
|
||||
'sidebar-list-line:border-l',
|
||||
'break-anywhere'
|
||||
)}
|
||||
/>
|
||||
) : null
|
||||
|
||||
@@ -14,9 +14,9 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
|
||||
<li className="flex flex-col">
|
||||
<div
|
||||
className={tcls(
|
||||
'-top-6 sticky z-1 flex items-center gap-3 px-3 pt-6',
|
||||
'-top-4 sticky z-1 flex items-center gap-3 px-3',
|
||||
'font-semibold text-xs uppercase tracking-wide',
|
||||
'pb-3', // Add extra padding to make the header fade a bit nicer
|
||||
'mt-2 pt-4 pb-3', // Add extra padding to make the header fade a bit nicer
|
||||
'-mb-1.5', // Then pull the page items a bit closer, effective bottom padding is 1.5 units / 6px.
|
||||
'mask-[linear-gradient(rgba(0,0,0,1)_70%,rgba(0,0,0,0))]', // Fade out effect of fixed page items. We want the fade to start past the header, this is a good approximation.
|
||||
'bg-tint-base',
|
||||
@@ -25,9 +25,9 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-subtle',
|
||||
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
|
||||
'[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
|
||||
'[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
|
||||
isFirst ? '-mt-6' : ''
|
||||
'lg:[html.sidebar-default.theme-gradient_&]:bg-gradient-primary',
|
||||
'lg:[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
|
||||
isFirst ? '-mt-4' : ''
|
||||
)}
|
||||
>
|
||||
<TOCPageIcon page={page} />
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from 'react';
|
||||
import { assert } from 'ts-essentials';
|
||||
|
||||
interface TOCScrollContainerContextType {
|
||||
getContainer: (listener: (element: HTMLDivElement) => void) => () => void;
|
||||
}
|
||||
|
||||
const TOCScrollContainerContext = React.createContext<TOCScrollContainerContextType | null>(null);
|
||||
|
||||
function useTOCScrollContainerContext() {
|
||||
const ctx = React.useContext(TOCScrollContainerContext);
|
||||
assert(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table of contents scroll container.
|
||||
*/
|
||||
export function TOCScrollContainer(props: ComponentPropsWithoutRef<'div'>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const listeners = useRef<((element: HTMLDivElement) => void)[]>([]);
|
||||
const getContainer: TOCScrollContainerContextType['getContainer'] = useCallback((listener) => {
|
||||
if (ref.current) {
|
||||
listener(ref.current);
|
||||
return () => {};
|
||||
}
|
||||
listeners.current.push(listener);
|
||||
return () => {
|
||||
listeners.current = listeners.current.filter((l) => l !== listener);
|
||||
};
|
||||
}, []);
|
||||
const value: TOCScrollContainerContextType = useMemo(() => ({ getContainer }), [getContainer]);
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
listeners.current.forEach((listener) => listener(element));
|
||||
return () => {
|
||||
listeners.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TOCScrollContainerContext.Provider value={value}>
|
||||
<div ref={ref} data-testid="toc-scroll-container" {...props} />
|
||||
</TOCScrollContainerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Offset to scroll the table of contents item by.
|
||||
const TOC_ITEM_OFFSET = 100;
|
||||
|
||||
/**
|
||||
* Scrolls the table of contents container to the page item when it's initially active.
|
||||
*/
|
||||
export function useScrollToActiveTOCItem(props: {
|
||||
anchorRef: React.RefObject<HTMLAnchorElement | null>;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const { isActive, anchorRef } = props;
|
||||
const { getContainer } = useTOCScrollContainerContext();
|
||||
useEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
if (isActive && anchor) {
|
||||
return getContainer((container) => {
|
||||
if (isOutOfView(anchor, container)) {
|
||||
container.scrollTo({ top: anchor.offsetTop - TOC_ITEM_OFFSET });
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isActive, getContainer, anchorRef]);
|
||||
}
|
||||
|
||||
function isOutOfView(element: HTMLElement, container: HTMLElement) {
|
||||
const tocItemTop = element.offsetTop;
|
||||
const containerTop = container.scrollTop;
|
||||
const containerBottom = containerTop + container.clientHeight;
|
||||
return (
|
||||
tocItemTop < containerTop + TOC_ITEM_OFFSET ||
|
||||
tocItemTop > containerBottom - TOC_ITEM_OFFSET
|
||||
);
|
||||
}
|
||||
@@ -3,42 +3,59 @@ import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
|
||||
import type React from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||
import { SideSheet } from '../primitives/SideSheet';
|
||||
import { PagesList } from './PagesList';
|
||||
import { TOCScrollContainer } from './TOCScroller';
|
||||
import { TableOfContentsScript } from './TableOfContentsScript';
|
||||
import { Trademark } from './Trademark';
|
||||
import { encodeClientTableOfContents } from './encodeClientTableOfContents';
|
||||
|
||||
/**
|
||||
* Sidebar container, responsible for setting the right dimensions and position for the sidebar.
|
||||
*/
|
||||
export async function TableOfContents(props: {
|
||||
context: GitBookSiteContext;
|
||||
header?: React.ReactNode; // Displayed outside the scrollable TOC as a sticky header
|
||||
innerHeader?: React.ReactNode; // Displayed outside the scrollable TOC, directly above the page list
|
||||
withTrademark?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { innerHeader, context, header, className } = props;
|
||||
const { innerHeader, context, header, className, withTrademark = true } = props;
|
||||
const { customization, revision } = context;
|
||||
|
||||
const pages = await encodeClientTableOfContents(context, revision.pages, revision.pages);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside // Sidebar container, responsible for setting the right dimensions and position for the sidebar.
|
||||
<SideSheet
|
||||
side="left"
|
||||
data-testid="table-of-contents"
|
||||
id="table-of-contents"
|
||||
toggleClass="navigation-open"
|
||||
withOverlay={true}
|
||||
withCloseButton={true}
|
||||
className={tcls(
|
||||
'group',
|
||||
'group/table-of-contents',
|
||||
'text-sm',
|
||||
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
'basis-full',
|
||||
'lg:basis-72',
|
||||
|
||||
'w-4/5',
|
||||
'md:w-1/2',
|
||||
'lg:w-72',
|
||||
'basis-72',
|
||||
'lg:page-no-toc:basis-56',
|
||||
|
||||
'relative',
|
||||
'z-1',
|
||||
'max-lg:not-sidebar-filled:bg-tint-base',
|
||||
'max-lg:not-sidebar-filled:border-r',
|
||||
'border-tint-subtle',
|
||||
|
||||
'lg:flex!',
|
||||
'lg:animate-none!',
|
||||
'lg:sticky',
|
||||
'lg:mr-12',
|
||||
'lg:z-0!',
|
||||
|
||||
// Server-side static positioning
|
||||
'lg:top-0',
|
||||
@@ -60,30 +77,22 @@ export async function TableOfContents(props: {
|
||||
'lg:page-no-toc:[html[style*="--outline-top-offset"]_&]:top-(--outline-top-offset)!',
|
||||
'lg:page-no-toc:[html[style*="--outline-height"]_&]:top-(--outline-height)!',
|
||||
|
||||
'pt-4',
|
||||
'pb-4',
|
||||
'pt-6 pb-4',
|
||||
'supports-[-webkit-touch-callout]:pb-[env(safe-area-inset-bottom)]', // Override bottom padding on iOS since we have a transparent bottom bar
|
||||
'lg:sidebar-filled:pr-6',
|
||||
'lg:page-no-toc:pr-0',
|
||||
'max-lg:pl-8',
|
||||
|
||||
'hidden',
|
||||
'navigation-open:flex!',
|
||||
'lg:flex',
|
||||
'lg:page-no-toc:hidden',
|
||||
'xl:page-no-toc:flex',
|
||||
'lg:site-header-none:page-no-toc:flex',
|
||||
'flex-col',
|
||||
'gap-4',
|
||||
|
||||
'navigation-open:border-b',
|
||||
'border-tint-subtle',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{header && header}
|
||||
{header}
|
||||
<div // The actual sidebar, either shown with a filled bg or transparent.
|
||||
className={tcls(
|
||||
'lg:-ms-5',
|
||||
'relative flex grow flex-col overflow-hidden border-tint-subtle',
|
||||
'-ms-5',
|
||||
'relative flex min-h-0 grow flex-col border-tint-subtle',
|
||||
|
||||
'sidebar-filled:bg-tint-subtle',
|
||||
'theme-muted:bg-tint-subtle',
|
||||
@@ -91,40 +100,43 @@ export async function TableOfContents(props: {
|
||||
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
|
||||
'[html.sidebar-filled.theme-gradient_&]:border',
|
||||
'max-lg:sidebar-filled:border',
|
||||
'page-no-toc:bg-transparent!',
|
||||
'page-no-toc:border-none!',
|
||||
|
||||
'sidebar-filled:rounded-xl',
|
||||
'sidebar-filled:rounded-2xl',
|
||||
'straight-corners:rounded-none',
|
||||
'page-has-toc:[html.sidebar-filled.circular-corners_&]:rounded-3xl'
|
||||
'page-has-toc:[html.sidebar-filled.circular-corners_&]:rounded-4xl'
|
||||
)}
|
||||
>
|
||||
{innerHeader ? (
|
||||
<div className="my-4 flex flex-col space-y-4 px-5 empty:hidden">
|
||||
{innerHeader}
|
||||
</div>
|
||||
) : null}
|
||||
<TOCScrollContainer // The scrollview inside the sidebar
|
||||
className={tcls(
|
||||
'flex grow flex-col p-2 pt-4',
|
||||
customization.trademark.enabled && 'lg:pb-20',
|
||||
'hide-scrollbar overflow-y-auto'
|
||||
)}
|
||||
{innerHeader}
|
||||
<ScrollContainer
|
||||
data-testid="toc-scroll-container"
|
||||
orientation="vertical"
|
||||
contentClassName="flex flex-col p-2 gutter-stable"
|
||||
active="[data-active=true]"
|
||||
leading={{
|
||||
fade: true,
|
||||
button: {
|
||||
className: '-mt-4',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PagesList
|
||||
pages={pages}
|
||||
isRoot={true}
|
||||
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
|
||||
style="page-no-toc:hidden grow border-tint-subtle sidebar-list-line:border-l"
|
||||
/>
|
||||
{customization.trademark.enabled ? (
|
||||
<Trademark
|
||||
context={context}
|
||||
placement={SiteInsightsTrademarkPlacement.Sidebar}
|
||||
/>
|
||||
) : null}
|
||||
</TOCScrollContainer>
|
||||
</ScrollContainer>
|
||||
{withTrademark && customization.trademark.enabled ? (
|
||||
<Trademark
|
||||
context={context}
|
||||
placement={SiteInsightsTrademarkPlacement.Sidebar}
|
||||
className="m-2 mt-auto sidebar-default:mr-4"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</SideSheet>
|
||||
<TableOfContentsScript />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AnimatePresence, motion } from 'motion/react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { Button, Link, type LinkInsightsProps, type LinkProps, ToggleChevron } from '../primitives';
|
||||
import { useScrollToActiveTOCItem } from './TOCScroller';
|
||||
|
||||
/**
|
||||
* Client component for a page document to toggle its children and be marked as active.
|
||||
@@ -76,8 +75,6 @@ function LinkItem(
|
||||
}
|
||||
) {
|
||||
const { isActive, href, insights, children, onActiveClick } = props;
|
||||
const anchorRef = useRef<HTMLAnchorElement>(null);
|
||||
useScrollToActiveTOCItem({ anchorRef, isActive });
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (isActive && onActiveClick) {
|
||||
@@ -88,7 +85,7 @@ function LinkItem(
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={anchorRef}
|
||||
data-active={isActive}
|
||||
href={href}
|
||||
insights={insights}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { SiteInsightsTrademarkPlacement } from '@gitbook/api';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
|
||||
import { getSpaceLanguage, t } from '@/intl/server';
|
||||
import { getSpaceLanguage, tString } from '@/intl/server';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { GitBookSpaceContext } from '@/lib/context';
|
||||
import { Link } from '../primitives';
|
||||
import { Button } from '../primitives';
|
||||
|
||||
/**
|
||||
* Trademark link to the GitBook.
|
||||
@@ -14,69 +14,6 @@ export function Trademark(props: {
|
||||
context: GitBookSpaceContext;
|
||||
placement: SiteInsightsTrademarkPlacement;
|
||||
className?: string;
|
||||
}) {
|
||||
const { className, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'relative',
|
||||
'z-2',
|
||||
'lg:absolute',
|
||||
|
||||
'left-0',
|
||||
'right-2',
|
||||
'bottom-0',
|
||||
|
||||
'pointer-events-none',
|
||||
'sidebar-filled:pl-2',
|
||||
'sidebar-filled:pb-2',
|
||||
'sidebar-filled:page-no-toc:p-0',
|
||||
|
||||
'bg-tint-base',
|
||||
'sidebar-filled:bg-tint-subtle',
|
||||
'theme-muted:bg-tint-subtle',
|
||||
'[html.sidebar-filled.theme-muted_&]:bg-tint-base',
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:bg-tint-base',
|
||||
|
||||
'rounded-lg',
|
||||
'straight-corners:rounded-none',
|
||||
'circular-corners:rounded-2xl',
|
||||
|
||||
'before:hidden',
|
||||
'lg:before:block',
|
||||
'before:content-[""]',
|
||||
'before:absolute',
|
||||
'before:inset-x-0',
|
||||
'before:bottom-full',
|
||||
'before:h-8',
|
||||
'before:bg-linear-to-b',
|
||||
'before:from-transparent',
|
||||
'before:to-tint-base',
|
||||
'sidebar-filled:before:to-tint-subtle',
|
||||
'theme-muted:before:to-tint-subtle',
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-subtle',
|
||||
'[html.sidebar-filled.theme-muted_&]:before:to-tint-base',
|
||||
'[html.sidebar-filled.theme-bold.tint_&]:before:to-tint-base',
|
||||
'page-no-toc:before:to-transparent!',
|
||||
|
||||
className
|
||||
)}
|
||||
>
|
||||
<TrademarkLink
|
||||
className="circular-corners:rounded-2xl rounded-lg straight-corners:rounded-none"
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trademark link to the GitBook.
|
||||
*/
|
||||
export function TrademarkLink(props: {
|
||||
context: GitBookSpaceContext;
|
||||
placement: SiteInsightsTrademarkPlacement;
|
||||
className?: string;
|
||||
}) {
|
||||
const { context, placement, className } = props;
|
||||
const { space } = context;
|
||||
@@ -88,8 +25,10 @@ export function TrademarkLink(props: {
|
||||
url.searchParams.set('utm_campaign', space.id);
|
||||
|
||||
return (
|
||||
<Link
|
||||
<Button
|
||||
target="_blank"
|
||||
variant="secondary"
|
||||
size="large"
|
||||
href={url.toString()}
|
||||
className={tcls(
|
||||
'text-sm',
|
||||
@@ -101,30 +40,21 @@ export function TrademarkLink(props: {
|
||||
'items-center',
|
||||
'px-5',
|
||||
'py-4',
|
||||
'gap-3',
|
||||
'whitespace-normal',
|
||||
|
||||
'sidebar-filled:px-3',
|
||||
'lg:sidebar-filled:page-no-toc:px-5',
|
||||
|
||||
'hover:bg-tint',
|
||||
'hover:text-tint-strong',
|
||||
|
||||
'ring-2',
|
||||
'lg:ring-1',
|
||||
'ring-inset',
|
||||
'ring-tint-subtle',
|
||||
|
||||
'transition-colors',
|
||||
'pointer-events-auto',
|
||||
'bg-transparent',
|
||||
'depth-subtle:shadow-none',
|
||||
'border-tint-subtle',
|
||||
|
||||
className
|
||||
)}
|
||||
icon={<Icon icon="gitbook" className="size-5 shrink-0" />}
|
||||
label={tString(language, 'powered_by_gitbook')}
|
||||
insights={{
|
||||
type: 'trademark_click',
|
||||
placement,
|
||||
}}
|
||||
>
|
||||
<Icon icon="gitbook" className={tcls('size-5', 'shrink-0')} />
|
||||
<span className={tcls('ml-3')}>{t(language, 'powered_by_gitbook')}</span>
|
||||
</Link>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { TableOfContents } from './TableOfContents';
|
||||
export { PagesList } from './PagesList';
|
||||
export { TOCScrollContainer } from './TOCScroller';
|
||||
export { Trademark } from './Trademark';
|
||||
|
||||
@@ -9,9 +9,9 @@ export const HEADER_HEIGHT_DESKTOP = 64 as const;
|
||||
* Style for the container to adapt between normal and full width.
|
||||
*/
|
||||
export const CONTAINER_STYLE: ClassValue = [
|
||||
'px-4',
|
||||
'sm:px-6',
|
||||
'md:px-8',
|
||||
'px-4 pl-[max(env(safe-area-inset-left),1rem)] pr-[max(env(safe-area-inset-right),1rem)]',
|
||||
'sm:px-6 sm:pl-[max(env(safe-area-inset-left),1.5rem)] sm:pr-[max(env(safe-area-inset-right),1.5rem)]',
|
||||
'md:px-8 md:pl-[max(env(safe-area-inset-left),2rem)] md:pr-[max(env(safe-area-inset-right),2rem)]',
|
||||
'max-w-screen-2xl',
|
||||
'mx-auto',
|
||||
];
|
||||
|
||||
@@ -28,7 +28,7 @@ export function HoverCard(
|
||||
<RadixHoverCard.Portal>
|
||||
<RadixHoverCard.Content
|
||||
side={props.side ?? 'top'}
|
||||
className="pointer-events-none z-40 w-screen max-w-md animate-scale-in px-4 data-[state='closed']:animate-scale-out sm:w-auto"
|
||||
className="pointer-events-none z-50 w-screen max-w-md animate-scale-in px-4 data-[state='closed']:animate-scale-out sm:w-auto"
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import * as React from 'react';
|
||||
import { useScrollListener } from '../hooks/useScrollListener';
|
||||
import { Button } from './Button';
|
||||
import { Button, type ButtonProps } from './Button';
|
||||
|
||||
/**
|
||||
* A container that encapsulates a scrollable area with usability features.
|
||||
@@ -17,17 +17,26 @@ export type ScrollContainerProps = {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
|
||||
/** Optional class(es) to apply when there the container can be scrolled on the leading (left or top) edge */
|
||||
leadingEdgeScrollClassName?: string;
|
||||
|
||||
/** Optional class(es) to apply when there the container can be scrolled on the trailing (right or bottom) edge */
|
||||
trailingEdgeScrollClassName?: string;
|
||||
|
||||
/** The direction of the scroll container. */
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
|
||||
/** Whether to fade out the edges of the container. */
|
||||
fadeEdges?: ('leading' | 'trailing')[];
|
||||
leading?: {
|
||||
/** Whether to fade out the leading edge of the container. */
|
||||
fade: boolean;
|
||||
/** Whether to show a button to scroll back. */
|
||||
button: boolean | ButtonProps;
|
||||
/** Optional class(es) to apply when there the container can be scrolled on the leading (left or top) edge */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
trailing?: {
|
||||
/** Whether to fade out the trailing edge of the container. */
|
||||
fade: boolean;
|
||||
/** Whether to show a button to scroll forward. */
|
||||
button: boolean | ButtonProps;
|
||||
/** Optional class(es) to apply when there the container can be scrolled on the trailing (right or bottom) edge */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** The ID or ref of the active item to scroll to. */
|
||||
active?: string | React.RefObject<HTMLElement | null>;
|
||||
@@ -39,10 +48,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
className,
|
||||
contentClassName,
|
||||
orientation,
|
||||
fadeEdges = ['leading', 'trailing'],
|
||||
active,
|
||||
leadingEdgeScrollClassName,
|
||||
trailingEdgeScrollClassName,
|
||||
leading = { fade: true, button: true },
|
||||
trailing = { fade: true, button: true },
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@@ -102,7 +110,9 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
return;
|
||||
}
|
||||
const activeItem =
|
||||
typeof active === 'string' ? document.getElementById(active) : active.current;
|
||||
typeof active === 'string'
|
||||
? containerRef.current?.querySelector(active)
|
||||
: active.current;
|
||||
if (!activeItem || !container.contains(activeItem)) {
|
||||
return;
|
||||
}
|
||||
@@ -138,28 +148,30 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'group/scroll-container relative flex overflow-hidden',
|
||||
'group/scroll-container relative flex shrink grow',
|
||||
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
|
||||
className,
|
||||
scrollPosition > 0 ? leadingEdgeScrollClassName : '',
|
||||
scrollPosition < scrollSize ? trailingEdgeScrollClassName : ''
|
||||
scrollPosition > 0 ? leading?.className : '',
|
||||
scrollPosition < scrollSize ? trailing?.className : ''
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex shrink grow',
|
||||
'flex flex-1 overflow-hidden',
|
||||
orientation === 'horizontal' ? 'min-w-0' : 'min-h-0',
|
||||
orientation === 'horizontal' ? 'no-scrollbar' : 'hide-scrollbar',
|
||||
orientation === 'horizontal' ? 'overflow-x-scroll' : 'flex-col overflow-y-auto',
|
||||
fadeEdges.includes('leading') && scrollPosition > 0
|
||||
leading.fade && scrollPosition > 0
|
||||
? orientation === 'horizontal'
|
||||
? 'mask-l-from-[calc(100%-2rem)]'
|
||||
: 'mask-t-from-[calc(100%-2rem)]'
|
||||
? 'mask-l-from-[calc(100%-1rem)]'
|
||||
: 'mask-t-from-[calc(100%-1rem)]'
|
||||
: '',
|
||||
fadeEdges.includes('trailing') && scrollPosition < scrollSize
|
||||
trailing.fade && scrollPosition < scrollSize
|
||||
? orientation === 'horizontal'
|
||||
? 'mask-r-from-[calc(100%-2rem)]'
|
||||
: 'mask-b-from-[calc(100%-2rem)]'
|
||||
? 'mask-r-from-[calc(100%-1rem)]'
|
||||
: 'mask-b-from-[calc(100%-1rem)]'
|
||||
: '',
|
||||
contentClassName
|
||||
)}
|
||||
@@ -169,44 +181,52 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
</div>
|
||||
|
||||
{/* Scroll buttons back & forward */}
|
||||
<Button
|
||||
icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
tabIndex={-1}
|
||||
className={tcls(
|
||||
'bg-tint-base!',
|
||||
orientation === 'horizontal'
|
||||
? '-translate-y-1/2! top-1/2 left-0 ml-2'
|
||||
: '-translate-x-1/2! top-0 left-1/2 mt-2',
|
||||
'absolute not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]',
|
||||
scrollPosition > 0
|
||||
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
|
||||
: 'pointer-events-none'
|
||||
)}
|
||||
onClick={scrollBack}
|
||||
label={tString(language, 'scroll_back')}
|
||||
/>
|
||||
<Button
|
||||
icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
tabIndex={-1}
|
||||
className={tcls(
|
||||
'bg-tint-base!',
|
||||
orientation === 'horizontal'
|
||||
? '-translate-y-1/2! top-1/2 right-0 mr-2'
|
||||
: '-translate-x-1/2! bottom-0 left-1/2 mb-2',
|
||||
'absolute not-pointer-none:block hidden scale-0 transition-[scale,opacity]',
|
||||
scrollPosition < scrollSize
|
||||
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
|
||||
: 'pointer-events-none'
|
||||
)}
|
||||
onClick={scrollFurther}
|
||||
label={tString(language, 'scroll_further')}
|
||||
/>
|
||||
{leading.button !== false ? (
|
||||
<Button
|
||||
icon={orientation === 'horizontal' ? 'chevron-left' : 'chevron-up'}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
tabIndex={-1}
|
||||
onClick={scrollBack}
|
||||
label={tString(language, 'scroll_back')}
|
||||
{...(typeof leading.button === 'object' ? leading.button : {})}
|
||||
className={tcls(
|
||||
'bg-tint-base!',
|
||||
orientation === 'horizontal'
|
||||
? '-translate-y-1/2! top-1/2 left-0 ml-2'
|
||||
: '-translate-x-1/2! top-0 left-1/2 mt-2',
|
||||
'absolute z-10 not-pointer-none:block hidden scale-0 opacity-0 transition-[scale,opacity]',
|
||||
scrollPosition > 0
|
||||
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
|
||||
: 'pointer-events-none',
|
||||
typeof leading.button === 'object' ? leading.button.className : ''
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{trailing.button !== false ? (
|
||||
<Button
|
||||
icon={orientation === 'horizontal' ? 'chevron-right' : 'chevron-down'}
|
||||
iconOnly
|
||||
size="xsmall"
|
||||
variant="secondary"
|
||||
tabIndex={-1}
|
||||
onClick={scrollFurther}
|
||||
label={tString(language, 'scroll_further')}
|
||||
{...(typeof trailing.button === 'object' ? trailing.button : {})}
|
||||
className={tcls(
|
||||
'bg-tint-base!',
|
||||
orientation === 'horizontal'
|
||||
? '-translate-y-1/2! top-1/2 right-0 mr-2'
|
||||
: '-translate-x-1/2! bottom-0 left-1/2 mb-2',
|
||||
'absolute z-10 not-pointer-none:block hidden scale-0 transition-[scale,opacity]',
|
||||
scrollPosition < scrollSize
|
||||
? 'not-pointer-none:group-hover/scroll-container:scale-100 not-pointer-none:group-hover/scroll-container:opacity-11'
|
||||
: 'pointer-events-none',
|
||||
typeof trailing.button === 'object' ? trailing.button.className : ''
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -214,7 +234,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
|
||||
/**
|
||||
* Scroll to an element in a container.
|
||||
*/
|
||||
function scrollToElementInContainer(element: HTMLElement, container: HTMLElement) {
|
||||
function scrollToElementInContainer(element: Element, container: HTMLElement) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rect = element.getBoundingClientRect();
|
||||
|
||||
@@ -229,6 +249,8 @@ function scrollToElementInContainer(element: HTMLElement, container: HTMLElement
|
||||
(rect.left - containerRect.left) -
|
||||
container.clientWidth / 2 +
|
||||
rect.width / 2,
|
||||
behavior: 'smooth',
|
||||
// Use 'auto' to avoid additional scroll animations when scrolling to an element
|
||||
// as this may be called during layout/initialization when the page is not fully loaded.
|
||||
behavior: 'auto',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
import { type ClassValue, tcls } from '@/lib/tailwind';
|
||||
import React from 'react';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Button } from './Button';
|
||||
|
||||
const ANIMATION_DURATION = 300;
|
||||
|
||||
/**
|
||||
* SideSheet - A slide-in panel component that can appear from the left or right side.
|
||||
*
|
||||
* Supports both controlled and uncontrolled modes:
|
||||
* - Controlled: Provide both `open` and `onOpenChange` props. Parent manages state.
|
||||
* - Uncontrolled: Omit `open` prop. Component manages its own state internally.
|
||||
*/
|
||||
export function SideSheet(
|
||||
props: {
|
||||
/** Which side the sheet slides in from */
|
||||
side: 'left' | 'right';
|
||||
/**
|
||||
* Optional CSS class to monitor and sync with `document.body.classList`.
|
||||
* When set, a MutationObserver watches for the class and syncs the sheet state accordingly.
|
||||
* Adding this class opens the sheet, removing it closes it.
|
||||
* Works in both controlled and uncontrolled modes.
|
||||
*/
|
||||
toggleClass?: string;
|
||||
/**
|
||||
* Modal behavior: true (always modal), false (never modal), or 'mobile' (modal only on mobile).
|
||||
* Defaults to 'mobile'.
|
||||
*/
|
||||
modal?: true | false | 'mobile';
|
||||
/**
|
||||
* Controls visibility. If provided, component is controlled (parent manages state).
|
||||
* If undefined, component is uncontrolled (manages its own state).
|
||||
*/
|
||||
open?: boolean;
|
||||
/** Called when the open state changes. Receives the new state (true/false). Only used in controlled mode. */
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Show a backdrop overlay when modal */
|
||||
withOverlay?: boolean;
|
||||
/** Show a close button when modal */
|
||||
withCloseButton?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
) {
|
||||
const {
|
||||
side,
|
||||
children,
|
||||
className,
|
||||
toggleClass,
|
||||
open: openState,
|
||||
modal = 'mobile',
|
||||
withOverlay,
|
||||
withCloseButton,
|
||||
onOpenChange,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isModal = modal === 'mobile' ? isMobile : modal;
|
||||
const asideRef = React.useRef<HTMLElement>(null);
|
||||
|
||||
// Internal state for uncontrolled mode (only used when open prop is undefined)
|
||||
const [open, setOpen] = React.useState(openState ?? false);
|
||||
|
||||
// Determine actual open state: controlled (from prop) or uncontrolled (from internal state)
|
||||
const isOpen = openState !== undefined ? openState : open;
|
||||
|
||||
const wasOpenRef = React.useRef(false);
|
||||
const [shouldHide, setShouldHide] = React.useState(!isOpen);
|
||||
|
||||
// Track if component has been opened to prevent initial animation
|
||||
React.useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (isOpen) {
|
||||
wasOpenRef.current = true;
|
||||
setShouldHide(false);
|
||||
} else if (wasOpenRef.current) {
|
||||
timer = setTimeout(() => {
|
||||
setShouldHide(true);
|
||||
}, ANIMATION_DURATION);
|
||||
} else {
|
||||
setShouldHide(true);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
if (openState !== undefined) {
|
||||
// Controlled mode: parent manages state, notify via callback with new state
|
||||
onOpenChange?.(false);
|
||||
} else {
|
||||
// Uncontrolled mode: update internal state and sync body class if needed
|
||||
setOpen(false);
|
||||
if (toggleClass) {
|
||||
document.body.classList.remove(toggleClass);
|
||||
}
|
||||
}
|
||||
}, [openState, onOpenChange, toggleClass]);
|
||||
|
||||
// Sync the sheet state with the body class if the toggleClass is set
|
||||
React.useEffect(() => {
|
||||
if (!toggleClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callback = (mutationList: MutationRecord[]) => {
|
||||
for (const mutation of mutationList) {
|
||||
if (mutation.attributeName === 'class') {
|
||||
const shouldBeOpen = document.body.classList.contains(toggleClass);
|
||||
if (openState !== undefined) {
|
||||
// Controlled mode: sync with parent's state
|
||||
// Notify parent of state change via onOpenChange
|
||||
if (shouldBeOpen !== openState) {
|
||||
onOpenChange?.(shouldBeOpen);
|
||||
}
|
||||
} else {
|
||||
// Uncontrolled mode: sync internal state with body class
|
||||
setOpen(shouldBeOpen);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(callback);
|
||||
observer.observe(document.body, { attributes: true });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [toggleClass, openState, onOpenChange]);
|
||||
|
||||
// Handle Escape key press to close the modal sheet
|
||||
React.useEffect(() => {
|
||||
if (!isModal || !isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isModal, isOpen, handleClose]);
|
||||
|
||||
// Focus trapping: prevent Tab from leaving the modal
|
||||
React.useEffect(() => {
|
||||
if (!isModal || !isOpen || !asideRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
const aside = asideRef.current;
|
||||
if (!aside) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusable = aside.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const current = document.activeElement as HTMLElement;
|
||||
|
||||
if (!aside.contains(current)) {
|
||||
// Focus escaped, bring it back
|
||||
(event.shiftKey ? last : first)?.focus();
|
||||
event.preventDefault();
|
||||
} else if (event.shiftKey && current === first) {
|
||||
// Shift+Tab at first element, wrap to last
|
||||
last?.focus();
|
||||
event.preventDefault();
|
||||
} else if (!event.shiftKey && current === last) {
|
||||
// Tab at last element, wrap to first
|
||||
first?.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isModal, isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{withOverlay ? (
|
||||
<SideSheetOverlay
|
||||
className={tcls(isModal && isOpen ? '' : 'hidden opacity-0 backdrop-blur-none')}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<aside
|
||||
ref={asideRef}
|
||||
className={tcls(
|
||||
'side-sheet',
|
||||
'fixed inset-y-0 z-41', // Above the side sheet overlay on z-40
|
||||
side === 'left' ? 'left-0' : 'right-0',
|
||||
withCloseButton ? 'max-w-[calc(100%-4rem)]' : 'max-w-[calc(100%-3rem)]',
|
||||
shouldHide ? 'hidden' : '',
|
||||
isOpen
|
||||
? side === 'left'
|
||||
? 'hydrated:animate-enter-from-left'
|
||||
: 'hydrated:animate-enter-from-right'
|
||||
: '',
|
||||
!isOpen && wasOpenRef.current
|
||||
? side === 'left'
|
||||
? 'hydrated:animate-exit-to-left'
|
||||
: 'hydrated:animate-exit-to-right'
|
||||
: '',
|
||||
className
|
||||
)}
|
||||
aria-expanded={isOpen}
|
||||
aria-modal={isModal}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
{withCloseButton ? (
|
||||
<SideSheetCloseButton
|
||||
className={tcls(
|
||||
side === 'left' ? 'left-full ml-4' : 'right-full mr-4',
|
||||
isModal && isOpen ? 'animate-blur-in' : 'hidden animate-blur-out'
|
||||
)}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backdrop overlay shown behind the modal sheet */
|
||||
export function SideSheetOverlay(props: { className?: ClassValue; onClick?: () => void }) {
|
||||
const { className, onClick } = props;
|
||||
return (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: global escape key handler is used to close the modal sheet
|
||||
<div
|
||||
id="side-sheet-overlay"
|
||||
onClick={() => {
|
||||
onClick?.();
|
||||
}}
|
||||
className={tcls(
|
||||
'fixed inset-0 z-40 items-start bg-tint-base/3 not-hydrated:opacity-0 starting:opacity-0 backdrop-blur-md starting:backdrop-blur-none transition-[opacity,display,backdrop-filter] transition-discrete duration-250 dark:bg-tint-base/6',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Close button displayed outside the sheet when modal */
|
||||
export function SideSheetCloseButton(props: { className?: ClassValue; onClick?: () => void }) {
|
||||
const { className, onClick } = props;
|
||||
const language = useLanguage();
|
||||
return (
|
||||
<Button
|
||||
icon="xmark"
|
||||
variant="secondary"
|
||||
iconOnly
|
||||
label={tString(language, 'close')}
|
||||
className={tcls('absolute top-4 bg-tint-base! transition-discrete', className)}
|
||||
onClick={() => {
|
||||
onClick?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export function defaultCustomization(): api.SiteCustomizationSettings {
|
||||
url: 'https://www.gitbook.com/privacy',
|
||||
},
|
||||
socialPreview: {},
|
||||
socialAccounts: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CustomizationThemeMode } from '@gitbook/api';
|
||||
import Negotiator from 'negotiator';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import rison from 'rison';
|
||||
@@ -360,7 +361,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
|
||||
const siteURLWithoutProtocol = `${siteCanonicalURL.host}${siteURLData.basePath}`;
|
||||
const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(
|
||||
siteURLData.pathname
|
||||
siteURLData.pathname,
|
||||
request
|
||||
);
|
||||
routeType = routeTypeFromPathname ?? routeType;
|
||||
|
||||
@@ -569,7 +571,10 @@ const EMBED_PAGE_PATH_REGEX = /^~gitbook\/embed\/page(\/(\S*))?$/;
|
||||
* Encode path in a site content.
|
||||
* Special paths are not encoded and passed to be handled by the route handlers.
|
||||
*/
|
||||
function encodePathInSiteContent(rawPathname: string): {
|
||||
function encodePathInSiteContent(
|
||||
rawPathname: string,
|
||||
request: Request
|
||||
): {
|
||||
pathname: string;
|
||||
routeType?: 'static' | 'dynamic';
|
||||
} {
|
||||
@@ -588,16 +593,6 @@ function encodePathInSiteContent(rawPathname: string): {
|
||||
};
|
||||
}
|
||||
|
||||
// If the pathname is a markdown file, we rewrite it to ~gitbook/markdown/:pathname
|
||||
if (pathname.match(MARKDOWN_PATH_REGEX)) {
|
||||
const pagePathWithoutMD = pathname.slice(0, -3);
|
||||
return {
|
||||
pathname: `~gitbook/markdown/${encodePagePath(pagePathWithoutMD)}`,
|
||||
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
|
||||
routeType: 'static',
|
||||
};
|
||||
}
|
||||
|
||||
// We skip encoding for paginated llms-full.txt pages (i.e. llms-full.txt/100)
|
||||
if (pathname.match(LLMS_FULL_PATH_REGEX)) {
|
||||
return { pathname, routeType: 'static' };
|
||||
@@ -618,6 +613,7 @@ function encodePathInSiteContent(rawPathname: string): {
|
||||
return { pathname };
|
||||
case '~gitbook/mcp':
|
||||
case 'llms.txt':
|
||||
case 'llms-full.txt':
|
||||
case 'sitemap.xml':
|
||||
case 'sitemap-pages.xml':
|
||||
case 'robots.txt':
|
||||
@@ -629,8 +625,19 @@ function encodePathInSiteContent(rawPathname: string): {
|
||||
case '~gitbook/pdf':
|
||||
// PDF routes are always dynamic as they depend on the search params.
|
||||
return { pathname, routeType: 'dynamic' };
|
||||
default:
|
||||
default: {
|
||||
// If the pathname is a markdown file or the request is accepting markdown,
|
||||
// we rewrite it to ~gitbook/markdown/:pathname
|
||||
if (pathname.match(MARKDOWN_PATH_REGEX) || isMarkdownPreferred(request)) {
|
||||
const pagePathWithoutMD = pathname.replace(MARKDOWN_PATH_REGEX, '');
|
||||
return {
|
||||
pathname: `~gitbook/markdown/${encodePagePath(pagePathWithoutMD)}`,
|
||||
// The markdown content is always static and doesn't depend on the dynamic parameter (customization, theme, etc)
|
||||
routeType: 'static',
|
||||
};
|
||||
}
|
||||
return { pathname: encodePagePath(pathname) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,3 +673,16 @@ async function writeResponseCookies<R extends NextResponse>(
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const MARKDOWN_MEDIA_TYPES = ['text/markdown'];
|
||||
|
||||
/**
|
||||
* Test if a request is requesting a markdown version of the page.
|
||||
*/
|
||||
function isMarkdownPreferred(request: Request): boolean {
|
||||
const negotiator = new Negotiator({
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
});
|
||||
const mediaTypes = negotiator.mediaTypes();
|
||||
return MARKDOWN_MEDIA_TYPES.some((mediaType) => mediaTypes.includes(mediaType));
|
||||
}
|
||||
|
||||
@@ -327,10 +327,10 @@ const config: Config = {
|
||||
blurOut: 'blurOut 200ms ease-in both',
|
||||
'blurOut-slow': 'blurOut 500ms ease-in both',
|
||||
|
||||
enterFromLeft: 'enterFromLeft 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
enterFromRight: 'enterFromRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
exitToLeft: 'exitToLeft 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
exitToRight: 'exitToRight 250ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
enterFromLeft: 'enterFromLeft 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
enterFromRight: 'enterFromRight 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
exitToLeft: 'exitToLeft 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
exitToRight: 'exitToRight 300ms cubic-bezier(0.83, 0, 0.17, 1) both',
|
||||
|
||||
heightIn: 'heightIn 200ms ease both',
|
||||
crawl: 'crawl 2s ease-in-out infinite',
|
||||
@@ -473,18 +473,18 @@ const config: Config = {
|
||||
},
|
||||
enterFromRight: {
|
||||
from: { opacity: '0', transform: 'translateX(50%)', display: 'none' },
|
||||
to: { opacity: '1', transform: 'translateX(0)', display: 'block' },
|
||||
to: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
|
||||
},
|
||||
enterFromLeft: {
|
||||
from: { opacity: '0', transform: 'translateX(-50%)', display: 'none' },
|
||||
to: { opacity: '1', transform: 'translateX(0)', display: 'block' },
|
||||
to: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
|
||||
},
|
||||
exitToRight: {
|
||||
from: { opacity: '1', transform: 'translateX(0)', display: 'block' },
|
||||
from: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
|
||||
to: { opacity: '0', transform: 'translateX(50%)', display: 'none' },
|
||||
},
|
||||
exitToLeft: {
|
||||
from: { opacity: '1', transform: 'translateX(0)', display: 'block' },
|
||||
from: { opacity: '1', transform: 'translateX(0)', display: 'inherit' },
|
||||
to: { opacity: '0', transform: 'translateX(-50%)', display: 'none' },
|
||||
},
|
||||
scaleIn: {
|
||||
@@ -591,7 +591,11 @@ const config: Config = {
|
||||
* Variant when the Table of Content navigation is open.
|
||||
*/
|
||||
addVariant('navigation-open', 'body.navigation-open &');
|
||||
addVariant('chat-open', 'body:has(.ai-chat:not(.hidden)) &');
|
||||
addVariant('chat-open', 'body:has(.ai-chat[aria-expanded="true"]) &');
|
||||
addVariant(
|
||||
'sheet-open',
|
||||
'html:has(.side-sheet[aria-modal="true"][aria-expanded="true"]) &, &:has(.side-sheet[aria-modal="true"][aria-expanded="true"])'
|
||||
);
|
||||
|
||||
/**
|
||||
* Variant when a header is displayed.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { getContentTestURL } from './utils';
|
||||
|
||||
describe('llms.txt', () => {
|
||||
it('should expose a llms.txt file', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms.txt')
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(await response.text()).toContain('# E2E Tests GitBook Open');
|
||||
});
|
||||
|
||||
it('should expose a llms.txt file with the accept header', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms.txt'),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/markdown',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(await response.text()).toContain('# E2E Tests GitBook Open');
|
||||
});
|
||||
});
|
||||
|
||||
describe('llms-full.txt', () => {
|
||||
it('should expose a llms-full.txt file', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms-full.txt')
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(await response.text()).toContain('# Welcome');
|
||||
});
|
||||
|
||||
it('should expose a llms-full.txt file with the accept header', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/llms-full.txt'),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/markdown',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(await response.text()).toContain('# Welcome');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { expect, it } from 'bun:test';
|
||||
import { getContentTestURL } from './utils';
|
||||
|
||||
it('should expose a markdown page with the .md extension', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/text-page.md')
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(await response.text()).toContain('# Text page');
|
||||
});
|
||||
|
||||
it('should expose a markdown page with the accept header', async () => {
|
||||
const response = await fetch(
|
||||
getContentTestURL('https://gitbook.gitbook.io/test-gitbook-open/text-page'),
|
||||
{
|
||||
headers: {
|
||||
Accept: 'text/markdown',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toContain('text/markdown');
|
||||
expect(await response.text()).toContain('# Text page');
|
||||
});
|
||||
@@ -71,5 +71,7 @@ export function getAssetURL(location: Partial<IconsAssetsLocation>, path: string
|
||||
*/
|
||||
export function getIconAssetURL(context: IconsContextType, style: string, icon: string): string {
|
||||
const location = context.assetsByStyles?.[style] ?? context;
|
||||
return getAssetURL(location, `svgs/${style}/${icon}.svg`);
|
||||
// Ensure icon is always a string to prevent [object Object]
|
||||
const iconName = typeof icon === 'string' ? icon : String(icon);
|
||||
return getAssetURL(location, `svgs/${style}/${iconName}.svg`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { type OpenAPIContext, getOpenAPIClientContext } from './context';
|
||||
import { generateMediaTypeExamples, generateSchemaExample } from './generateSchemaExample';
|
||||
import { stringifyOpenAPI } from './stringifyOpenAPI';
|
||||
import type { OpenAPIOperationData } from './types';
|
||||
import { mergeHeaders } from './util/headers';
|
||||
import { getDefaultServerURL } from './util/server';
|
||||
import {
|
||||
resolvePrefillCodePlaceholderFromSecurityScheme,
|
||||
@@ -125,10 +126,9 @@ function generateCodeSamples(props: {
|
||||
const mediaTypeRendererFactories = Object.entries(requestBody?.content ?? {}).map(
|
||||
([mediaType, mediaTypeObject]) => {
|
||||
return (generator: CodeSampleGenerator) => {
|
||||
const mediaTypeHeaders = {
|
||||
...genericHeaders,
|
||||
const mediaTypeHeaders = mergeHeaders(genericHeaders, {
|
||||
'Content-Type': mediaType,
|
||||
};
|
||||
});
|
||||
return {
|
||||
mediaType,
|
||||
element: context.renderCodeBlock({
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { mergeHeaders } from './headers';
|
||||
|
||||
describe('mergeHeaders', () => {
|
||||
it('should merge headers without conflicts', () => {
|
||||
const baseHeaders = {
|
||||
'user-agent': 'browser',
|
||||
Authorization: 'Bearer token',
|
||||
};
|
||||
const overrideHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const result = mergeHeaders(baseHeaders, overrideHeaders);
|
||||
|
||||
expect(result['user-agent']).toBe('browser');
|
||||
expect(result.Authorization).toBe('Bearer token');
|
||||
expect(result['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('should remove duplicate headers case-insensitively', () => {
|
||||
const baseHeaders = {
|
||||
'content-type': 'application/vnd.api+json',
|
||||
'user-agent': 'browser',
|
||||
};
|
||||
const overrideHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const result = mergeHeaders(baseHeaders, overrideHeaders);
|
||||
|
||||
// Should only have one Content-Type header (from override)
|
||||
expect(result['Content-Type']).toBe('application/json');
|
||||
expect(result['content-type']).toBeUndefined();
|
||||
expect(result['user-agent']).toBe('browser');
|
||||
});
|
||||
|
||||
it('should handle multiple case variations', () => {
|
||||
const baseHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'content-type': 'application/vnd.api+json',
|
||||
'CONTENT-TYPE': 'text/plain',
|
||||
'User-Agent': 'browser',
|
||||
};
|
||||
const overrideHeaders = {
|
||||
'Content-Type': 'application/xml',
|
||||
};
|
||||
|
||||
const result = mergeHeaders(baseHeaders, overrideHeaders);
|
||||
|
||||
// Should only have one Content-Type header (from override)
|
||||
expect(result['Content-Type']).toBe('application/xml');
|
||||
expect(result['content-type']).toBeUndefined();
|
||||
expect(result['CONTENT-TYPE']).toBeUndefined();
|
||||
expect(result['User-Agent']).toBe('browser');
|
||||
});
|
||||
|
||||
it('should override headers with same key but different casing', () => {
|
||||
const baseHeaders = {
|
||||
'user-agent': 'old-browser',
|
||||
'User-Agent': 'new-browser',
|
||||
};
|
||||
const overrideHeaders = {
|
||||
'USER-AGENT': 'override-browser',
|
||||
};
|
||||
|
||||
const result = mergeHeaders(baseHeaders, overrideHeaders);
|
||||
|
||||
// Should only have the override header
|
||||
expect(result['USER-AGENT']).toBe('override-browser');
|
||||
expect(result['user-agent']).toBeUndefined();
|
||||
expect(result['User-Agent']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty base headers', () => {
|
||||
const baseHeaders = {};
|
||||
const overrideHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer token',
|
||||
};
|
||||
|
||||
const result = mergeHeaders(baseHeaders, overrideHeaders);
|
||||
|
||||
expect(result['Content-Type']).toBe('application/json');
|
||||
expect(result.Authorization).toBe('Bearer token');
|
||||
});
|
||||
|
||||
it('should handle empty override headers', () => {
|
||||
const baseHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer token',
|
||||
};
|
||||
const overrideHeaders = {};
|
||||
|
||||
const result = mergeHeaders(baseHeaders, overrideHeaders);
|
||||
|
||||
expect(result['Content-Type']).toBe('application/json');
|
||||
expect(result.Authorization).toBe('Bearer token');
|
||||
});
|
||||
|
||||
it('should preserve override header casing', () => {
|
||||
const baseHeaders = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
const overrideHeaders = {
|
||||
'Content-Type': 'application/xml',
|
||||
};
|
||||
|
||||
const result = mergeHeaders(baseHeaders, overrideHeaders);
|
||||
|
||||
// Should use the casing from override headers
|
||||
expect(result['Content-Type']).toBe('application/xml');
|
||||
expect(Object.keys(result)).toContain('Content-Type');
|
||||
expect(Object.keys(result)).not.toContain('content-type');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Merges two header objects, removing duplicate headers case-insensitively.
|
||||
* Headers from the second object take precedence over headers from the first object.
|
||||
*/
|
||||
export function mergeHeaders(
|
||||
baseHeaders: Record<string, string>,
|
||||
overrideHeaders: Record<string, string>
|
||||
): Record<string, string> {
|
||||
const overrideKeysLower = new Set(Object.keys(overrideHeaders).map((key) => key.toLowerCase()));
|
||||
const filteredBaseHeaders = Object.fromEntries(
|
||||
Object.entries(baseHeaders).filter(([key]) => !overrideKeysLower.has(key.toLowerCase()))
|
||||
);
|
||||
|
||||
return {
|
||||
...filteredBaseHeaders,
|
||||
...overrideHeaders,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,14 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
for arg in "$@"; do
|
||||
if [[ "${arg}" == "--dry-run" ]]; then
|
||||
DRY_RUN=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
NAME=$(node -p "require('./package.json').name")
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
@@ -16,6 +24,27 @@ if npm view "${NAME}@${VERSION}" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# We compute the workspace root relative to this script
|
||||
WORKSPACE_ROOT=$(dirname $(dirname $(realpath $0)))
|
||||
|
||||
# Strip development exports before packing and restore afterward.
|
||||
PACKAGE_JSON_PATH="${PWD}/package.json"
|
||||
PACKAGE_JSON_BACKUP=$(mktemp)
|
||||
cp "${PACKAGE_JSON_PATH}" "${PACKAGE_JSON_BACKUP}"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${TARBALL_PATH:-}" && -f "${TARBALL_PATH}" ]]; then
|
||||
rm -f "${TARBALL_PATH}"
|
||||
fi
|
||||
if [[ -f "${PACKAGE_JSON_BACKUP}" ]]; then
|
||||
mv "${PACKAGE_JSON_BACKUP}" "${PACKAGE_JSON_PATH}"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
node "${WORKSPACE_ROOT}/scripts/strip-development-exports.mjs" "${PACKAGE_JSON_PATH}"
|
||||
|
||||
# Sanitize the name to make it a valid filename as bun doesn't support @ in filenames
|
||||
SANITIZED_NAME=${NAME//@/}
|
||||
SANITIZED_NAME=${SANITIZED_NAME//\//-}
|
||||
@@ -23,9 +52,6 @@ TARBALL_FILENAME="${SANITIZED_NAME}-${VERSION}.tgz"
|
||||
|
||||
bun pm pack --filename "${TARBALL_FILENAME}"
|
||||
|
||||
# We compute the workspace root relative to this script
|
||||
WORKSPACE_ROOT=$(dirname $(dirname $(realpath $0)))
|
||||
|
||||
# Bun pack puts the tarball in the workspace root
|
||||
TARBALL_PATH="${WORKSPACE_ROOT}/${TARBALL_FILENAME}"
|
||||
|
||||
@@ -34,8 +60,9 @@ if [[ ! -f "${TARBALL_PATH}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up the tarball
|
||||
trap 'rm -f "${TARBALL_PATH}"' EXIT
|
||||
|
||||
# Publish with verbose logging to aid debugging
|
||||
npm publish "${TARBALL_PATH}" --no-workspaces --provenance
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "npm publish \"${TARBALL_PATH}\" --no-workspaces --provenance"
|
||||
else
|
||||
npm publish "${TARBALL_PATH}" --no-workspaces --provenance
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
export function stripDevelopmentExports(exportsField) {
|
||||
if (!exportsField || typeof exportsField !== 'object' || Array.isArray(exportsField)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(exportsField, 'development')) {
|
||||
delete exportsField.development;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
for (const value of Object.values(exportsField)) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
if (stripDevelopmentExports(value)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
export async function stripDevelopmentExportsInFile(filePath) {
|
||||
const raw = await fs.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (!parsed.exports) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const changed = stripDevelopmentExports(parsed.exports);
|
||||
|
||||
if (!changed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = `${JSON.stringify(parsed, null, 4)}\n`;
|
||||
await fs.writeFile(filePath, next);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const filePath = process.argv[2] || path.join(process.cwd(), 'package.json');
|
||||
await stripDevelopmentExportsInFile(filePath);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error('Failed to strip development exports:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { stripDevelopmentExportsInFile } from './strip-development-exports.mjs';
|
||||
|
||||
test('stripDevelopmentExportsInFile removes development conditions', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'strip-dev-exports-'));
|
||||
const filePath = path.join(dir, 'package.json');
|
||||
|
||||
const original = {
|
||||
name: '@gitbook/example',
|
||||
exports: {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
development: './src/index.ts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
'./icons': {
|
||||
types: './dist/icons.d.ts',
|
||||
development: './src/icons.ts',
|
||||
default: './dist/icons.js',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(filePath, `${JSON.stringify(original, null, 4)}\n`);
|
||||
|
||||
const changed = await stripDevelopmentExportsInFile(filePath);
|
||||
expect(changed).toBe(true);
|
||||
|
||||
const updated = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
||||
|
||||
expect(updated.exports['.'].development).toBeUndefined();
|
||||
expect(updated.exports['./icons'].development).toBeUndefined();
|
||||
expect(updated.exports['.'].default).toBe('./dist/index.js');
|
||||
});
|
||||
|
||||
test('stripDevelopmentExportsInFile returns false when nothing to remove', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'strip-dev-exports-'));
|
||||
const filePath = path.join(dir, 'package.json');
|
||||
|
||||
const original = {
|
||||
name: '@gitbook/example',
|
||||
exports: {
|
||||
'.': {
|
||||
types: './dist/index.d.ts',
|
||||
default: './dist/index.js',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(filePath, `${JSON.stringify(original, null, 4)}\n`);
|
||||
|
||||
const changed = await stripDevelopmentExportsInFile(filePath);
|
||||
expect(changed).toBe(false);
|
||||
|
||||
const updated = JSON.parse(await fs.readFile(filePath, 'utf8'));
|
||||
expect(updated).toEqual(original);
|
||||
});
|
||||
Reference in New Issue
Block a user