mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-24 19:32:07 +00:00
Merge branch 'main' into claire/RND-8419-support-new-sidebartitle-page-option
* main: (48 commits) Edgeless Assistant window on `depth: flat` sites (#3788) Add heredoc support for cURL JSON body (#3790) Update site layout and blocks with container queries (#3780) Rework OpenAPI Authorizations + scopes (#3786) Support custom cover heights (#3779) Update `DropdownMenu` styling (#3787) Treat pages without visible TOC items as `no-toc` (#3783) Implement unique title tags for sections & variants (#3768) Add page breadcrumbs to search results (#3784) Expand code block when printing (#3782) Disable Workers Observability (#3781) Reword "Everywhere" search scope to "All docs" (#3776) Add padding to TOC page group items (#3777) Improve OAuth2 scopes handling in OpenAPI (#3775) Support translations and generic variants together (#3772) Initialize hash on mount and improve navigation handling (#3774) Revert "Support cover image heights and positioning changes" (#3771) Fix search container shadow (#3770) Redirect to space base path on fallback true (#3745) Add isRoot prop to PagesList for root header offset (#3759) ...
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
---
|
||||
|
||||
Add heredoc support for cURL JSON body
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix webframe height issue
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add breadcrumbs to search results
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
SearchInput fixes — Translate Clear button, Bold theme color, "Esc" keyboard shortcut
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix search container shadow
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Update dropdown menu styling
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Printable collapsed code block
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Revert contentref anchor title
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Reword "Everywhere" search scope to "All docs"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Move the "Ask <query>" to the bottom of search results if query is not a question
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Improve AI Search branding
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Add alt text support to card covers
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve ancestor label in references
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Tweak language dropdown styling
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix ToggeableLinkItem display
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix icon resolution for page group
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Support translations and generic variants together
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Edgeless Assistant window on `depth: flat` sites
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add confirmation to "Copy MCP URL" page action
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Implement unique title tags for sections & variants
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Treat pages without visible TOC items as `no-toc`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Add padding to TOC page group items
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Update search scope UI
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Fix OpenAPI path display
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": minor
|
||||
---
|
||||
|
||||
Update site layout and blocks with container queries and better transitions
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@gitbook/react-openapi': patch
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Improve OAuth2 scopes handling in OpenAPI
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"gitbook": patch
|
||||
---
|
||||
|
||||
Fix opacity of ScrollContainer buttons on bold themes
|
||||
@@ -54,10 +54,9 @@ After forking this repository, you'll want to [create a branch](https://docs.git
|
||||
#### 3. Install dependencies and run the project locally
|
||||
|
||||
##### Prerequisites:
|
||||
- Node.js (Version: >= 22.3)
|
||||
- Use `nvm` for easy Node management
|
||||
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
||||
- We use a text-based lockfile which isn't supported below 1.2.15
|
||||
|
||||
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
|
||||
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
|
||||
|
||||
##### Setup steps:
|
||||
|
||||
|
||||
@@ -343,7 +343,7 @@
|
||||
"react-dom": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@gitbook/api": "0.143.2",
|
||||
"@gitbook/api": "0.147.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
@@ -724,7 +724,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.143.2", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ZIQwIgX+artl0o20ftnzeJyM9icwQ6pFeM55AG/lEdeFbMuUGxTfSnZ90+8Aou8l/3ELR3/tOkU2SAvyKc+ATw=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.147.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ON32kqr9ID6td9h26bmmPflpoKZZ3+Z0W5URjc/YIDfk+YFb7/rVrOGzYf3NV9R+uZuxVzIKRJ9Yq8v0WLVwNQ=="],
|
||||
|
||||
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "gitbook",
|
||||
"version": "0.1.0",
|
||||
"engines": {
|
||||
"node": "^22.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.29.7",
|
||||
@@ -39,7 +42,7 @@
|
||||
"catalog": {
|
||||
"@tsconfig/strictest": "^2.0.6",
|
||||
"@tsconfig/node20": "^20.1.6",
|
||||
"@gitbook/api": "0.143.2",
|
||||
"@gitbook/api": "0.147.0",
|
||||
"@scalar/api-client-react": "^1.3.46",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
@@ -112,11 +112,11 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://adiblar.gitbook.io',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.gradient.network',
|
||||
contentBaseURL: 'https://docs.gradient.network',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
// {
|
||||
// name: 'docs.gradient.network',
|
||||
// contentBaseURL: 'https://docs.gradient.network',
|
||||
// tests: [{ name: 'Home', url: '/' }],
|
||||
// },
|
||||
// {
|
||||
// name: 'mygate-network.gitbook.io',
|
||||
// contentBaseURL: 'https://mygate-network.gitbook.io',
|
||||
@@ -172,11 +172,6 @@ const testCases: TestsCase[] = [
|
||||
contentBaseURL: 'https://docs.realapp.link',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.plaza.finance',
|
||||
contentBaseURL: 'https://docs.plaza.finance',
|
||||
tests: [{ name: 'Home', url: '/' }],
|
||||
},
|
||||
{
|
||||
name: 'docs.publicai.io',
|
||||
contentBaseURL: 'https://docs.publicai.io',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
headerLinks,
|
||||
runTestCases,
|
||||
waitForCookiesDialog,
|
||||
waitForCoverImages,
|
||||
waitForNotFound,
|
||||
} from './util';
|
||||
|
||||
@@ -228,6 +229,58 @@ const testCases: TestsCase[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Expandable TOC navigation',
|
||||
url: '',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
|
||||
// Verify "Navigation" link is not visible initially
|
||||
const navigationLink = page.getByRole('link', { name: 'Navigation' });
|
||||
await expect(navigationLink).not.toBeVisible();
|
||||
|
||||
// Find and click the chevron element that is next to "Editor" in the TOC
|
||||
// It is a span inside the link
|
||||
const editorChevron = page
|
||||
.getByRole('link', { name: 'Editor' })
|
||||
.locator('span');
|
||||
await editorChevron.click();
|
||||
|
||||
// Verify "Navigation" link becomes visible after expansion
|
||||
await expect(navigationLink).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Expandable nested TOC navigation',
|
||||
url: '',
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
|
||||
// Verify "Spaces" link is not visible initially
|
||||
const navigationLink = page.getByRole('link', { name: 'Spaces' });
|
||||
await expect(navigationLink).not.toBeVisible();
|
||||
|
||||
// Find and click the chevron element that is next to "Editor" in the TOC
|
||||
// It is a span inside the link
|
||||
const editorChevron = page
|
||||
.getByRole('link', { name: 'Editor' })
|
||||
.locator('span');
|
||||
await editorChevron.click();
|
||||
|
||||
// At this stage the link should still not be visible
|
||||
await expect(navigationLink).not.toBeVisible();
|
||||
|
||||
// Then we click 'Content Structure' chevron to expand further
|
||||
const contentStructureChevron = page
|
||||
.getByRole('link', { name: 'Content Structure' })
|
||||
.locator('span');
|
||||
await contentStructureChevron.click();
|
||||
|
||||
// Verify "Spaces" link becomes visible after expansion
|
||||
await expect(navigationLink).toBeVisible();
|
||||
},
|
||||
},
|
||||
...searchTestCases,
|
||||
{
|
||||
name: 'Not found',
|
||||
@@ -727,7 +780,10 @@ const testCases: TestsCase[] = [
|
||||
url: 'blocks/integrations',
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
const mermaidIframe = page.locator('iframe[title*="mermaid"]').contentFrame();
|
||||
const mermaidIframe = page
|
||||
.locator('iframe[title*="mermaid"]')
|
||||
.first()
|
||||
.contentFrame();
|
||||
await expect(mermaidIframe.getByText('Mermaid', { exact: true })).toBeVisible();
|
||||
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
|
||||
},
|
||||
@@ -851,7 +907,10 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'With cover',
|
||||
url: 'page-options/page-with-cover',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With cover for dark mode',
|
||||
@@ -866,12 +925,18 @@ const testCases: TestsCase[] = [
|
||||
{
|
||||
name: 'With hero cover',
|
||||
url: 'page-options/page-with-hero-cover',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With cover and no TOC',
|
||||
url: 'page-options/page-with-cover-and-no-toc',
|
||||
run: waitForCookiesDialog,
|
||||
run: async (page) => {
|
||||
await waitForCookiesDialog(page);
|
||||
await waitForCoverImages(page);
|
||||
},
|
||||
screenshot: {
|
||||
waitForTOCScrolling: false,
|
||||
},
|
||||
|
||||
@@ -154,6 +154,13 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||
expect(response?.status()).toBe(404);
|
||||
}
|
||||
|
||||
export async function waitForCoverImages(page: Page) {
|
||||
// Wait for cover images to exist (not the shimmer placeholder)
|
||||
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform test cases into Playwright tests and run it.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,8 @@ export default {
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
||||
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||
incrementalCache: () =>
|
||||
import('./openNext/incrementalCache/server').then((m) => m.default),
|
||||
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||
},
|
||||
},
|
||||
@@ -18,7 +19,8 @@ export default {
|
||||
converter: 'edge',
|
||||
proxyExternalRequest: 'fetch',
|
||||
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
||||
incrementalCache: () => import('./openNext/incrementalCache').then((m) => m.default),
|
||||
incrementalCache: () =>
|
||||
import('./openNext/incrementalCache/middleware').then((m) => m.default),
|
||||
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
{
|
||||
"main": "default.js",
|
||||
"name": "gitbook-open-v2-server",
|
||||
"keep_names": false,
|
||||
"compatibility_date": "2025-04-14",
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"vars": {
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
},
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
"main": "do.js",
|
||||
"name": "gitbook-open-v2-do",
|
||||
"compatibility_date": "2025-04-14",
|
||||
"keep_names": false,
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
"global_fetch_strictly_public"
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"env": {
|
||||
"preview": {
|
||||
"vars": {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"main": "middleware.js",
|
||||
"name": "gitbook-open-v2",
|
||||
"compatibility_date": "2025-04-14",
|
||||
"keep_names": false,
|
||||
"compatibility_flags": [
|
||||
"nodejs_compat",
|
||||
"allow_importable_env",
|
||||
@@ -11,9 +12,6 @@
|
||||
"directory": "../../.open-next/assets",
|
||||
"binding": "ASSETS"
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true
|
||||
},
|
||||
"vars": {
|
||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||
},
|
||||
|
||||
+1
-12
@@ -7,8 +7,6 @@ import type {
|
||||
} from '@opennextjs/aws/types/overrides.js';
|
||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
|
||||
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
|
||||
|
||||
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
|
||||
@@ -23,7 +21,7 @@ export type KeyOptions = {
|
||||
* It is very similar to the `R2IncrementalCache` in the `@opennextjs/cloudflare` package, but it has an additional
|
||||
* R2WriteBuffer Durable Object to handle writes to R2. Given how we set up cache, we often end up writing to the same key too fast.
|
||||
*/
|
||||
class GitbookIncrementalCache implements IncrementalCache {
|
||||
export class GitbookIncrementalCache implements IncrementalCache {
|
||||
name = 'GitbookIncrementalCache';
|
||||
|
||||
async get<CacheType extends CacheEntryType = 'cache'>(
|
||||
@@ -137,12 +135,3 @@ class GitbookIncrementalCache implements IncrementalCache {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// We can do it because we use our own logic to invalidate the cache
|
||||
bypassTagCacheOnCacheHit: true,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// We can do it because we use our own logic to invalidate the cache
|
||||
bypassTagCacheOnCacheHit: true,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
||||
import { GitbookIncrementalCache } from './incrementalCache';
|
||||
|
||||
export default withRegionalCache(new GitbookIncrementalCache(), {
|
||||
mode: 'long-lived',
|
||||
// Because of a race condition, the middleware may have populated the cache entry before `cache.match` had time to run on the server.
|
||||
// TODO: We should bypass the incremental cache entirely when the interceptor has caught the request. Should be done in OpenNext.
|
||||
bypassTagCacheOnCacheHit: false,
|
||||
defaultLongLivedTtlSec: 60 * 60 * 24 /* 24 hours */,
|
||||
// We don't want to update the cache entry on every cache hit
|
||||
shouldLazilyUpdateOnCacheHit: false,
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { CustomizationAIMode } from '@gitbook/api';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
import * as React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||
import { useAIChatController, useAIChatState } from '.';
|
||||
import { AIChatIcon, getAIChatName } from '../AIChat';
|
||||
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
|
||||
import { useIntegrationAssistants } from '../Integrations';
|
||||
import { useSearch } from '../Search/useSearch';
|
||||
|
||||
@@ -104,16 +104,7 @@ export function useAI(): AIContext {
|
||||
assistants.push({
|
||||
id: 'gitbook-ai-search',
|
||||
label: tString(language, 'ai_chat_context_badge'),
|
||||
icon: (
|
||||
<div className="relative">
|
||||
<Icon icon="search" className="size-4" />
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="absolute top-[2.5px] left-[2.6px] size-2"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
icon: <AISearchIcon />,
|
||||
open: (query?: string) => {
|
||||
if (query) {
|
||||
setSearchState((prev) =>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { t, tString, useLanguage } from '@/intl/client';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import React from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
@@ -65,16 +66,17 @@ export function AIChat(props: { trademark: boolean }) {
|
||||
}
|
||||
}, [chat.opened, trackEvent]);
|
||||
|
||||
if (!chat.opened) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="ai-chat"
|
||||
className="ai-chat inset-y-0 right-0 z-40 mx-auto flex max-w-3xl animate-present scroll-mt-36 px-4 py-4 transition-all duration-300 sm:px-6 lg:fixed lg:w-80 lg:animate-enter-from-right lg:pr-4 lg:pl-0 xl:w-96"
|
||||
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 depth-flat:lg:p-0 lg:pr-4 lg:pl-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'
|
||||
)}
|
||||
>
|
||||
<EmbeddableFrame className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
||||
<EmbeddableFrame className="relative shrink-0 circular-corners:rounded-3xl rounded-corners:rounded-md border border-tint-subtle depth-subtle:shadow-lg shadow-tint transition-all duration-300 lg:w-76 depth-flat:lg:rounded-none depth-flat:lg:border-y-0 depth-flat:lg:border-r-0 xl:w-92">
|
||||
<EmbeddableFrameHeader>
|
||||
<AIChatDynamicIcon trademark={trademark} />
|
||||
<EmbeddableFrameHeaderMain>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function AIChatButton(props: {
|
||||
iconOnly={!showLabel}
|
||||
size="medium"
|
||||
variant="header"
|
||||
className="h-9 px-2.5 max-md:[&_.button-content]:hidden"
|
||||
className="h-9 px-2.5 @max-2xl:[&_.button-content]:hidden"
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{t(language, 'ai_chat_ask', assistant.label)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Icon, IconStyle } from '@gitbook/icons';
|
||||
import type React from 'react';
|
||||
|
||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
@@ -9,17 +9,18 @@ interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
trademark?: boolean;
|
||||
}
|
||||
|
||||
export const AIChatIcon = ({
|
||||
export function AIChatIcon({
|
||||
className = 'size-4',
|
||||
size,
|
||||
trademark = true,
|
||||
state = 'default',
|
||||
...props
|
||||
}: AIChatIconProps) => {
|
||||
}: AIChatIconProps) {
|
||||
if (!trademark) {
|
||||
return (
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
{...props}
|
||||
className={tcls(
|
||||
className,
|
||||
(state === 'thinking' || state === 'working') &&
|
||||
@@ -163,4 +164,36 @@ export const AIChatIcon = ({
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function AISearchIcon({
|
||||
className = 'size-4',
|
||||
state = 'default',
|
||||
}: Pick<AIChatIconProps, 'className' | 'state'>) {
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
'relative',
|
||||
state === 'intro' && 'animate-[fadeIn_1s_both,orbit_1s_cubic-bezier(0.16,1,0.3,1)]',
|
||||
state === 'thinking' || state === 'working'
|
||||
? 'animate-[fadeIn_1s_both,orbit_1s_ease-out,orbit_2s_1s_infinite_forwards_linear]'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<Icon icon="search" className={className} />
|
||||
<Icon
|
||||
icon="sparkle"
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'absolute top-[15.7%] left-[15.6%] size-[50%]',
|
||||
state === 'thinking' || state === 'working'
|
||||
? 'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||
: '',
|
||||
state === 'intro'
|
||||
? 'animate-[spin_2s_.5s_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
||||
: ''
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,11 +81,19 @@ export function AIChatMessages(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function HoldMessage() {
|
||||
export function HoldMessage({
|
||||
breakLines = false,
|
||||
className = '',
|
||||
}: { breakLines?: boolean; className?: string }) {
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle">
|
||||
<div
|
||||
className={tcls(
|
||||
'animate-[heightIn_500ms_4500ms_ease_both] overflow-hidden py-2 text-tint-subtle',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{tString(language, 'ai_chat_hold_message_1')
|
||||
.split(' ')
|
||||
.map((word, index) => (
|
||||
@@ -99,6 +107,7 @@ function HoldMessage() {
|
||||
{word}{' '}
|
||||
</span>
|
||||
))}
|
||||
{breakLines ? <br /> : null}
|
||||
{tString(language, 'ai_chat_hold_message_2')
|
||||
.split(' ')
|
||||
.map((word, index) => (
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import type { DocumentBlockCode } from '@gitbook/api';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useAdaptiveVisitor } from '@/components/Adaptive';
|
||||
import { useInViewportListener } from '@/components/hooks/useInViewportListener';
|
||||
import { useScrollListener } from '@/components/hooks/useScrollListener';
|
||||
import { Button } from '@/components/primitives';
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { useDebounceCallback } from 'usehooks-ts';
|
||||
import type { BlockProps } from '../Block';
|
||||
import { type InlineExpressionVariables, useEvaluateInlineExpression } from '../InlineExpression';
|
||||
@@ -16,14 +19,17 @@ import { plainHighlight } from './plain-highlight';
|
||||
type ClientBlockProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
inlines: RenderedInline[];
|
||||
inlineExprVariables: InlineExpressionVariables;
|
||||
mode: BlockProps<DocumentBlockCode>['context']['mode'];
|
||||
};
|
||||
|
||||
export const CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT = 10;
|
||||
|
||||
/**
|
||||
* Render a code-block client-side by loading the highlighter asynchronously.
|
||||
* It allows us to defer some load to avoid blocking the rendering of the whole page with block highlighting.
|
||||
*/
|
||||
export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
const { block, style, inlines, inlineExprVariables } = props;
|
||||
const { block, mode, style, inlines, inlineExprVariables } = props;
|
||||
const blockRef = useRef<HTMLDivElement>(null);
|
||||
const isInViewportRef = useRef(false);
|
||||
const [isInViewport, setIsInViewport] = useState(false);
|
||||
@@ -113,13 +119,84 @@ export function ClientCodeBlock(props: ClientBlockProps) {
|
||||
setLines(null);
|
||||
}, [isInViewport, block, inlines, evaluateInlineExpression]);
|
||||
|
||||
return (
|
||||
const expandable = block.data.expandable;
|
||||
|
||||
const numberOfLinesOfCode = lines?.length ?? plainLines.length;
|
||||
const collapsedLineCount =
|
||||
block.data.collapsedLineCount || CODE_BLOCK_DEFAULT_COLLAPSED_LINE_COUNT;
|
||||
const isExpandable = Boolean(
|
||||
expandable && mode !== 'print' && numberOfLinesOfCode > collapsedLineCount
|
||||
);
|
||||
|
||||
const codeBlockBodyId = useId();
|
||||
|
||||
const renderer = (
|
||||
<CodeBlockRenderer
|
||||
ref={blockRef}
|
||||
aria-busy={highlighting}
|
||||
block={block}
|
||||
style={style}
|
||||
lines={lines ?? plainLines}
|
||||
id={codeBlockBodyId}
|
||||
/>
|
||||
);
|
||||
|
||||
return isExpandable ? (
|
||||
<CodeBlockExpandable
|
||||
lines={lines ?? plainLines}
|
||||
controls={codeBlockBodyId}
|
||||
collapsedLineCount={collapsedLineCount}
|
||||
>
|
||||
{renderer}
|
||||
</CodeBlockExpandable>
|
||||
) : (
|
||||
renderer
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlockExpandable(props: {
|
||||
children: React.ReactNode;
|
||||
lines: HighlightLine[];
|
||||
collapsedLineCount: number;
|
||||
controls?: string;
|
||||
}) {
|
||||
const { children, controls, lines = [], collapsedLineCount } = props;
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const language = useLanguage();
|
||||
return (
|
||||
<div className="group/codeblock-expandable relative">
|
||||
<div
|
||||
className={tcls(
|
||||
isExpanded
|
||||
? '[&_pre]:after:opacity-0'
|
||||
: '[&_pre]:h-[calc(2rem+var(--line-count)*var(--line-height))] [&_pre]:overflow-y-hidden [&_pre]:after:opacity-100',
|
||||
'[&_pre]:after:pointer-events-none [&_pre]:after:absolute [&_pre]:after:inset-0 [&_pre]:after:z-1 [&_pre]:after:bg-gradient-to-t [&_pre]:after:from-0% [&_pre]:after:from-tint-2 [&_pre]:after:to-70% [&_pre]:after:to-transparent [&_pre]:after:content-[""]'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--line-count': collapsedLineCount,
|
||||
'--line-height': '1.25rem',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-0 flex w-full justify-center">
|
||||
<Button
|
||||
icon={isExpanded ? 'chevron-up' : 'chevron-down'}
|
||||
size="xsmall"
|
||||
variant="blank"
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="pointer-events-auto z-1 my-2 text-primary text-sm opacity-0 focus:opacity-11 group-hover/codeblock-expandable:opacity-11"
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={controls}
|
||||
>
|
||||
{isExpanded
|
||||
? t(language, 'code_block_expanded')
|
||||
: t(language, 'code_block_collapsed', lines.length)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
return { inline, body };
|
||||
});
|
||||
|
||||
if (!isEstimatedOffscreen && !hasInlineExpression) {
|
||||
if (!isEstimatedOffscreen && !hasInlineExpression && !block.data.expandable) {
|
||||
// In v2, we render the code block server-side
|
||||
const lines = await highlight(block, richInlines);
|
||||
return <CodeBlockRenderer block={block} style={style} lines={lines} />;
|
||||
@@ -73,6 +73,7 @@ export async function CodeBlock(props: BlockProps<DocumentBlockCode>) {
|
||||
style={style}
|
||||
inlines={richInlines}
|
||||
inlineExprVariables={variables}
|
||||
mode={context.mode}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { HighlightLine, HighlightToken } from './highlight';
|
||||
type CodeBlockRendererProps = Pick<BlockProps<DocumentBlockCode>, 'block' | 'style'> & {
|
||||
lines: HighlightLine[];
|
||||
'aria-busy'?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -23,11 +24,12 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
) {
|
||||
const { block, style, lines, 'aria-busy': ariaBusy } = props;
|
||||
|
||||
const id = useId();
|
||||
const withLineNumbers = Boolean(block.data.lineNumbers) && block.nodes.length > 1;
|
||||
const withWrap = block.data.overflow === 'wrap';
|
||||
const title = block.data.title;
|
||||
|
||||
const id = useId();
|
||||
const codeId = props.id || id;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -42,7 +44,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
) : null}
|
||||
</div>
|
||||
<CopyCodeButton
|
||||
codeId={id}
|
||||
codeId={codeId}
|
||||
style="z-2 mt-2 mr-2 self-start justify-self-end leading-none opacity-0 backdrop-blur-md [grid-area:2/1] group-hover/codeblock:opacity-11"
|
||||
/>
|
||||
<pre
|
||||
@@ -53,7 +55,7 @@ export const CodeBlockRenderer = forwardRef(function CodeBlockRenderer(
|
||||
)}
|
||||
>
|
||||
<code
|
||||
id={id}
|
||||
id={codeId}
|
||||
className={tcls(
|
||||
'inline-grid min-w-full grid-cols-[auto_1fr] [count-reset:line] print:whitespace-pre-wrap',
|
||||
withWrap && 'whitespace-pre-wrap'
|
||||
|
||||
@@ -80,6 +80,7 @@ export function Column(props: {
|
||||
<div
|
||||
className={tcls(
|
||||
'flex flex-col',
|
||||
'@container/column',
|
||||
(verticalAlignment === VerticalAlignment.Top || !verticalAlignment) &&
|
||||
'justify-start',
|
||||
verticalAlignment === VerticalAlignment.Middle && 'justify-center',
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
|
||||
alt="Drawing"
|
||||
sizes={imageBlockSizes}
|
||||
zoom
|
||||
loading="lazy"
|
||||
/>
|
||||
</Caption>
|
||||
);
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
|
||||
sources={{ light: { src: embed.icon } }}
|
||||
sizes={[{ width: 20 }]}
|
||||
resize={context.contentContext.imageResizer}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -17,29 +17,34 @@ export function Details(props: {
|
||||
}) {
|
||||
const { children, id, className } = props;
|
||||
|
||||
const detailsRef = React.useRef<HTMLDetailsElement>(null);
|
||||
const ref = React.useRef<HTMLDetailsElement>(null);
|
||||
|
||||
const [openFromHash, setOpenFromHash] = React.useState(false);
|
||||
|
||||
const hash = useHash();
|
||||
|
||||
/**
|
||||
* Open the details element if the url hash refers to the id of the details element
|
||||
* or the id of some element contained within the details element.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!hash || !detailsRef.current) {
|
||||
if (!hash || !ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hash === id) {
|
||||
setOpenFromHash(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.getElementById(hash);
|
||||
setOpenFromHash(Boolean(activeElement && detailsRef.current?.contains(activeElement)));
|
||||
const isOpen = Boolean(activeElement && ref.current.contains(activeElement));
|
||||
setOpenFromHash(isOpen);
|
||||
}, [hash, id]);
|
||||
|
||||
return (
|
||||
<details
|
||||
ref={detailsRef}
|
||||
ref={ref}
|
||||
id={id}
|
||||
open={props.open || openFromHash}
|
||||
className={tcls(
|
||||
|
||||
@@ -19,34 +19,42 @@ export function Images(props: BlockProps<DocumentBlockImages>) {
|
||||
className={tcls(
|
||||
style,
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-3',
|
||||
'w-full',
|
||||
align === 'center' && 'justify-center',
|
||||
align === 'right' && 'justify-end',
|
||||
align === 'left' && 'justify-start',
|
||||
hasMultipleImages && ['grid', 'grid-flow-col'],
|
||||
withFrame && [
|
||||
'rounded-2xl',
|
||||
'border',
|
||||
'border-[rgb(234,235,238)]',
|
||||
'dark:border-[rgb(45,50,58)]',
|
||||
'relative',
|
||||
'overflow-hidden',
|
||||
]
|
||||
align === 'left' && 'justify-start'
|
||||
)}
|
||||
>
|
||||
{block.nodes.map((node: any, _i: number) => (
|
||||
<ImageBlock
|
||||
key={node.key}
|
||||
block={node}
|
||||
document={document}
|
||||
style={[]}
|
||||
siblings={block.nodes.length}
|
||||
context={context}
|
||||
isEstimatedOffscreen={isEstimatedOffscreen}
|
||||
withFrame={withFrame}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={tcls(
|
||||
'flex',
|
||||
'flex-row',
|
||||
'gap-3',
|
||||
hasMultipleImages && ['grid', 'grid-flow-col'],
|
||||
withFrame && [
|
||||
'rounded-2xl',
|
||||
'border',
|
||||
'border-[rgb(234,235,238)]',
|
||||
'dark:border-[rgb(45,50,58)]',
|
||||
'relative',
|
||||
'overflow-hidden',
|
||||
'w-fit',
|
||||
]
|
||||
)}
|
||||
>
|
||||
{block.nodes.map((node: any, _i: number) => (
|
||||
<ImageBlock
|
||||
key={node.key}
|
||||
block={node}
|
||||
document={document}
|
||||
style={[]}
|
||||
siblings={block.nodes.length}
|
||||
context={context}
|
||||
isEstimatedOffscreen={isEstimatedOffscreen}
|
||||
withFrame={withFrame}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,8 +143,7 @@ async function ImageBlock(props: {
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
priority={isEstimatedOffscreen ? 'lazy' : 'high'}
|
||||
preload
|
||||
loading={isEstimatedOffscreen ? 'lazy' : 'eager'}
|
||||
zoom
|
||||
inlineStyle={{
|
||||
maxWidth: '100%',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DocumentInlineIcon, DocumentMarkColor } from '@gitbook/api';
|
||||
import type { DocumentInlineIcon } from '@gitbook/api';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName } from '@gitbook/icons';
|
||||
@@ -7,16 +7,11 @@ import { textColorToStyle } from './utils/colors';
|
||||
|
||||
export async function InlineIcon(props: InlineProps<DocumentInlineIcon>) {
|
||||
const { inline } = props;
|
||||
const icon = inline.data.icon as IconName;
|
||||
// @ts-expect-error remove this comment once API is updated
|
||||
const color = inline.data.color
|
||||
? // @ts-expect-error remove "as DocumentMarkColor['data']['text']" once API is updated
|
||||
(inline.data.color as DocumentMarkColor['data']['text'])
|
||||
: undefined;
|
||||
const { color, icon } = inline.data;
|
||||
|
||||
return (
|
||||
<Icon
|
||||
icon={icon}
|
||||
icon={icon as IconName}
|
||||
className={tcls('inline size-[1em]', color ? textColorToStyle[color] : null)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -49,8 +49,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
priority="lazy"
|
||||
preload
|
||||
loading="lazy"
|
||||
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
|
||||
inline
|
||||
zoom={!isInLink}
|
||||
|
||||
@@ -41,6 +41,7 @@ export function getOpenAPIContext(args: {
|
||||
plus: <Icon icon="plus" />,
|
||||
copy: <Icon icon="copy" />,
|
||||
check: <Icon icon="check" />,
|
||||
lock: <Icon icon="lock" />,
|
||||
},
|
||||
renderCodeBlock: (codeProps) => <PlainCodeBlock {...codeProps} />,
|
||||
renderDocument: (documentProps) => (
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
.openapi-deprecated,
|
||||
.openapi-stability {
|
||||
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||
@apply py-0.5 px-1.5 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-sm text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||
}
|
||||
|
||||
.openapi-stability-alpha {
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
|
||||
.openapi-markdown code {
|
||||
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||
@apply py-px px-1 min-w-[1.625rem] font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||
}
|
||||
|
||||
.openapi-markdown pre code {
|
||||
@@ -95,7 +95,7 @@
|
||||
/* Method Tags */
|
||||
.openapi-method,
|
||||
.openapi-statuscode {
|
||||
@apply rounded uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
|
||||
@apply rounded straight-corners:rounded-none circular-corners:rounded-md uppercase font-mono items-center shrink-0 font-semibold text-[0.813rem] px-1 py-0.5 mr-2 text-tint-12/8 leading-tight align-middle inline-flex ring-1 ring-inset ring-tint-12/1 dark:ring-tint-1/1 whitespace-nowrap;
|
||||
}
|
||||
|
||||
.openapi-method-get,
|
||||
@@ -270,11 +270,11 @@
|
||||
}
|
||||
|
||||
.openapi-schema-enum-value:first-child {
|
||||
@apply rounded-l ml-0;
|
||||
@apply rounded-l straight-corners:rounded-none circular-corners:rounded-l-md ml-0;
|
||||
}
|
||||
|
||||
.openapi-schema-enum-value:last-child {
|
||||
@apply rounded-r;
|
||||
@apply rounded-r straight-corners:rounded-none circular-corners:rounded-r-md;
|
||||
}
|
||||
|
||||
/* Schema Description */
|
||||
@@ -308,7 +308,7 @@
|
||||
.openapi-schema-pattern code,
|
||||
.openapi-schema-enum-value code,
|
||||
.openapi-schema-default code {
|
||||
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||
@apply py-px px-1 min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint-subtle bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-xs leading-[calc(max(1.20em,1.25rem))] before:content-none! after:!content-none;
|
||||
}
|
||||
|
||||
/* Authentication */
|
||||
@@ -325,16 +325,20 @@
|
||||
@apply prose *:!prose-sm *:text-tint;
|
||||
}
|
||||
|
||||
.openapi-securities-oauth-content {
|
||||
@apply flex flex-col gap-1 mt-1;
|
||||
}
|
||||
|
||||
.openapi-securities-oauth-content.openapi-markdown code {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.openapi-securities-scopes ul {
|
||||
@apply !my-0;
|
||||
@apply !my-0 ml-4 pl-0;
|
||||
}
|
||||
|
||||
.openapi-securities-url {
|
||||
@apply ml-0.5 px-0.5 rounded hover:bg-tint transition-colors;
|
||||
@apply ml-0.5 px-0.5 rounded straight-corners:rounded-none circular-corners:rounded-md hover:bg-tint dark:hover:bg-tint-hover transition-colors;
|
||||
}
|
||||
|
||||
.openapi-securities-body {
|
||||
@@ -394,15 +398,13 @@
|
||||
@apply text-left prose-sm text-sm leading-tight text-tint select-text prose-strong:font-semibold prose-strong:text-inherit;
|
||||
}
|
||||
|
||||
.openapi-disclosure-group-trigger[aria-expanded="false"] {
|
||||
.openapi-response-description.openapi-markdown {
|
||||
@apply truncate;
|
||||
@apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0;
|
||||
}
|
||||
.openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-description.openapi-markdown {
|
||||
@apply truncate;
|
||||
@apply [&>*:not(:first-child)]:hidden *:truncate *:!p-0 *:!m-0;
|
||||
}
|
||||
|
||||
.openapi-response-tab-content {
|
||||
@apply basis-[60%]
|
||||
}
|
||||
.openapi-disclosure-group-trigger[aria-expanded="false"] .openapi-response-tab-content {
|
||||
@apply basis-[60%];
|
||||
}
|
||||
|
||||
.openapi-response-body {
|
||||
@@ -454,6 +456,10 @@
|
||||
@apply flex items-center font-mono text-[0.813rem] gap-1 h-fit *:truncate overflow-x-auto min-w-0 max-w-full font-normal text-tint-strong;
|
||||
}
|
||||
|
||||
.openapi-codesample-header-content .openapi-path-title {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
.openapi-codesample-header-content .openapi-path .openapi-path-variable {
|
||||
@apply text-[0.813rem];
|
||||
}
|
||||
@@ -474,7 +480,7 @@
|
||||
}
|
||||
|
||||
.openapi-path-variable {
|
||||
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded text-sm leading-none before:content-none! after:!content-none;
|
||||
@apply p-px min-w-[1.625rem] text-tint-strong font-normal w-fit justify-center items-center ring-1 ring-inset ring-tint bg-tint rounded straight-corners:rounded-none circular-corners:rounded-md text-sm leading-none before:content-none! after:!content-none;
|
||||
}
|
||||
|
||||
.openapi-path-server {
|
||||
@@ -486,7 +492,7 @@
|
||||
}
|
||||
|
||||
.openapi-path-title {
|
||||
@apply flex-1 relative font-normal text-left overflow-x-auto font-mono text-tint-strong/10;
|
||||
@apply flex-1 relative font-normal items-center gap-y-1 flex flex-wrap text-left overflow-x-auto font-mono text-tint-strong/10;
|
||||
@apply whitespace-nowrap md:whitespace-normal;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
@@ -520,7 +526,7 @@
|
||||
.openapi-panel,
|
||||
.openapi-codesample,
|
||||
.openapi-response-examples {
|
||||
@apply border shrink min-h-40 overflow-hidden rounded-md straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
|
||||
@apply border shrink min-h-40 overflow-hidden rounded-lg straight-corners:rounded-none circular-corners:rounded-xl bg-tint-subtle border-tint-subtle shadow-sm;
|
||||
}
|
||||
|
||||
.openapi-response-examples-panel {
|
||||
@@ -597,8 +603,8 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-select > button {
|
||||
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded leading-none;
|
||||
@apply hover:bg-tint-hover transition-all;
|
||||
@apply flex items-center font-normal cursor-pointer *:truncate gap-1.5 p-1.5 border border-tint-subtle text-tint-strong rounded straight-corners:rounded-none circular-corners:rounded-md leading-none;
|
||||
@apply hover:bg-tint dark:hover:bg-tint-hover transition-all;
|
||||
}
|
||||
|
||||
.openapi-select:not(.openapi-select-unstyled) > button {
|
||||
@@ -606,7 +612,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-select-unstyled > button {
|
||||
@apply p-1;
|
||||
@apply p-1 *:truncate max-w-full;
|
||||
}
|
||||
|
||||
.openapi-select > button[data-focused="true"] {
|
||||
@@ -630,7 +636,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-select-popover {
|
||||
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
|
||||
@apply min-w-32 z-10 max-w-[max(20rem,var(--trigger-width))] overflow-x-hidden max-h-52 overflow-y-auto p-1.5 border border-tint-subtle bg-tint-base backdrop-blur-xl rounded-md straight-corners:rounded-none circular-corners:rounded-xl;
|
||||
@apply shadow-md shadow-tint-12/1 dark:shadow-tint-1/1;
|
||||
}
|
||||
|
||||
@@ -643,7 +649,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-select-item {
|
||||
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded !outline-none;
|
||||
@apply text-sm flex items-center cursor-pointer px-1.5 overflow-hidden py-1 text-tint ring-0 border-none rounded straight-corners:rounded-none circular-corners:rounded-md !outline-none;
|
||||
@apply hover:bg-tint-hover hover:theme-gradient:bg-tint-12/1 hover:text-tint-strong contrast-more:hover:ring-1 contrast-more:hover:ring-inset contrast-more:hover:ring-current;
|
||||
}
|
||||
|
||||
@@ -739,7 +745,7 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-tabs-tab {
|
||||
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded;
|
||||
@apply hover:bg-primary-hover whitespace-nowrap font-mono font-normal tabular-nums hover:text-primary cursor-pointer transition-all relative text-[0.813rem] text-tint px-1 border border-transparent rounded straight-corners:rounded-none circular-corners:rounded-md;
|
||||
}
|
||||
|
||||
.openapi-tabs-tab[aria-selected="true"] {
|
||||
@@ -810,12 +816,12 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-schemas-disclosure > .openapi-disclosure-trigger {
|
||||
@apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
|
||||
@apply flex items-center font-mono transition-all font-normal text-tint-strong !text-sm hover:bg-tint-subtle dark:hover:bg-tint-hover relative flex-1 gap-2.5 p-5 truncate -outline-offset-1;
|
||||
}
|
||||
|
||||
.openapi-schemas-disclosure > .openapi-disclosure-trigger,
|
||||
.openapi-schemas-disclosure .openapi-disclosure-panel {
|
||||
@apply straight-corners:!rounded-none;
|
||||
@apply straight-corners:!rounded-none circular-corners:!rounded-md;
|
||||
}
|
||||
|
||||
.openapi-disclosure-panel {
|
||||
@@ -843,7 +849,7 @@ body:has(.openapi-select-popover) {
|
||||
.openapi-schema-alternatives .openapi-disclosure,
|
||||
.openapi-schemas-disclosure .openapi-schema.openapi-disclosure
|
||||
) {
|
||||
@apply rounded-xl;
|
||||
@apply rounded-md circular-corners:rounded-xl straight-corners:rounded-none;
|
||||
}
|
||||
|
||||
.openapi-disclosure .openapi-schemas-disclosure .openapi-schema.openapi-disclosure {
|
||||
@@ -858,10 +864,10 @@ body:has(.openapi-select-popover) {
|
||||
@apply ring-1 shadow-sm;
|
||||
}
|
||||
|
||||
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:first-child) {
|
||||
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:first-child) {
|
||||
@apply mt-2;
|
||||
}
|
||||
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure):not(:last-child) {
|
||||
.openapi-disclosure[data-expanded="true"]:not(.openapi-schemas-disclosure,.openapi-required-scopes):not(:last-child) {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
@@ -993,12 +999,12 @@ body:has(.openapi-select-popover) {
|
||||
}
|
||||
|
||||
.openapi-path-copy-button {
|
||||
@apply p-1 flex rounded-md;
|
||||
@apply hover:bg-tint;
|
||||
@apply p-1 flex rounded-md straight-corners:rounded-none;
|
||||
@apply hover:bg-tint dark:hover:bg-tint-hover;
|
||||
}
|
||||
|
||||
.openapi-path-copy-button-icon {
|
||||
@apply size-6 flex opacity-0 transition-all;
|
||||
@apply size-6 opacity-0 transition-all hidden sm:flex;
|
||||
}
|
||||
|
||||
.openapi-path:hover .openapi-path-copy-button-icon {
|
||||
@@ -1007,4 +1013,27 @@ body:has(.openapi-select-popover) {
|
||||
|
||||
.openapi-path-copy-button-icon svg {
|
||||
@apply text-tint size-4;
|
||||
}
|
||||
|
||||
.openapi-required-scopes {
|
||||
@apply border text-base rounded-md straight-corners:rounded-none circular-corners:rounded-md font-medium mx-0;
|
||||
}
|
||||
|
||||
.openapi-required-scopes .openapi-required-scopes-header {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.openapi-required-scopes .openapi-required-scopes-header svg {
|
||||
@apply size-3.5 text-tint-subtle rotate-none;
|
||||
}
|
||||
|
||||
.openapi-required-scopes .openapi-disclosure-group-panel {
|
||||
@apply px-3 pb-3;
|
||||
}
|
||||
.openapi-required-scopes .openapi-securities-scopes {
|
||||
@apply ml-6 font-normal *:!text-[0.8125rem];
|
||||
}
|
||||
|
||||
.openapi-required-scopes .openapi-required-scopes-description {
|
||||
@apply text-xs !text-tint font-normal mb-2;
|
||||
}
|
||||
@@ -60,7 +60,7 @@ export async function RecordCard(
|
||||
'overflow-hidden',
|
||||
'[&_.heading>div:first-child]:hidden',
|
||||
'[&_.heading>div]:text-[.8em]',
|
||||
'md:[&_.heading>div]:text-[1em]',
|
||||
'@xl:[&_.heading>div]:text-[1em]',
|
||||
'[&_.blocks:first-child_.heading]:pt-0', // Remove padding-top on first heading in card
|
||||
|
||||
// On mobile, check if we can display the cover responsively or not:
|
||||
@@ -69,10 +69,10 @@ export async function RecordCard(
|
||||
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
|
||||
? [
|
||||
lightCoverIsSquareOrPortrait
|
||||
? 'grid-cols-[40%__1fr] min-[432px]:grid-cols-none min-[432px]:grid-rows-[auto_1fr]'
|
||||
? '@sm:grid-cols-none grid-cols-[40%__1fr] @sm:grid-rows-[auto_1fr]'
|
||||
: '',
|
||||
darkCoverIsSquareOrPortrait
|
||||
? 'dark:grid-cols-[40%__1fr] dark:min-[432px]:grid-cols-none dark:min-[432px]:grid-rows-[auto_1fr]'
|
||||
? 'dark:@sm:grid-cols-none dark:grid-cols-[40%__1fr] dark:@sm:grid-rows-[auto_1fr]'
|
||||
: '',
|
||||
].filter(Boolean)
|
||||
: 'grid-rows-[auto_1fr]'
|
||||
@@ -85,11 +85,13 @@ export async function RecordCard(
|
||||
light: {
|
||||
src: lightCover.href,
|
||||
size: lightCover.file?.dimensions,
|
||||
alt: light.alt,
|
||||
},
|
||||
dark: darkCover
|
||||
? {
|
||||
src: darkCover.href,
|
||||
size: darkCover.file?.dimensions,
|
||||
alt: dark.alt,
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
@@ -110,18 +112,15 @@ export async function RecordCard(
|
||||
'bg-tint-subtle',
|
||||
lightCoverIsSquareOrPortrait || darkCoverIsSquareOrPortrait
|
||||
? [
|
||||
lightCoverIsSquareOrPortrait
|
||||
? 'min-[432px]:aspect-video min-[432px]:h-auto'
|
||||
: '',
|
||||
lightCoverIsSquareOrPortrait ? '@sm:aspect-video @sm:h-auto' : '',
|
||||
darkCoverIsSquareOrPortrait
|
||||
? 'dark:min-[432px]:aspect-video dark:min-[432px]:h-auto'
|
||||
? 'dark:@sm:aspect-video dark:@sm:h-auto'
|
||||
: '',
|
||||
].filter(Boolean)
|
||||
: ['h-auto', 'aspect-video'],
|
||||
objectFits
|
||||
)}
|
||||
priority={isOffscreen ? 'lazy' : 'high'}
|
||||
preload
|
||||
loading={isOffscreen ? 'lazy' : 'eager'}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
|
||||
@@ -229,7 +229,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
},
|
||||
},
|
||||
}}
|
||||
priority="lazy"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<FileIcon
|
||||
@@ -404,7 +404,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
|
||||
size: image.file?.dimensions,
|
||||
},
|
||||
}}
|
||||
priority="lazy"
|
||||
loading="lazy"
|
||||
/>
|
||||
{image.text}
|
||||
</StyledLink>
|
||||
|
||||
@@ -15,8 +15,8 @@ export function ViewCards(props: TableViewProps<DocumentTableViewCards>) {
|
||||
'inline-grid',
|
||||
'gap-4',
|
||||
'grid-cols-1',
|
||||
'min-[432px]:grid-cols-2',
|
||||
view.cardSize === 'large' ? 'md:grid-cols-2' : 'md:grid-cols-3',
|
||||
'@sm:grid-cols-2',
|
||||
view.cardSize === 'large' ? '@xl:grid-cols-2' : '@xl:grid-cols-3',
|
||||
block.data.fullWidth ? 'large:flex-column' : null
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -21,6 +21,12 @@ export function getRecordValue<T extends number | string | boolean | string[] |
|
||||
return record.values[definitionId];
|
||||
}
|
||||
|
||||
type RecordCardCover = {
|
||||
contentRef: ContentRefFile | ContentRefURL | null;
|
||||
objectFit?: CardsImageObjectFit;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the covers for a record card.
|
||||
* Returns both the light and dark covers with their content refs and optional object fit.
|
||||
@@ -31,10 +37,7 @@ export function getRecordCardCovers(
|
||||
record: DocumentTableRecord,
|
||||
view: DocumentTableViewCards
|
||||
): {
|
||||
[key in 'light' | 'dark']: {
|
||||
contentRef: ContentRefFile | ContentRefURL | null;
|
||||
objectFit?: CardsImageObjectFit;
|
||||
};
|
||||
[key in 'light' | 'dark']: RecordCardCover;
|
||||
} {
|
||||
const lightValue = view.coverDefinition
|
||||
? (getRecordValue(record, view.coverDefinition) as DocumentTableImageRecord | string[])
|
||||
@@ -53,10 +56,9 @@ export function getRecordCardCovers(
|
||||
/**
|
||||
* Process a cover value and return the content ref and object fit.
|
||||
*/
|
||||
function processCoverValue(value: DocumentTableImageRecord | string[] | null | undefined): {
|
||||
contentRef: ContentRefFile | ContentRefURL | null;
|
||||
objectFit?: CardsImageObjectFit;
|
||||
} {
|
||||
function processCoverValue(
|
||||
value: DocumentTableImageRecord | string[] | null | undefined
|
||||
): RecordCardCover {
|
||||
if (!value) {
|
||||
return { contentRef: null };
|
||||
}
|
||||
@@ -78,6 +80,7 @@ function processCoverValue(value: DocumentTableImageRecord | string[] | null | u
|
||||
return {
|
||||
contentRef: imageValue.ref,
|
||||
objectFit: imageValue.objectFit,
|
||||
alt: imageValue.alt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo, useCallback, useMemo, type ComponentPropsWithRef } from 'react';
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithRef,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
NavigationStatusContext,
|
||||
useHash,
|
||||
useIsMounted,
|
||||
useListOverflow,
|
||||
} from '@/components/hooks';
|
||||
import { useHash, useListOverflow } from '@/components/hooks';
|
||||
import { DropdownMenu, DropdownMenuItem } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { tString } from '@/intl/translate';
|
||||
@@ -74,9 +76,9 @@ export function DynamicTabs(props: {
|
||||
}) {
|
||||
const { id, tabs, className } = props;
|
||||
const router = useRouter();
|
||||
const { onNavigationClick } = React.useContext(NavigationStatusContext);
|
||||
|
||||
const hash = useHash();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [tabsState, setTabsState] = useTabsState();
|
||||
const activeState = useMemo(() => {
|
||||
const input = { id, tabs };
|
||||
@@ -85,29 +87,33 @@ export function DynamicTabs(props: {
|
||||
);
|
||||
}, [id, tabs, tabsState]);
|
||||
|
||||
// Track if the tab has been touched by the user.
|
||||
const touchedRef = useRef(false);
|
||||
|
||||
// To avoid issue with hydration, we only use the state from localStorage
|
||||
// once the component has been mounted.
|
||||
// once the component has been initialized (=mounted).
|
||||
// Otherwise because of the streaming/suspense approach, tabs can be first-rendered at different time
|
||||
// and get stuck into an inconsistent state.
|
||||
const mounted = useIsMounted();
|
||||
const active = mounted ? activeState : tabs[0];
|
||||
const active = initialized ? activeState : tabs[0];
|
||||
|
||||
// When clicking to select a tab, we:
|
||||
// - update the URL hash
|
||||
// - mark this specific ID as selected
|
||||
// - store the ID to auto-select other tabs with the same title
|
||||
const selectTab = useCallback(
|
||||
(tabId: string) => {
|
||||
(tabId: string, manual = true) => {
|
||||
const tab = tabs.find((tab) => tab.id === tabId);
|
||||
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = `#${tab.id}`;
|
||||
if (window.location.hash !== href) {
|
||||
router.replace(href);
|
||||
onNavigationClick(href);
|
||||
if (manual) {
|
||||
touchedRef.current = true;
|
||||
const href = `#${tab.id}`;
|
||||
if (window.location.hash !== href) {
|
||||
router.replace(href, { scroll: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTabsState((prev) => {
|
||||
@@ -128,16 +134,18 @@ export function DynamicTabs(props: {
|
||||
};
|
||||
});
|
||||
},
|
||||
[onNavigationClick, router, setTabsState, tabs, id]
|
||||
[router, setTabsState, tabs, id]
|
||||
);
|
||||
|
||||
// When the hash changes, we try to select the tab containing the targetted element.
|
||||
React.useEffect(() => {
|
||||
React.useLayoutEffect(() => {
|
||||
setInitialized(true);
|
||||
|
||||
if (hash) {
|
||||
// First check if the hash matches a tab ID.
|
||||
const hashIsTab = tabs.some((tab) => tab.id === hash);
|
||||
if (hashIsTab) {
|
||||
selectTab(hash);
|
||||
selectTab(hash, false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,10 +160,39 @@ export function DynamicTabs(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
selectTab(tabPanel.id);
|
||||
selectTab(tabPanel.id, false);
|
||||
}
|
||||
}, [selectTab, tabs, hash]);
|
||||
|
||||
// Scroll to active element in the tab.
|
||||
React.useLayoutEffect(() => {
|
||||
// If there is no hash or active tab, nothing to scroll.
|
||||
if (!hash || !active) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the tab is touched, we don't want to scroll.
|
||||
if (touchedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the hash matches a tab, then the scroll is already done.
|
||||
const hashIsTab = tabs.some((tab) => tab.id === hash);
|
||||
if (hashIsTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.getElementById(hash);
|
||||
if (!activeElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeElement.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'instant',
|
||||
});
|
||||
}, [active, tabs, hash]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={tcls(
|
||||
@@ -184,9 +221,11 @@ const TabPanel = memo(function TabPanel(props: {
|
||||
role="tabpanel"
|
||||
id={tab.id}
|
||||
aria-labelledby={getTabButtonId(tab.id)}
|
||||
className={tcls('p-4', isActive ? null : 'hidden')}
|
||||
className="scroll-mt-[calc(var(--content-scroll-margin)+var(--spacing)*20)]"
|
||||
>
|
||||
{tab.body}
|
||||
<div className="p-4" hidden={!isActive}>
|
||||
{tab.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,19 +19,19 @@ export function getBlockTextStyle(block: DocumentBlock): {
|
||||
};
|
||||
case 'heading-1':
|
||||
return {
|
||||
textSize: 'text-3xl font-semibold',
|
||||
textSize: 'text-xl @xs:text-2xl @lg:text-3xl font-semibold',
|
||||
lineHeight: 'leading-tight',
|
||||
marginTop: 'column-first-of-type:pt-0 pt-[1em]',
|
||||
};
|
||||
case 'heading-2':
|
||||
return {
|
||||
textSize: 'text-2xl font-semibold',
|
||||
textSize: 'text-lg @xs:text-xl @lg:text-2xl font-semibold',
|
||||
lineHeight: 'leading-snug',
|
||||
marginTop: 'column-first-of-type:pt-0 pt-[0.75em]',
|
||||
};
|
||||
case 'heading-3':
|
||||
return {
|
||||
textSize: 'text-xl font-semibold',
|
||||
textSize: 'text-base @xs:text-lg @lg:text-xl font-semibold',
|
||||
lineHeight: 'leading-snug',
|
||||
marginTop: 'column-first-of-type:pt-0 pt-[0.5em]',
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export const EmbeddableFrame = React.forwardRef<HTMLDivElement, EmbeddableFrameP
|
||||
<div
|
||||
{...divProps}
|
||||
className={tcls(
|
||||
'flex h-full grow animate-fade-in-slow flex-col overflow-hidden bg-tint-base text-sm text-tint',
|
||||
'flex h-full grow flex-col overflow-hidden bg-tint-base text-sm text-tint',
|
||||
divProps.className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -29,112 +29,123 @@ export function Footer(props: { context: GitBookSiteContext }) {
|
||||
className={tcls(
|
||||
'border-tint-subtle border-t',
|
||||
// If the footer only contains a mode toggle, we only show it on smaller screens
|
||||
mobileOnly ? 'xl:hidden' : null
|
||||
mobileOnly ? '@7xl:hidden' : null
|
||||
)}
|
||||
>
|
||||
<div className={tcls(CONTAINER_STYLE, 'px-4', 'py-8', 'lg:py-12', 'mx-auto')}>
|
||||
<div className="motion-safe:transition-[padding] motion-safe:duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div
|
||||
className={tcls(
|
||||
'mx-auto grid max-w-3xl site-width-wide:max-w-screen-2xl justify-between gap-12 lg:max-w-none!',
|
||||
'grid-cols-[auto_auto]',
|
||||
'lg:grid-cols-[18rem_minmax(auto,48rem)_auto]',
|
||||
'xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
|
||||
'lg:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]',
|
||||
'xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]',
|
||||
'lg:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
|
||||
'xl:page-no-toc:grid-cols-[14rem_minmax(auto,48rem)_14rem]',
|
||||
'lg:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[minmax(auto,90rem)_auto]',
|
||||
'xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
|
||||
CONTAINER_STYLE,
|
||||
'px-4',
|
||||
'py-8',
|
||||
'@4xl:py-12',
|
||||
'mx-auto',
|
||||
'@container/footer'
|
||||
)}
|
||||
>
|
||||
{
|
||||
// Footer Logo
|
||||
customization.footer.logo ? (
|
||||
<div className="col-start-1 row-start-1">
|
||||
<Image
|
||||
alt="Logo"
|
||||
resize={context.imageResizer}
|
||||
sources={{
|
||||
light: {
|
||||
src: customization.footer.logo.light,
|
||||
},
|
||||
dark: customization.footer.logo.dark
|
||||
? {
|
||||
src: customization.footer.logo.dark,
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
priority="lazy"
|
||||
style={[
|
||||
'w-auto',
|
||||
'max-w-40',
|
||||
'lg:max-w-64',
|
||||
'max-h-10',
|
||||
'lg:max-h-12',
|
||||
'object-contain',
|
||||
'object-left',
|
||||
'rounded-sm',
|
||||
'straight-corners:rounded-xs',
|
||||
]}
|
||||
sizes={[
|
||||
{
|
||||
width: 320,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
{
|
||||
// Theme Toggle
|
||||
customization.themes.toggeable ? (
|
||||
<div className="-col-start-2 row-start-1 flex items-start justify-end xl:hidden">
|
||||
<React.Suspense fallback={null}>
|
||||
<ThemeToggler />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
{
|
||||
// Navigation groups (split into equal columns)
|
||||
customization.footer.groups?.length > 0 ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'col-span-2 lg:page-has-toc:col-span-1 lg:page-has-toc:col-start-2 xl:page-no-toc:col-span-1 xl:page-no-toc:col-start-2'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl flex-col gap-10 sm:flex-row sm:gap-6">
|
||||
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
|
||||
(column, columnIndex) => (
|
||||
<div
|
||||
key={columnIndex}
|
||||
className="flex flex-1 grow flex-col gap-10"
|
||||
>
|
||||
{column.map((group, groupIndex) => (
|
||||
<FooterLinksGroup
|
||||
key={groupIndex}
|
||||
group={group}
|
||||
context={context}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={tcls(
|
||||
'mx-auto flex @xs:grid @4xl:max-w-none! max-w-3xl site-width-wide:max-w-screen-2xl flex-col justify-between gap-12',
|
||||
'grid-cols-[auto_auto]',
|
||||
'@4xl:grid-cols-[18rem_minmax(auto,48rem)_auto]',
|
||||
'@7xl:grid-cols-[18rem_minmax(auto,48rem)_14rem]',
|
||||
'@4xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_auto]',
|
||||
'@7xl:site-width-wide:grid-cols-[18rem_minmax(auto,80rem)_14rem]',
|
||||
'@4xl:page-no-toc:grid-cols-[minmax(auto,48rem)_auto]',
|
||||
'@7xl:page-no-toc:grid-cols-[14rem_minmax(auto,48rem)_14rem]',
|
||||
'@4xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[minmax(auto,90rem)_auto]',
|
||||
'@7xl:[body:has(.site-width-wide,.page-no-toc)_&]:grid-cols-[14rem_minmax(auto,90rem)_14rem]'
|
||||
)}
|
||||
>
|
||||
{
|
||||
// Footer Logo
|
||||
customization.footer.logo ? (
|
||||
<div className="col-start-1 row-start-1">
|
||||
<Image
|
||||
alt="Logo"
|
||||
resize={context.imageResizer}
|
||||
sources={{
|
||||
light: {
|
||||
src: customization.footer.logo.light,
|
||||
},
|
||||
dark: customization.footer.logo.dark
|
||||
? {
|
||||
src: customization.footer.logo.dark,
|
||||
}
|
||||
: null,
|
||||
}}
|
||||
loading="lazy"
|
||||
style={[
|
||||
'w-auto',
|
||||
'max-w-40',
|
||||
'@4xl:max-w-64',
|
||||
'max-h-10',
|
||||
'@4xl:max-h-12',
|
||||
'object-contain',
|
||||
'object-left',
|
||||
'rounded-sm',
|
||||
'straight-corners:rounded-xs',
|
||||
]}
|
||||
sizes={[
|
||||
{
|
||||
width: 320,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
) : null
|
||||
}
|
||||
|
||||
{
|
||||
// Legal
|
||||
customization.footer.copyright ? (
|
||||
<div className="order-last col-span-full flex w-full grow flex-col items-center gap-2 text-center text-tint text-xs">
|
||||
<p>{customization.footer.copyright}</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
{
|
||||
// Theme Toggle
|
||||
customization.themes.toggeable ? (
|
||||
<div className="-col-start-2 row-start-1 flex items-start @xs:justify-end xl:hidden">
|
||||
<React.Suspense fallback={null}>
|
||||
<ThemeToggler />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
{
|
||||
// Navigation groups (split into equal columns)
|
||||
customization.footer.groups?.length > 0 ? (
|
||||
<div
|
||||
className={tcls(
|
||||
'@4xl:page-has-toc:col-span-1 @7xl:page-no-toc:col-span-1 col-span-2 @4xl:page-has-toc:col-start-2 @7xl:page-no-toc:col-start-2'
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl site-width-wide:max-w-screen-2xl @xl:flex-row flex-col @xl:gap-6 gap-10">
|
||||
{partition(customization.footer.groups, FOOTER_COLUMNS).map(
|
||||
(column, columnIndex) => (
|
||||
<div
|
||||
key={columnIndex}
|
||||
className="flex flex-1 grow flex-col gap-10"
|
||||
>
|
||||
{column.map((group, groupIndex) => (
|
||||
<FooterLinksGroup
|
||||
key={groupIndex}
|
||||
group={group}
|
||||
context={context}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
{
|
||||
// Legal
|
||||
customization.footer.copyright ? (
|
||||
<div className="order-last col-span-full flex w-full grow flex-col items-center gap-2 text-center text-tint text-xs">
|
||||
<p>{customization.footer.copyright}</p>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -51,6 +51,7 @@ export function CurrentContentIcon(
|
||||
},
|
||||
}
|
||||
}
|
||||
preload
|
||||
{...imageProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { GitBookSiteContext } from '@/lib/context';
|
||||
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
|
||||
import { getSpaceLanguage, t } from '@/intl/server';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import type { SiteSpace } from '@gitbook/api';
|
||||
import { SearchContainer } from '../Search';
|
||||
import { SiteSectionTabs, encodeClientSiteSections } from '../SiteSections';
|
||||
import { HeaderLink } from './HeaderLink';
|
||||
@@ -18,9 +19,12 @@ import { TranslationsDropdown } from './SpacesDropdown';
|
||||
export function Header(props: {
|
||||
context: GitBookSiteContext;
|
||||
withTopHeader?: boolean;
|
||||
withVariants?: 'generic' | 'translations';
|
||||
variants: {
|
||||
generic: SiteSpace[];
|
||||
translations: SiteSpace[];
|
||||
};
|
||||
}) {
|
||||
const { context, withTopHeader, withVariants } = props;
|
||||
const { context, withTopHeader, variants } = props;
|
||||
const { siteSpace, siteSpaces, sections, customization } = context;
|
||||
|
||||
const withSections = Boolean(
|
||||
@@ -63,7 +67,7 @@ export function Header(props: {
|
||||
'theme-bold:shadow-tint-12/2'
|
||||
)}
|
||||
>
|
||||
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div className="transition-all duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<div
|
||||
className={tcls(
|
||||
'gap-4',
|
||||
@@ -75,13 +79,19 @@ export function Header(props: {
|
||||
'py-3',
|
||||
'min-h-16',
|
||||
'sm:h-16',
|
||||
CONTAINER_STYLE
|
||||
CONTAINER_STYLE,
|
||||
'transition-[max-width] duration-300',
|
||||
'@container/header'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex max-w-full lg:basis-72',
|
||||
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4'
|
||||
'flex max-w-full',
|
||||
'min-w-0 shrink items-center justify-start gap-2 lg:gap-4',
|
||||
'search' in customization.styling &&
|
||||
customization.styling.search === 'prominent'
|
||||
? 'lg:@2xl:basis-72'
|
||||
: null
|
||||
)}
|
||||
>
|
||||
<HeaderMobileMenu
|
||||
@@ -91,8 +101,8 @@ export function Header(props: {
|
||||
'theme-bold:text-header-link',
|
||||
'hover:bg-tint-hover',
|
||||
'hover:theme-bold:bg-header-link/3',
|
||||
withVariants === 'generic'
|
||||
? 'xl:hidden'
|
||||
variants.generic.length > 1
|
||||
? 'lg:hidden'
|
||||
: 'page-no-toc:hidden lg:hidden'
|
||||
)}
|
||||
/>
|
||||
@@ -104,36 +114,37 @@ export function Header(props: {
|
||||
'flex',
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
'md:basis-56',
|
||||
'@2xl:basis-56',
|
||||
'justify-self-end',
|
||||
'items-center',
|
||||
'gap-2',
|
||||
'transition-[margin] duration-300',
|
||||
'search' in customization.styling &&
|
||||
customization.styling.search === 'prominent'
|
||||
? [
|
||||
'md:grow-[0.8]',
|
||||
'lg:basis-40',
|
||||
'md:max-w-[40%]',
|
||||
'lg:max-w-lg',
|
||||
'lg:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
|
||||
'xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
|
||||
'md:mr-auto',
|
||||
'@2xl:grow-[0.8]',
|
||||
'@4xl:basis-40',
|
||||
'@2xl:max-w-[40%]',
|
||||
'@4xl:max-w-lg',
|
||||
'lg:@2xl:ml-[max(calc((100%-18rem-48rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem)
|
||||
'not-chat-open:xl:ml-[max(calc((100%-18rem-48rem-14rem-3rem)/2),1.5rem)]', // container (100%) - sidebar (18rem) - content (48rem) - outline (14rem) - margin (3rem)
|
||||
'@2xl:mr-auto',
|
||||
'order-last',
|
||||
'md:order-[unset]',
|
||||
'@2xl:order-[unset]',
|
||||
]
|
||||
: ['order-last']
|
||||
)}
|
||||
>
|
||||
<SearchContainer
|
||||
style={customization.styling.search}
|
||||
withVariants={withVariants === 'generic'}
|
||||
withVariants={variants.generic.length > 1}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
s.object === 'site-section' && s.siteSpaces.length > 1
|
||||
) ?? false
|
||||
}
|
||||
withSections={!!sections}
|
||||
withSections={sections ? sections.list.length > 1 : false}
|
||||
section={
|
||||
sections
|
||||
? // Client-encode to avoid a serialisation issue that was causing the language selector to disappear
|
||||
@@ -150,7 +161,7 @@ export function Header(props: {
|
||||
</div>
|
||||
|
||||
{customization.header.links.length > 0 ||
|
||||
(!withSections && withVariants === 'translations') ? (
|
||||
(!withSections && variants.translations.length > 1) ? (
|
||||
<HeaderLinks>
|
||||
{customization.header.links.length > 0 ? (
|
||||
<>
|
||||
@@ -170,11 +181,15 @@ export function Header(props: {
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{!withSections && withVariants === 'translations' ? (
|
||||
{!withSections && variants.translations.length > 1 ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
siteSpace={
|
||||
variants.translations.find(
|
||||
(space) => space.id === siteSpace.id
|
||||
) ?? siteSpace
|
||||
}
|
||||
siteSpaces={variants.translations}
|
||||
className="flex! theme-bold:text-header-link hover:theme-bold:bg-header-link/3"
|
||||
/>
|
||||
) : null}
|
||||
@@ -187,14 +202,17 @@ export function Header(props: {
|
||||
{sections && withSections ? (
|
||||
<div className="transition-[padding] duration-300 lg:chat-open:pr-80 xl:chat-open:pr-96">
|
||||
<SiteSectionTabs sections={encodeClientSiteSections(context, sections)}>
|
||||
{withVariants === 'translations' ? (
|
||||
<div className="before:contents[] flex self-start py-2 before:mr-4 before:border-tint before:border-l">
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
/>
|
||||
</div>
|
||||
{variants.translations.length > 1 ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={
|
||||
variants.translations.find(
|
||||
(space) => space.id === siteSpace.id
|
||||
) ?? siteSpace
|
||||
}
|
||||
siteSpaces={variants.translations}
|
||||
className="my-2 ml-2 self-start"
|
||||
/>
|
||||
) : null}
|
||||
</SiteSectionTabs>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function HeaderLinks({ children, style }: HeaderLinksProps) {
|
||||
<div
|
||||
className={tcls(
|
||||
styles.containerHeaderlinks,
|
||||
'lg:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow items-center justify-end gap-x-4 lg:gap-x-6 xl:grow-0',
|
||||
'@4xl:[&>.button+.button]:-ml-2 z-20 ml-auto flex min-w-9 shrink grow @7xl:grow-0 items-center justify-end @4xl:gap-x-6 gap-x-4',
|
||||
style
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function HeaderLogo(props: HeaderLogoProps) {
|
||||
width: 260,
|
||||
},
|
||||
]}
|
||||
priority="high"
|
||||
preload
|
||||
style={tcls(
|
||||
'overflow-hidden',
|
||||
'shrink',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@media (max-width: 1279px) {
|
||||
@container header (width < 1280px) {
|
||||
.containerHeaderlinks {
|
||||
container-type: inline-size;
|
||||
container-name: headerlinks;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import assertNever from 'assert-never';
|
||||
import QuickLRU from 'quick-lru';
|
||||
import React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { createStore, useStore } from 'zustand';
|
||||
|
||||
type PageActionType = 'button' | 'dropdown-menu-item';
|
||||
|
||||
@@ -41,18 +41,13 @@ export function ActionOpenAssistant(props: { assistant: Assistant; type: PageAct
|
||||
type CopiedStore = {
|
||||
copied: boolean;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
copy: (data: string, opts?: { onSuccess?: () => void }) => void;
|
||||
};
|
||||
|
||||
// We need to store everything in a store to share the state between every instance of the component.
|
||||
const useCopiedStore = create<
|
||||
CopiedStore & {
|
||||
setLoading: (loading: boolean) => void;
|
||||
copy: (data: string, opts?: { onSuccess?: () => void }) => void;
|
||||
}
|
||||
>((set) => {
|
||||
const createCopiedStateStore = () => {
|
||||
let timeoutRef: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return {
|
||||
return createStore<CopiedStore>()((set) => ({
|
||||
copied: false,
|
||||
loading: false,
|
||||
setLoading: (loading: boolean) => set({ loading }),
|
||||
@@ -70,13 +65,25 @@ const useCopiedStore = create<
|
||||
timeoutRef = setTimeout(() => {
|
||||
set({ copied: false });
|
||||
onSuccess?.();
|
||||
|
||||
// Reset the timeout ref to avoid multiple timeouts
|
||||
timeoutRef = null;
|
||||
}, 1500);
|
||||
},
|
||||
};
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
const copiedStores = new Map<string, ReturnType<typeof createCopiedStateStore>>();
|
||||
|
||||
const getOrCreateCopiedStoreByKey = (storeKey: string) => {
|
||||
const existing = copiedStores.get(storeKey);
|
||||
if (existing) return existing;
|
||||
const created = createCopiedStateStore();
|
||||
copiedStores.set(storeKey, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
function useCopiedStore(stateKey: string) {
|
||||
return useStore(getOrCreateCopiedStoreByKey(stateKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for the markdown versbion of the page.
|
||||
@@ -96,7 +103,7 @@ export function ActionCopyMarkdown(props: {
|
||||
|
||||
const closeDropdown = useDropdownMenuClose();
|
||||
|
||||
const { copied, loading, setLoading, copy } = useCopiedStore();
|
||||
const { copied, loading, setLoading, copy } = useCopiedStore('markdown');
|
||||
|
||||
// Fetch the markdown from the page
|
||||
const fetchMarkdown = async () => {
|
||||
@@ -293,14 +300,29 @@ export function CopyToClipboard(props: {
|
||||
icon: IconName;
|
||||
}) {
|
||||
const { type, data, label, description, icon } = props;
|
||||
|
||||
const closeDropdown = useDropdownMenuClose();
|
||||
|
||||
const language = useLanguage();
|
||||
const labelKey = label.toLowerCase().replace(/\s+/g, '_');
|
||||
const { copied, copy } = useCopiedStore(labelKey);
|
||||
|
||||
return (
|
||||
<PageActionWrapper
|
||||
type={type}
|
||||
icon={icon}
|
||||
label={label}
|
||||
icon={copied ? 'check' : icon}
|
||||
label={copied ? tString(language, 'code_copied') : label}
|
||||
description={description}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(data);
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
copy(data, {
|
||||
onSuccess: () => {
|
||||
if (type === 'dropdown-menu-item') {
|
||||
closeDropdown();
|
||||
}
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -37,24 +37,32 @@ export function PageAside(props: {
|
||||
'group/aside',
|
||||
'order-last',
|
||||
'hidden',
|
||||
'max-w-0',
|
||||
'pt-8',
|
||||
'pb-4',
|
||||
'opacity-0',
|
||||
|
||||
'xl:flex',
|
||||
'xl:max-3xl:chat-open:hidden',
|
||||
'xl:max-3xl:chat-open:opacity-0',
|
||||
'max-w-56',
|
||||
|
||||
// Animate the width of the aside when the chat is open
|
||||
'xl:max-3xl:*:chat-open:w-56',
|
||||
'overflow-hidden',
|
||||
|
||||
'xl:max-w-56',
|
||||
'xl:opacity-11',
|
||||
'xl:ml-12',
|
||||
|
||||
'xl:max-3xl:chat-open:hidden',
|
||||
'xl:max-3xl:chat-open:max-w-0',
|
||||
'xl:max-3xl:chat-open:opacity-0',
|
||||
'xl:max-3xl:chat-open:ml-0',
|
||||
|
||||
'motion-safe:xl:transition-[width,max-width,margin,opacity,display] motion-safe:xl:duration-300',
|
||||
'motion-safe:transition-discrete',
|
||||
'hydrated:starting:ml-0',
|
||||
'hydrated:starting:max-w-0',
|
||||
'hydrated:starting:opacity-0',
|
||||
|
||||
'transition-[margin,max-width,opacity,display] duration-300',
|
||||
'transition-discrete',
|
||||
|
||||
'basis-56',
|
||||
'xl:ml-12',
|
||||
'grow-0',
|
||||
'shrink-0',
|
||||
'break-anywhere', // To prevent long words in headings from breaking the layout
|
||||
@@ -93,6 +101,7 @@ export function PageAside(props: {
|
||||
<div
|
||||
className={tcls(
|
||||
'flex flex-col',
|
||||
'min-w-56 shrink-0',
|
||||
'overflow-hidden',
|
||||
'w-full',
|
||||
'xl:max-2xl:rounded-corners:page-api-block:rounded-md',
|
||||
|
||||
@@ -46,6 +46,11 @@ export function PageBody(props: {
|
||||
const language = getSpaceLanguage(context);
|
||||
const updatedAt = page.updatedAt ?? page.createdAt;
|
||||
|
||||
const hasVisibleTOCItems =
|
||||
context.revision.pages.filter(
|
||||
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
|
||||
).length > 0;
|
||||
|
||||
return (
|
||||
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
|
||||
<main
|
||||
@@ -54,9 +59,12 @@ export function PageBody(props: {
|
||||
'max-w-screen-2xl py-8',
|
||||
// Allow words to break if they are too long.
|
||||
'break-anywhere',
|
||||
'@container',
|
||||
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
|
||||
siteWidthWide ? 'site-width-wide' : 'site-width-default',
|
||||
page.layout.tableOfContents ? 'page-has-toc' : 'page-no-toc'
|
||||
page.layout.tableOfContents && hasVisibleTOCItems
|
||||
? 'page-has-toc'
|
||||
: 'page-no-toc'
|
||||
)}
|
||||
>
|
||||
<PreservePageLayout siteWidthWide={siteWidthWide} />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { assert } from 'ts-essentials';
|
||||
import { PageCoverImage } from './PageCoverImage';
|
||||
import { getCoverHeight } from './coverHeight';
|
||||
import defaultPageCoverSVG from './default-page-cover.svg';
|
||||
|
||||
const defaultPageCover = defaultPageCoverSVG as StaticImageData;
|
||||
@@ -22,6 +23,8 @@ export async function PageCover(props: {
|
||||
context: GitBookSiteContext;
|
||||
}) {
|
||||
const { as, page, cover, context } = props;
|
||||
const height = getCoverHeight(cover);
|
||||
|
||||
const [resolved, resolvedDark] = await Promise.all([
|
||||
cover.ref ? resolveContentRef(cover.ref, context) : null,
|
||||
cover.refDark ? resolveContentRef(cover.refDark, context) : null,
|
||||
@@ -108,6 +111,7 @@ export async function PageCover(props: {
|
||||
dark,
|
||||
}}
|
||||
y={cover.yPos}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { useRef } from 'react';
|
||||
import { useResizeObserver } from 'usehooks-ts';
|
||||
import type { ImageSize } from '../utils';
|
||||
import { useCoverPosition } from './useCoverPosition';
|
||||
|
||||
interface ImageAttributes {
|
||||
src: string;
|
||||
@@ -20,29 +19,27 @@ interface Images {
|
||||
|
||||
const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 };
|
||||
|
||||
function getTop(container: { height?: number; width?: number }, y: number, img: ImageAttributes) {
|
||||
// When the size of the image hasn't been determined, we fallback to the center position
|
||||
if (!img.size || y === 0) return '50%';
|
||||
const ratio =
|
||||
container.height && container.width
|
||||
? Math.max(container.width / img.size.width, container.height / img.size.height)
|
||||
: 1;
|
||||
const scaledHeight = img.size ? img.size.height * ratio : PAGE_COVER_SIZE.height;
|
||||
const top =
|
||||
container.height && img.size ? (container.height - scaledHeight) / 2 + y * ratio : y;
|
||||
return `${top}px`;
|
||||
interface PageCoverImageProps {
|
||||
imgs: Images;
|
||||
y: number;
|
||||
// Only if the `height` was customized by the user (and thus defined), we use it to set the cover's height and skip the default behaviour of fixed aspect-ratio.
|
||||
height: number | undefined;
|
||||
}
|
||||
|
||||
export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
export function PageCoverImage(props: PageCoverImageProps) {
|
||||
const { imgs, y, height } = props;
|
||||
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
|
||||
|
||||
const container = useResizeObserver({
|
||||
// @ts-expect-error wrong types
|
||||
ref: containerRef,
|
||||
});
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef}>
|
||||
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
|
||||
<img
|
||||
src={imgs.light.src}
|
||||
srcSet={imgs.light.srcSet}
|
||||
@@ -51,8 +48,11 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
|
||||
style={{
|
||||
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${getTop(container, y, imgs.light)}`,
|
||||
aspectRatio: height
|
||||
? undefined
|
||||
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
height, // if no height is passed, no height will be set.
|
||||
}}
|
||||
/>
|
||||
{imgs.dark && (
|
||||
@@ -64,8 +64,11 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
|
||||
alt="Page cover"
|
||||
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
|
||||
style={{
|
||||
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${getTop(container, y, imgs.dark)}`,
|
||||
aspectRatio: height
|
||||
? undefined
|
||||
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
|
||||
objectPosition: `50% ${objectPositionY}%`,
|
||||
height, // if no height is passed, no height will be set.
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -100,11 +100,14 @@ export async function PageHeader(props: {
|
||||
{page.layout.title ? (
|
||||
<h1
|
||||
className={tcls(
|
||||
'text-4xl',
|
||||
'text-2xl',
|
||||
'@xs:text-3xl',
|
||||
'@lg:text-4xl',
|
||||
'leading-tight',
|
||||
'font-bold',
|
||||
'flex',
|
||||
'items-center',
|
||||
'gap-4',
|
||||
'gap-[.5em]',
|
||||
'grow',
|
||||
'text-pretty',
|
||||
'clear-right',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { RevisionPageDocumentCover } from '@gitbook/api';
|
||||
|
||||
export const DEFAULT_COVER_HEIGHT = 240;
|
||||
export const MIN_COVER_HEIGHT = 10;
|
||||
export const MAX_COVER_HEIGHT = 700;
|
||||
|
||||
// Normalize and clamp the cover height between the minimum and maximum heights
|
||||
function clampCoverHeight(height: number | null | undefined): number {
|
||||
if (typeof height !== 'number' || Number.isNaN(height)) {
|
||||
return DEFAULT_COVER_HEIGHT;
|
||||
}
|
||||
|
||||
return Math.min(MAX_COVER_HEIGHT, Math.max(MIN_COVER_HEIGHT, height));
|
||||
}
|
||||
|
||||
// When a user set a custom cover height, we return the clamped cover height. If no height is set, we want to preserve the existing logic for sizing of the cover image and return `undefined` for height.
|
||||
export function getCoverHeight(
|
||||
cover: RevisionPageDocumentCover | null | undefined
|
||||
): number | undefined {
|
||||
// Cover (and thus height) is not defined
|
||||
if (!cover || !cover.height) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return clampCoverHeight((cover as RevisionPageDocumentCover).height ?? DEFAULT_COVER_HEIGHT);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './PageBody';
|
||||
export * from './PageCover';
|
||||
export * from './useCoverPosition';
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useResizeObserver } from 'usehooks-ts';
|
||||
|
||||
interface ImageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ImageAttributes {
|
||||
src: string;
|
||||
srcSet?: string;
|
||||
sizes?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size?: ImageSize;
|
||||
}
|
||||
|
||||
interface Images {
|
||||
light: ImageAttributes;
|
||||
dark?: ImageAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to calculate the object position Y percentage for a cover image
|
||||
* based on the y offset, image dimensions, and container dimensions.
|
||||
*/
|
||||
export function useCoverPosition(imgs: Images, y: number) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
|
||||
|
||||
const container = useResizeObserver({
|
||||
// @ts-expect-error wrong types
|
||||
ref: containerRef,
|
||||
});
|
||||
|
||||
// Load original image dimensions if not provided in `imgs`
|
||||
useLayoutEffect(() => {
|
||||
// Check if we have dimensions from dark (if provided) or else the default light.
|
||||
const hasDimensions = imgs.dark?.size || imgs.light.size;
|
||||
|
||||
if (hasDimensions) {
|
||||
return; // Already have dimensions
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Load the original image (using src, not srcSet) to get true dimensions
|
||||
// Use dark image if available, otherwise fall back to light
|
||||
const imageToLoad = imgs.dark || imgs.light;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setLoadedDimensions({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
setIsLoading(false);
|
||||
};
|
||||
img.onerror = () => {
|
||||
// If image fails to load, use a fallback
|
||||
setIsLoading(false);
|
||||
};
|
||||
img.src = imageToLoad.src;
|
||||
}, [imgs.light, imgs.dark]);
|
||||
|
||||
// Use provided dimensions or fall back to loaded dimensions
|
||||
// Check dark first, then light, then loaded dimensions
|
||||
const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions;
|
||||
|
||||
// Calculate ratio and dimensions similar to useCoverPosition hook
|
||||
const ratio =
|
||||
imageDimensions && container.height && container.width
|
||||
? Math.max(
|
||||
container.width / imageDimensions.width,
|
||||
container.height / imageDimensions.height
|
||||
)
|
||||
: 1;
|
||||
const safeRatio = ratio || 1;
|
||||
|
||||
const scaledHeight =
|
||||
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
|
||||
const maxOffset =
|
||||
scaledHeight && container.height
|
||||
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
|
||||
: 0;
|
||||
|
||||
// Parse the position between the allowed min/max
|
||||
const objectPositionY = useMemo(() => {
|
||||
if (!container.height || !imageDimensions) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
const scaled = imageDimensions.height * safeRatio;
|
||||
if (scaled <= container.height || maxOffset === 0) {
|
||||
return 50;
|
||||
}
|
||||
|
||||
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
|
||||
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
|
||||
return relative * 100;
|
||||
}, [container.height, imageDimensions, maxOffset, safeRatio, y]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
objectPositionY,
|
||||
isLoading: !imageDimensions || isLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t, tString } from '@/intl/translate';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { readStreamableValue } from 'ai/rsc';
|
||||
import React from 'react';
|
||||
|
||||
import { Loading } from '@/components/primitives';
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { t } from '@/intl/translate';
|
||||
import type { TranslationLanguage } from '@/intl/translations';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { AIResponseFeedback } from '../AIChat';
|
||||
import { AIResponseFeedback, AISearchIcon } from '../AIChat';
|
||||
import { HoldMessage } from '../AIChat/AIChatMessages';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { Link } from '../primitives';
|
||||
import { Button, Link } from '../primitives';
|
||||
import { useSearchAskContext } from './SearchAskContext';
|
||||
import { type AskAnswerResult, type AskAnswerSource, streamAskQuestion } from './server-actions';
|
||||
import { useSearchLink } from './useSearch';
|
||||
import { useSearch, useSearchLink } from './useSearch';
|
||||
|
||||
export type SearchAskState =
|
||||
| {
|
||||
@@ -84,8 +83,11 @@ export function SearchAskAnswer(props: { query: string }) {
|
||||
}, [setAskState]);
|
||||
|
||||
const loading = (
|
||||
<div className="flex grow items-center justify-center">
|
||||
<Loading className={tcls('size-6', 'text-tint/6')} />
|
||||
<div className="flex grow flex-col items-center justify-center">
|
||||
<div className="relative">
|
||||
<AISearchIcon state="thinking" className="size-6 text-tint/6" />
|
||||
</div>
|
||||
<HoldMessage breakLines={true} className="text-center" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -129,8 +131,29 @@ function TransitionAnswerBody(props: {
|
||||
});
|
||||
}, [answer]);
|
||||
|
||||
const language = useLanguage();
|
||||
const [, setSearchState] = useSearch();
|
||||
|
||||
return display ? (
|
||||
<div className={tcls('w-full')}>
|
||||
<div className={tcls('flex w-full flex-col gap-4')}>
|
||||
<div className="-mt-2 -mr-2 relative flex items-center gap-2">
|
||||
<Button
|
||||
icon="chevron-left"
|
||||
variant="blank"
|
||||
iconOnly
|
||||
label={tString(language, 'search_back')}
|
||||
className="-ml-2 starting:-mr-2 starting:ml-0 starting:w-0 starting:px-0 starting:opacity-0 transition-[all,margin,padding,width,opacity] transition-discrete delay-[0s,2s,2s,2s,2.5s] duration-[.3s,1s,1s,1s,.5s]"
|
||||
onClick={() => {
|
||||
setSearchState((prev) =>
|
||||
prev ? { ...prev, query: prev.ask, ask: null } : null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<AISearchIcon state="intro" className="size-4 text-tint/6" />
|
||||
<span className="animate-[fadeIn_1s_.5s_both] font-semibold text-tint">
|
||||
{t(language, 'ai_answer')}
|
||||
</span>
|
||||
</div>
|
||||
<AnswerBody query={query} answer={display} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -144,7 +167,7 @@ function AnswerBody(props: { query: string; answer: AskAnswerResult }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-testid="search-ask-answer" className="text-tint-strong">
|
||||
<div data-testid="search-ask-answer" className="animate-fade-in-slow text-tint-strong">
|
||||
{answer.body ?? t(language, 'search_ask_no_answer')}
|
||||
{answer.sources.length > 0 ? (
|
||||
// @TODO: Add responseId once search uses new AI endpoint
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useSearchAskState } from './SearchAskContext';
|
||||
import { SearchAskProvider } from './SearchAskContext';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchResults, type SearchResultsRef } from './SearchResults';
|
||||
import { SearchScopeToggle } from './SearchScopeToggle';
|
||||
import { SearchScopeControl } from './SearchScopeControl';
|
||||
import { useSearch } from './useSearch';
|
||||
import { useSearchResults } from './useSearchResults';
|
||||
import { useSearchResultsCursor } from './useSearchResultsCursor';
|
||||
@@ -215,27 +215,31 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
// Only show content if there's a query or Ask is enabled
|
||||
state?.query || withAI ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<div className="scroll-py-2 overflow-y-scroll p-2">
|
||||
{state !== null && !showAsk ? (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
query={normalizedQuery}
|
||||
id={searchResultsId}
|
||||
fetching={fetching}
|
||||
results={results}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
/>
|
||||
) : null}
|
||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
||||
</div>
|
||||
{(withVariants || withSections) && !showAsk ? (
|
||||
<SearchScopeToggle
|
||||
section={section}
|
||||
spaceTitle={spaceTitle}
|
||||
withVariants={withVariants}
|
||||
withSiteVariants={withSiteVariants}
|
||||
withSections={withSections}
|
||||
/>
|
||||
<div className="border-tint-subtle border-t bg-tint-subtle px-4 py-1.5">
|
||||
<SearchScopeControl
|
||||
section={section}
|
||||
spaceTitle={spaceTitle}
|
||||
withVariants={withVariants}
|
||||
withSiteVariants={withSiteVariants}
|
||||
withSections={withSections}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{state !== null && !showAsk ? (
|
||||
<SearchResults
|
||||
ref={resultsRef}
|
||||
query={normalizedQuery}
|
||||
id={searchResultsId}
|
||||
fetching={fetching}
|
||||
results={results}
|
||||
cursor={cursor}
|
||||
error={error}
|
||||
/>
|
||||
) : null}
|
||||
{showAsk ? <SearchAskAnswer query={normalizedAsk} /> : null}
|
||||
</React.Suspense>
|
||||
) : null
|
||||
}
|
||||
@@ -247,7 +251,7 @@ export function SearchContainer(props: SearchContainerProps) {
|
||||
onOpenAutoFocus: (event) => event.preventDefault(),
|
||||
align: 'start',
|
||||
className:
|
||||
'@container overflow-y-scroll bg-tint-base has-[.empty]:hidden scroll-py-2 w-128 p-2 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
'@container flex flex-col bg-tint-base has-[.empty]:hidden w-128 p-0 max-h-[min(32rem,var(--radix-popover-content-available-height))] max-w-[min(var(--radix-popover-content-available-width),32rem)]',
|
||||
onInteractOutside: (event) => {
|
||||
// Don't close if clicking on the search input itself
|
||||
if (searchInputRef.current?.contains(event.target as Node)) {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { Button, variantClasses } from '../primitives';
|
||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||
import { useClassnames } from '../primitives/StyleProvider';
|
||||
|
||||
interface SearchInputProps {
|
||||
@@ -20,7 +21,7 @@ interface SearchInputProps {
|
||||
}
|
||||
|
||||
// Size classes for medium size button
|
||||
const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', 'md:circular-corners:px-4'];
|
||||
const sizeClasses = ['text-sm', 'px-3.5', 'py-1.5', '@2xl:circular-corners:px-4'];
|
||||
|
||||
/**
|
||||
* Input to trigger search.
|
||||
@@ -69,21 +70,21 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
variantClasses.header,
|
||||
sizeClasses,
|
||||
// Additional custom styles
|
||||
'has-[input:focus]:-translate-y-px h-9 grow cursor-pointer px-2.5 has-[input:focus]:bg-tint-base has-[input:focus]:depth-subtle:shadow-lg has-[input:focus]:depth-subtle:shadow-primary-subtle has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-primary-hover md:cursor-text',
|
||||
'has-[input:focus]:-translate-y-px h-9 grow @2xl:cursor-text cursor-pointer px-2.5 has-[input:focus]:bg-tint-base has-[input:focus]:depth-subtle:shadow-lg has-[input:focus]:depth-subtle:shadow-primary-subtle has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-primary-hover',
|
||||
'theme-bold:border-header-link/3 has-[input:focus-visible]:theme-bold:border-header-link/5 has-[input:focus-visible]:theme-bold:bg-header-link/3 has-[input:focus-visible]:theme-bold:ring-header-link/5',
|
||||
'theme-bold:before:absolute theme-bold:before:inset-0 theme-bold:before:bg-header-background/7 theme-bold:before:backdrop-blur-xl ', // Special overlay to make the transparent colors of theme-bold visible.
|
||||
'relative z-30 max-w-none shrink grow justify-start max-md:absolute max-md:right-0',
|
||||
isOpen ? 'max-md:w-56' : 'max-md:w-[38px]'
|
||||
'@max-2xl:absolute relative @max-2xl:right-0 z-30 max-w-none shrink grow justify-start',
|
||||
isOpen ? '@max-2xl:w-56' : '@max-2xl:w-[38px]'
|
||||
)}
|
||||
>
|
||||
{value && isOpen ? (
|
||||
<Button
|
||||
variant="blank"
|
||||
label="Clear"
|
||||
label={tString(language, 'clear')}
|
||||
size="medium"
|
||||
iconOnly
|
||||
icon="circle-xmark"
|
||||
className="-ml-1.5 -mr-1 animate-scale-in px-1.5"
|
||||
className="-ml-1.5 -mr-1 animate-scale-in px-1.5 theme-bold:text-header-link theme-bold:hover:bg-header-link/3"
|
||||
onClick={() => {
|
||||
onChange('');
|
||||
inputRef.current?.focus();
|
||||
@@ -110,7 +111,7 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
data-testid="search-input"
|
||||
className={tcls(
|
||||
'peer z-10 min-w-0 grow bg-transparent py-0.5 text-tint-strong theme-bold:text-header-link outline-hidden transition-[width] duration-300 contain-paint placeholder:text-tint theme-bold:placeholder:text-current theme-bold:placeholder:opacity-7',
|
||||
isOpen ? '' : 'max-md:opacity-0'
|
||||
isOpen ? '' : '@max-2xl:opacity-0'
|
||||
)}
|
||||
role="combobox"
|
||||
autoComplete="off"
|
||||
@@ -120,47 +121,12 @@ export const SearchInput = React.forwardRef<HTMLDivElement, SearchInputProps>(
|
||||
// Forward
|
||||
ref={inputRef}
|
||||
/>
|
||||
{!isOpen ? <Shortcut /> : null}
|
||||
<KeyboardShortcut
|
||||
keys={isOpen ? ['esc'] : ['mod', 'k']}
|
||||
className="last:-mr-1 theme-bold:border-header-link/5 theme-bold:bg-header-background theme-bold:text-header-link"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function getOperatingSystem() {
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
|
||||
if (platform.includes('mac')) return 'mac';
|
||||
if (platform.includes('win')) return 'win';
|
||||
|
||||
return 'win';
|
||||
}
|
||||
|
||||
function Shortcut() {
|
||||
const [operatingSystem, setOperatingSystem] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setOperatingSystem(getOperatingSystem());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-busy={operatingSystem === null ? 'true' : undefined}
|
||||
className={tcls(
|
||||
`shortcut -mr-1 relative z-10 hidden justify-end gap-0.5 whitespace-nowrap text-xs [font-feature-settings:"calt","case"] after:absolute after:right-full after:z-20 after:h-full after:w-8 after:bg-linear-to-r after:from-transparent after:to-tint-base theme-bold:after:to-transparent after:content-[''] contrast-more:text-tint-strong md:flex`,
|
||||
operatingSystem
|
||||
? 'motion-safe:animate-fade-in motion-reduce:opacity-11'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
>
|
||||
<kbd
|
||||
className={`flex h-5 min-w-5 items-center justify-center rounded-sm border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1 ${operatingSystem === 'mac' ? 'text-sm' : ''}`}
|
||||
>
|
||||
{operatingSystem === 'mac' ? '⌘' : 'Ctrl'}
|
||||
</kbd>
|
||||
<kbd className="flex size-5 items-center justify-center rounded-sm border border-tint-subtle theme-bold:border-header-link/5 bg-tint-base theme-bold:bg-header-background px-1">
|
||||
K
|
||||
</kbd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,13 +54,13 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
|
||||
'transition-colors',
|
||||
'font-normal',
|
||||
'uppercase',
|
||||
'leading-none',
|
||||
'leading-snug',
|
||||
'tracking-wider',
|
||||
'mb-1',
|
||||
'flex',
|
||||
'flex-wrap',
|
||||
'gap-x-2',
|
||||
'gap-y-1',
|
||||
'gap-y-0',
|
||||
'items-center'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { t, useLanguage } from '@/intl/client';
|
||||
import type { SiteSection } from '@gitbook/api';
|
||||
import { Button, DropdownChevron, DropdownMenu, DropdownMenuItem } from '../primitives';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
interface SearchScopeControlProps {
|
||||
spaceTitle: string;
|
||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||
withVariants: boolean;
|
||||
withSiteVariants: boolean;
|
||||
withSections: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar to toggle between search modes (global or scoped to a space).
|
||||
* Only visible when the space is in a collection.
|
||||
*/
|
||||
export function SearchScopeControl(props: SearchScopeControlProps) {
|
||||
const { withVariants, withSections } = props;
|
||||
|
||||
const [state] = useSearch();
|
||||
const language = useLanguage();
|
||||
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Whether to include all variants in the search
|
||||
const sectionScopeIsExtended = ['default', 'all'].includes(state.scope);
|
||||
const variantScopeIsExtended = ['extended', 'all'].includes(state.scope);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="mr-1">{t(language, 'search_scope_title')}</span>
|
||||
{withSections ? (
|
||||
<SearchScopeSectionControl isExtended={sectionScopeIsExtended} {...props} />
|
||||
) : null}
|
||||
|
||||
{withVariants && (!withSections || !sectionScopeIsExtended) ? (
|
||||
<SearchScopeVariantControl isExtended={variantScopeIsExtended} {...props} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchScopeSectionControl(props: SearchScopeControlProps & { isExtended: boolean }) {
|
||||
const { isExtended, section } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const [, setSearchState] = useSearch();
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
button={
|
||||
<Button
|
||||
variant="blank"
|
||||
size="medium"
|
||||
className="px-2 text-tint-strong"
|
||||
icon={isExtended ? undefined : section?.icon}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{t(
|
||||
language,
|
||||
isExtended
|
||||
? 'search_scope_section_all'
|
||||
: 'search_scope_section_current',
|
||||
section?.title ?? ''
|
||||
)}
|
||||
<DropdownChevron />
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
leadingIcon="infinity"
|
||||
className="gap-3"
|
||||
active={isExtended}
|
||||
onClick={() =>
|
||||
setSearchState((prev) => (prev ? { ...prev, scope: 'default' } : null))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-tint-strong">
|
||||
{t(language, 'search_scope_section_all')}
|
||||
</span>
|
||||
<span className="text-tint-subtle text-xs">
|
||||
{t(language, 'search_scope_section_all_description')}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
leadingIcon={section?.icon ?? 'crosshairs'}
|
||||
className="gap-3"
|
||||
active={!isExtended}
|
||||
onClick={() =>
|
||||
setSearchState((prev) => (prev ? { ...prev, scope: 'current' } : null))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-tint-strong">
|
||||
{t(language, 'search_scope_section_current', section?.title ?? '')}
|
||||
</span>
|
||||
<span className="text-tint-subtle text-xs">
|
||||
{t(language, 'search_scope_section_current_description')}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchScopeVariantControl(props: SearchScopeControlProps & { isExtended: boolean }) {
|
||||
const { isExtended, spaceTitle, withSections } = props;
|
||||
|
||||
const language = useLanguage();
|
||||
const [, setSearchState] = useSearch();
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
button={
|
||||
<Button variant="blank" size="medium" className="px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-tint-strong">
|
||||
{t(
|
||||
language,
|
||||
isExtended
|
||||
? 'search_scope_variant_all'
|
||||
: 'search_scope_variant_current',
|
||||
spaceTitle ?? ''
|
||||
)}
|
||||
</span>
|
||||
<DropdownChevron />
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="gap-3"
|
||||
active={!isExtended}
|
||||
onClick={() =>
|
||||
setSearchState((prev) =>
|
||||
prev ? { ...prev, scope: withSections ? 'current' : 'default' } : null
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-tint-strong">
|
||||
{t(language, 'search_scope_variant_current', spaceTitle ?? '')}
|
||||
</span>
|
||||
<span className="text-tint-subtle text-xs">
|
||||
{t(language, 'search_scope_variant_current_description')}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-3"
|
||||
active={isExtended}
|
||||
onClick={() =>
|
||||
setSearchState((prev) => (prev ? { ...prev, scope: 'extended' } : null))
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-tint-strong">
|
||||
{t(language, 'search_scope_variant_all')}
|
||||
</span>
|
||||
<span className="text-tint-subtle text-xs">
|
||||
{t(language, 'search_scope_variant_all_description')}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { tString, useLanguage } from '@/intl/client';
|
||||
import type { SiteSection } from '@gitbook/api';
|
||||
import { Button, Checkbox, DropdownMenu, DropdownMenuItem } from '../primitives';
|
||||
import { SegmentedControl, SegmentedControlItem } from '../primitives/SegmentedControl';
|
||||
import { useSearch } from './useSearch';
|
||||
|
||||
/**
|
||||
* Toolbar to toggle between search modes (global or scoped to a space).
|
||||
* Only visible when the space is in a collection.
|
||||
*/
|
||||
export function SearchScopeToggle(props: {
|
||||
spaceTitle: string;
|
||||
section?: Pick<SiteSection, 'title' | 'icon'>;
|
||||
withVariants: boolean;
|
||||
withSiteVariants: boolean;
|
||||
withSections: boolean;
|
||||
}) {
|
||||
const { spaceTitle, section, withVariants, withSections, withSiteVariants } = props;
|
||||
const [state, setSearchState] = useSearch();
|
||||
const language = useLanguage();
|
||||
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Whether to include all variants in the search
|
||||
const scopeIsExtended = ['extended', 'all'].includes(state.scope);
|
||||
|
||||
return (
|
||||
<>
|
||||
{withSections ? (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<SegmentedControl className="grow animate-scale-in">
|
||||
<SegmentedControlItem
|
||||
active={['default', 'all'].includes(state.scope)}
|
||||
label={tString(language, 'search_scope_all')}
|
||||
icon="infinity"
|
||||
onClick={() =>
|
||||
setSearchState({
|
||||
...state,
|
||||
scope: scopeIsExtended ? 'all' : 'default',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SegmentedControlItem
|
||||
active={['current', 'extended'].includes(state.scope)}
|
||||
icon={section?.icon}
|
||||
label={tString(language, 'search_scope_current', section?.title)}
|
||||
onClick={() =>
|
||||
setSearchState({
|
||||
...state,
|
||||
scope: scopeIsExtended ? 'extended' : 'current',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SegmentedControl>
|
||||
{withSiteVariants ? (
|
||||
<DropdownMenu
|
||||
button={
|
||||
<Button
|
||||
icon="ellipsis"
|
||||
iconOnly
|
||||
size="default"
|
||||
variant="blank"
|
||||
label={tString(language, 'more')}
|
||||
className="shrink-0"
|
||||
active={scopeIsExtended}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setSearchState({
|
||||
...state,
|
||||
scope: (() => {
|
||||
switch (state.scope) {
|
||||
case 'extended':
|
||||
return 'current';
|
||||
case 'current':
|
||||
return 'extended';
|
||||
case 'all':
|
||||
return 'default';
|
||||
default:
|
||||
return 'all';
|
||||
}
|
||||
})(),
|
||||
})
|
||||
}
|
||||
active={scopeIsExtended}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox checked={scopeIsExtended} />
|
||||
<div className="flex flex-col items-start">
|
||||
Include all variants
|
||||
<div className="text-tint-subtle text-xs">
|
||||
Search content in every language/version
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
) : withVariants ? (
|
||||
<SegmentedControl className="mb-2 animate-scale-in">
|
||||
<SegmentedControlItem
|
||||
size={withSections ? 'small' : 'medium'}
|
||||
active={!scopeIsExtended}
|
||||
className="py-1"
|
||||
label={tString(language, 'search_scope_current', spaceTitle)}
|
||||
onClick={() =>
|
||||
setSearchState({
|
||||
...state,
|
||||
scope: 'default',
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
<SegmentedControlItem
|
||||
size={withSections ? 'small' : 'medium'}
|
||||
active={scopeIsExtended}
|
||||
className="py-1"
|
||||
icon="infinity"
|
||||
label={tString(language, 'search_scope_all')}
|
||||
onClick={() => setSearchState({ ...state, scope: 'all' })}
|
||||
/>
|
||||
</SegmentedControl>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -413,11 +413,21 @@ async function transformSitePageResult(
|
||||
icon: siteSection?.icon as IconName,
|
||||
label: siteSection.title,
|
||||
},
|
||||
(!siteSection || siteSection?.siteSpaces.length > 1) && siteSpace
|
||||
(siteSection?.siteSpaces?.filter(
|
||||
// If a space is the only one in its langauge, it's a translation variant and we don't want to show it.
|
||||
(space) =>
|
||||
siteSection?.siteSpaces?.filter(
|
||||
// Check if there are other spaces in the same language within the section.
|
||||
(s) => s.space.language === space.space.language
|
||||
).length > 1 // We only want to show the space if there are other spaces in the same language within the section.
|
||||
).length ?? 0) > 1 && siteSpace
|
||||
? {
|
||||
label: siteSpace.title,
|
||||
}
|
||||
: undefined,
|
||||
...pageItem.ancestors.map((ancestor) => ({
|
||||
label: ancestor.title,
|
||||
})),
|
||||
].filter((item) => item !== undefined),
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
import { type Assistant, useAI } from '@/components/AI';
|
||||
import { useTrackEvent } from '../Insights';
|
||||
import { isQuestion } from './isQuestion';
|
||||
import type { SearchScope } from './useSearch';
|
||||
|
||||
export type ResultType =
|
||||
@@ -198,13 +199,16 @@ function withAskTriggers(
|
||||
return without;
|
||||
}
|
||||
|
||||
const queryIsQuestion = isQuestion(query);
|
||||
|
||||
return [
|
||||
...(queryIsQuestion ? [] : (without ?? [])),
|
||||
...assistants.map((assistant, index) => ({
|
||||
type: 'question' as const,
|
||||
id: `question-${index}`,
|
||||
query,
|
||||
assistant,
|
||||
})),
|
||||
...(without ?? []),
|
||||
...(!queryIsQuestion ? [] : (without ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from '@gitbook/api';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { PageAside } from '@/components/PageAside';
|
||||
import { PageBody, PageCover } from '@/components/PageBody';
|
||||
@@ -75,9 +74,7 @@ export async function SitePage(props: SitePageProps) {
|
||||
insightsDisplayContext={SiteInsightsDisplayContext.Site}
|
||||
/>
|
||||
</div>
|
||||
<React.Suspense fallback={null}>
|
||||
<PageClientLayout />
|
||||
</React.Suspense>
|
||||
<PageClientLayout />
|
||||
</div>
|
||||
</PageContextProvider>
|
||||
);
|
||||
@@ -95,6 +92,30 @@ export async function generateSitePageViewport(context: GitBookSiteContext): Pro
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A string concatenation of the site structure (sections and variants) titles.
|
||||
*/
|
||||
function getSiteStructureTitle(context: GitBookSiteContext): string | null {
|
||||
const { sections, siteSpace, siteSpaces } = context;
|
||||
|
||||
const title = [];
|
||||
if (
|
||||
sections &&
|
||||
sections.current.default === false && // Only if the current section is not the default one
|
||||
sections.list.filter((section) => section.object === 'site-section').length > 1 // Only if there are multiple sections
|
||||
) {
|
||||
title.push(sections.current.title);
|
||||
}
|
||||
if (
|
||||
siteSpaces.length > 1 && // Only if there are multiple variants
|
||||
siteSpace.default === false && // Only if the variant is not the default one
|
||||
siteSpaces.filter((space) => space.space.language === siteSpace.space.language).length > 1 // Only if there are multiple variants *for the current language*. This filters out spaces that are "just" translations of each other, not versions.
|
||||
) {
|
||||
title.push(siteSpace.title);
|
||||
}
|
||||
return title.join(' ');
|
||||
}
|
||||
|
||||
export async function generateSitePageMetadata(props: SitePageProps): Promise<Metadata> {
|
||||
const { context, pageTarget } = await getPageDataWithFallback({
|
||||
context: props.context,
|
||||
@@ -102,14 +123,25 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise<Me
|
||||
});
|
||||
|
||||
if (!pageTarget) {
|
||||
if (context.isFallback) {
|
||||
redirect(context.linker.toPathInSpace('/'));
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { page, ancestors } = pageTarget;
|
||||
const { site, customization, revision, linker, imageResizer } = context;
|
||||
const siteStructureTitle = getSiteStructureTitle(context);
|
||||
|
||||
return {
|
||||
title: [page.title, site.title].filter(Boolean).join(' | '),
|
||||
title: [
|
||||
page.title,
|
||||
// Prevent duplicate titles by comparing against the page title.
|
||||
page.title !== siteStructureTitle ? siteStructureTitle : null, // The first page of a section is often the same as the section title, so we don't need to show it.
|
||||
page.title !== site.title ? site.title : null, // The site title can also be the same as the site title on the site's landing page.
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | '),
|
||||
description: page.description ?? '',
|
||||
alternates: {
|
||||
// Trim trailing slashes in canonical URL to match the redirect behavior
|
||||
@@ -154,6 +186,10 @@ export async function getSitePageData(props: SitePageProps) {
|
||||
// before trying to resolve the page again
|
||||
redirect(context.linker.toPathInSpace(pathname));
|
||||
} else {
|
||||
// If the page is not found and we are in fallback mode, return a redirect to the basepath
|
||||
if (context.isFallback) {
|
||||
redirect(context.linker.toPathInSpace('/'));
|
||||
}
|
||||
notFound();
|
||||
}
|
||||
} else if (getPagePath(context.revision.pages, pageTarget.page) !== rawPathname) {
|
||||
|
||||
@@ -67,15 +67,18 @@ export function SiteSectionTabs(props: {
|
||||
className={tcls(
|
||||
'grow',
|
||||
'md:-ml-8 -ml-4 sm:-ml-6',
|
||||
!children ? 'md:-mr-8 -mr-4 sm:-mr-6' : ''
|
||||
!children
|
||||
? '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'
|
||||
)}
|
||||
activeId={currentSection.id}
|
||||
trailingEdgeScrollClassName={children ? 'after:border-tint' : ''}
|
||||
>
|
||||
<NavigationMenu.List
|
||||
className={tcls(
|
||||
'-mx-3 flex grow gap-2 bg-transparent',
|
||||
'pl-4 sm:pl-6 md:pl-8',
|
||||
!children ? 'pr-4 sm:pr-6 md:pr-8' : ''
|
||||
!children ? 'pr-4 sm:pr-6 md:pr-8' : 'pr-4'
|
||||
)}
|
||||
aria-label="Sections"
|
||||
id="sections"
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { languages } from '@/intl/translations';
|
||||
import { type SiteSpace, TranslationLanguage } from '@gitbook/api';
|
||||
import { categorizeVariants } from './categorizeVariants';
|
||||
|
||||
type FakeSiteSpace = {
|
||||
id: SiteSpace['id'];
|
||||
title: SiteSpace['title'];
|
||||
space: Pick<SiteSpace['space'], 'language'>;
|
||||
};
|
||||
|
||||
function makeContext(current: FakeSiteSpace, all: FakeSiteSpace[]) {
|
||||
return {
|
||||
// Only the properties used by categorizeVariants are required for these tests
|
||||
siteSpace: current,
|
||||
siteSpaces: all,
|
||||
} as unknown as Parameters<typeof categorizeVariants>[0];
|
||||
}
|
||||
|
||||
const englishA = {
|
||||
id: 'en-a',
|
||||
title: 'Docs EN A',
|
||||
space: { language: TranslationLanguage.En },
|
||||
};
|
||||
const englishB = {
|
||||
id: 'en-b',
|
||||
title: 'Docs EN B',
|
||||
space: { language: TranslationLanguage.En },
|
||||
};
|
||||
const frenchA = {
|
||||
id: 'fr-a',
|
||||
title: 'Docs FR A',
|
||||
space: { language: TranslationLanguage.Fr },
|
||||
};
|
||||
const frenchB = {
|
||||
id: 'fr-b',
|
||||
title: 'Docs FR B',
|
||||
space: { language: TranslationLanguage.Fr },
|
||||
};
|
||||
const undefinedLanguage = {
|
||||
id: 'undefined',
|
||||
title: 'Docs in Undefined Language',
|
||||
space: { language: undefined },
|
||||
};
|
||||
const unsupportedLanguage = {
|
||||
id: 'unsupported',
|
||||
title: 'Docs in Unsupported Language',
|
||||
space: { language: 'xx' as TranslationLanguage },
|
||||
};
|
||||
|
||||
describe('categorizeVariants', () => {
|
||||
it('returns all spaces as generic and no translations for single-language sites', () => {
|
||||
const ctx = makeContext(englishA, [englishA, englishB]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
|
||||
expect(result.translations).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all spaces as generic and no translations for sites with 1 language and an undefined language', () => {
|
||||
const ctx = makeContext(englishA, [englishA, englishB, undefinedLanguage]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b', 'undefined']);
|
||||
expect(result.translations).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps one-per-language translations without remapping titles', () => {
|
||||
const ctx = makeContext(englishA, [englishA, frenchA]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
// Generic should only include current language variants when multi-language
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
|
||||
|
||||
// With exactly 1 per language, translations length equals number of languages → no remap
|
||||
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||
{ id: 'en-a', title: 'Docs EN A' },
|
||||
{ id: 'fr-a', title: 'Docs FR A' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps one-per-language translations without remapping titles, including unsupported languages', () => {
|
||||
const ctx = makeContext(englishA, [englishA, unsupportedLanguage]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
// Generic should only include current language variants when multi-language
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
|
||||
|
||||
// With exactly 1 per language, translations length equals number of languages → no remap
|
||||
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||
{ id: 'en-a', title: 'Docs EN A' },
|
||||
{ id: 'unsupported', title: 'Docs in Unsupported Language' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps one-per-language translations when there are more than 1 language and an undefined language', () => {
|
||||
const ctx = makeContext(englishA, [englishA, frenchA, undefinedLanguage]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a']);
|
||||
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||
{ id: 'en-a', title: 'Docs EN A' },
|
||||
{ id: 'fr-a', title: 'Docs FR A' },
|
||||
{ id: 'undefined', title: 'Docs in Undefined Language' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates to first space per language and maps titles to language names', () => {
|
||||
const ctx = makeContext(englishA, [englishA, englishB, frenchA, frenchB]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
// Generic includes all current-language variants when multi-language
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
|
||||
|
||||
// Distinct languages are ['en','fr'] but initial translations had 4 → remap
|
||||
// After remap: first per language, with title set to language label
|
||||
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||
{ id: 'en-a', title: languages.en.language },
|
||||
{ id: 'fr-a', title: languages.fr.language },
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates to first space per language and maps titles to language names, and falls back to original title if no language is found', () => {
|
||||
const ctx = makeContext(englishA, [
|
||||
englishA,
|
||||
englishB,
|
||||
frenchA,
|
||||
frenchB,
|
||||
undefinedLanguage,
|
||||
unsupportedLanguage,
|
||||
]);
|
||||
|
||||
const result = categorizeVariants(ctx);
|
||||
|
||||
expect(result.generic.map((s) => s.id)).toEqual(['en-a', 'en-b']);
|
||||
expect(result.translations.map((s) => ({ id: s.id, title: s.title }))).toEqual([
|
||||
{ id: 'en-a', title: languages.en.language },
|
||||
{ id: 'fr-a', title: languages.fr.language },
|
||||
{ id: 'undefined', title: 'Docs in Undefined Language' },
|
||||
{ id: 'unsupported', title: 'Docs in Unsupported Language' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,9 @@ import { Footer } from '@/components/Footer';
|
||||
import { Header, HeaderLogo } from '@/components/Header';
|
||||
import { TableOfContents } from '@/components/TableOfContents';
|
||||
import { CONTAINER_STYLE } from '@/components/layout';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import { getSpaceLanguage } from '@/intl/server';
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { GITBOOK_APP_URL } from '@/lib/env';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { AIChatProvider } from '../AI';
|
||||
import type { RenderAIMessageOptions } from '../AI';
|
||||
import { AIChat } from '../AIChat';
|
||||
@@ -27,6 +25,7 @@ import { SiteSectionList, encodeClientSiteSections } from '../SiteSections';
|
||||
import { CurrentContentProvider } from '../hooks';
|
||||
import { NavigationLoader } from '../primitives/NavigationLoader';
|
||||
import { SpaceLayoutContextProvider } from './SpaceLayoutContext';
|
||||
import { categorizeVariants } from './categorizeVariants';
|
||||
|
||||
type SpaceLayoutProps = {
|
||||
context: GitBookSiteContext;
|
||||
@@ -105,16 +104,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
|
||||
const withSections = Boolean(sections && sections.list.length > 1);
|
||||
|
||||
const currentLanguage = getSpaceLanguage(context);
|
||||
const withVariants: 'generic' | 'translations' | undefined =
|
||||
siteSpaces.length > 1
|
||||
? siteSpaces.some(
|
||||
(space) => space.space.language && space.space.language !== currentLanguage.locale
|
||||
)
|
||||
? 'translations'
|
||||
: 'generic'
|
||||
: undefined;
|
||||
const variants = categorizeVariants(context);
|
||||
|
||||
const withFooter =
|
||||
customization.themes.toggeable ||
|
||||
@@ -125,7 +115,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
return (
|
||||
<SpaceLayoutServerContext {...props}>
|
||||
<Announcement context={context} />
|
||||
<Header withTopHeader={withTopHeader} withVariants={withVariants} context={context} />
|
||||
<Header withTopHeader={withTopHeader} variants={variants} context={context} />
|
||||
<NavigationLoader />
|
||||
{customization.ai?.mode === CustomizationAIMode.Assistant ? (
|
||||
<AIChat trademark={customization.trademark.enabled} />
|
||||
@@ -139,7 +129,8 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
'lg:flex-row',
|
||||
'lg:justify-center',
|
||||
CONTAINER_STYLE,
|
||||
'site-width-wide:max-w-full',
|
||||
'site-width-wide:max-w-screen-4xl',
|
||||
'hydrated:transition-[max-width] duration-300',
|
||||
|
||||
// Ensure the footer is display below the viewport even if the content is not enough
|
||||
withFooter && [
|
||||
@@ -157,6 +148,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
className={tcls(
|
||||
'hidden',
|
||||
'pr-4',
|
||||
'mt-2',
|
||||
'lg:flex',
|
||||
'grow-0',
|
||||
'dark:shadow-light/1',
|
||||
@@ -165,11 +157,15 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
)}
|
||||
>
|
||||
<HeaderLogo context={context} />
|
||||
{withVariants === 'translations' ? (
|
||||
{variants.translations.length > 1 ? (
|
||||
<TranslationsDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
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}
|
||||
@@ -183,7 +179,7 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
<div className="flex gap-2">
|
||||
<SearchContainer
|
||||
style={CustomizationSearchStyle.Subtle}
|
||||
withVariants={withVariants === 'generic'}
|
||||
withVariants={variants.generic.length > 1}
|
||||
withSiteVariants={
|
||||
sections?.list.some(
|
||||
(s) =>
|
||||
@@ -213,14 +209,14 @@ export function SpaceLayout(props: SpaceLayoutProps) {
|
||||
sections={encodeClientSiteSections(context, sections)}
|
||||
/>
|
||||
)}
|
||||
{withVariants === 'generic' && (
|
||||
{variants.generic.length > 1 ? (
|
||||
<SpacesDropdown
|
||||
context={context}
|
||||
siteSpace={siteSpace}
|
||||
siteSpaces={siteSpaces}
|
||||
siteSpaces={variants.generic}
|
||||
className="w-full px-3 py-2"
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { languages } from '@/intl/translations';
|
||||
import type { GitBookSiteContext } from '@/lib/context';
|
||||
|
||||
/**
|
||||
* Categorize the variants of the space into generic and translation variants.
|
||||
*/
|
||||
export function categorizeVariants(context: GitBookSiteContext) {
|
||||
const { siteSpace, siteSpaces } = context;
|
||||
const currentLanguage = siteSpace.space.language;
|
||||
|
||||
// Get all languages of the variants.
|
||||
const variantLanguages = [...new Set(siteSpaces.map((space) => space.space.language))];
|
||||
|
||||
// We only show the language picker if there are at least 2 distinct languages, excluding undefined.
|
||||
const isMultiLanguage =
|
||||
variantLanguages.filter((language) => language !== undefined).length > 1;
|
||||
|
||||
// Generic variants are all spaces that have the same language as the current (can also be undefined).
|
||||
const genericVariants = isMultiLanguage
|
||||
? siteSpaces.filter(
|
||||
(space) => space === siteSpace || space.space.language === currentLanguage
|
||||
)
|
||||
: siteSpaces;
|
||||
|
||||
// Translation variants are all spaces that have a different language than the current.
|
||||
let translationVariants = isMultiLanguage
|
||||
? siteSpaces.filter(
|
||||
(space) => space === siteSpace || space.space.language !== currentLanguage
|
||||
)
|
||||
: [];
|
||||
|
||||
// If there is exactly 1 variant per language, we will use them as-is.
|
||||
// Otherwise, we will create a translation dropdown with the first space of each language.
|
||||
if (variantLanguages.length !== translationVariants.length) {
|
||||
translationVariants = variantLanguages
|
||||
// Get the first space of each language.
|
||||
.map((variantLanguage) =>
|
||||
translationVariants.find((space) => space.space.language === variantLanguage)
|
||||
)
|
||||
// Filter out unmatched languages.
|
||||
.filter((space) => space !== undefined)
|
||||
// Transform the title to include the language name if we have a translation. Otherwise, use the original title.
|
||||
.map((space) => {
|
||||
const language = languages[space.space.language as keyof typeof languages];
|
||||
return {
|
||||
...space,
|
||||
title: language ? language.language : space.title,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
generic: genericVariants,
|
||||
translations: translationVariants,
|
||||
};
|
||||
}
|
||||
@@ -7,14 +7,14 @@ import { tcls } from '@/lib/tailwind';
|
||||
import { PagesList } from './PagesList';
|
||||
import { TOCPageIcon } from './TOCPageIcon';
|
||||
|
||||
export function PageGroupItem(props: { page: ClientTOCPageGroup }) {
|
||||
const { page } = props;
|
||||
export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boolean }) {
|
||||
const { page, isFirst } = props;
|
||||
|
||||
return (
|
||||
<li className="group/page-group-item flex flex-col">
|
||||
<li className="flex flex-col">
|
||||
<div
|
||||
className={tcls(
|
||||
'-top-6 group-first/page-group-item:-mt-6 sticky z-1 flex items-center gap-3 px-3 pt-6',
|
||||
'-top-6 sticky z-1 flex items-center gap-3 px-3 pt-6',
|
||||
'font-semibold text-xs uppercase tracking-wide',
|
||||
'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.
|
||||
@@ -26,7 +26,8 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup }) {
|
||||
'[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'
|
||||
'[html.sidebar-default.theme-gradient.tint_&]:bg-gradient-tint',
|
||||
isFirst ? '-mt-6' : ''
|
||||
)}
|
||||
>
|
||||
<TOCPageIcon page={page} />
|
||||
|
||||
@@ -9,12 +9,16 @@ import { PageDocumentItem } from './PageDocumentItem';
|
||||
import { PageGroupItem } from './PageGroupItem';
|
||||
import { PageLinkItem } from './PageLinkItem';
|
||||
|
||||
export function PagesList(props: { pages: ClientTOCPage[]; style?: ClassValue }) {
|
||||
const { pages, style } = props;
|
||||
export function PagesList(props: {
|
||||
pages: ClientTOCPage[];
|
||||
style?: ClassValue;
|
||||
isRoot?: boolean;
|
||||
}) {
|
||||
const { pages, style, isRoot = false } = props;
|
||||
|
||||
return (
|
||||
<ul className={tcls('flex flex-col gap-y-0.5', style)}>
|
||||
{pages.map((page) => {
|
||||
{pages.map((page, idx) => {
|
||||
switch (page.type) {
|
||||
case 'document':
|
||||
return <PageDocumentItem key={page.id} page={page} />;
|
||||
@@ -23,7 +27,13 @@ export function PagesList(props: { pages: ClientTOCPage[]; style?: ClassValue })
|
||||
return <PageLinkItem key={page.id} page={page} />;
|
||||
|
||||
case 'group':
|
||||
return <PageGroupItem key={page.id} page={page} />;
|
||||
return (
|
||||
<PageGroupItem
|
||||
key={page.id}
|
||||
page={page}
|
||||
isFirst={isRoot && idx === 0}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
assertNever(page);
|
||||
|
||||
@@ -59,7 +59,7 @@ 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-6',
|
||||
'pt-4',
|
||||
'pb-4',
|
||||
'lg:sidebar-filled:pr-6',
|
||||
'lg:page-no-toc:pr-0',
|
||||
@@ -104,13 +104,14 @@ export async function TableOfContents(props: {
|
||||
) : null}
|
||||
<TOCScrollContainer // The scrollview inside the sidebar
|
||||
className={tcls(
|
||||
'flex grow flex-col p-2',
|
||||
'flex grow flex-col p-2 pt-4',
|
||||
customization.trademark.enabled && 'lg:pb-20',
|
||||
'hide-scrollbar overflow-y-auto'
|
||||
)}
|
||||
>
|
||||
<PagesList
|
||||
pages={pages}
|
||||
isRoot={true}
|
||||
style="page-no-toc:hidden border-tint-subtle sidebar-list-line:border-l"
|
||||
/>
|
||||
{customization.trademark.enabled ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Icon } from '@gitbook/icons';
|
||||
import { motion } from 'motion/react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
@@ -184,9 +184,6 @@ const show = {
|
||||
const hide = {
|
||||
opacity: 0,
|
||||
height: 0,
|
||||
transitionEnd: {
|
||||
display: 'none',
|
||||
},
|
||||
};
|
||||
|
||||
function Descendants(props: {
|
||||
@@ -195,12 +192,17 @@ function Descendants(props: {
|
||||
}) {
|
||||
const { isVisible, children } = props;
|
||||
return (
|
||||
<motion.div
|
||||
animate={isVisible ? show : hide}
|
||||
initial={isVisible ? show : hide}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
<AnimatePresence>
|
||||
{isVisible ? (
|
||||
<motion.div
|
||||
initial={hide}
|
||||
animate={show}
|
||||
exit={hide}
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
React.useEffect(() => {
|
||||
// Initialize hash on mount - It could be null on SSR rehydration
|
||||
setHash(getHash());
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
@@ -73,7 +75,8 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (pathnameRef.current !== url.pathname) {
|
||||
// We don't want to set isNavigating for same page hash navigation
|
||||
if (pathnameRef.current !== url.pathname && !href.startsWith('#')) {
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
setIsNavigating(true);
|
||||
timeoutRef.current = null;
|
||||
@@ -94,7 +97,7 @@ export const NavigationStatusProvider: React.FC<React.PropsWithChildren> = ({ ch
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to get the current hash from the URL.
|
||||
* Hook to get the current hash from the URL. The hash is set on navigation clicks **NOT** on hashchange events or on navigation end.
|
||||
* @see https://github.com/vercel/next.js/discussions/49465
|
||||
* We use a different hack than this one, because for same page link it don't work
|
||||
* We can't use the `hashChange` event because it doesn't fire for `replaceState` and `pushState` which are used by Next.js.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Returns the value of the previous render.
|
||||
*/
|
||||
export function usePrevious<T>(value: T): T | undefined {
|
||||
const ref = React.useRef<T | undefined>(undefined);
|
||||
React.useLayoutEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
}
|
||||
@@ -1,37 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import React from 'react';
|
||||
|
||||
import { useHash } from './useHash';
|
||||
import { usePrevious } from './usePrevious';
|
||||
|
||||
/**
|
||||
* Scroll the page to an anchor point or
|
||||
* to the top of the page when navigating between pages (pathname)
|
||||
* or sections of a page (hash).
|
||||
* Scroll the page to the hash or reset scroll to the top.
|
||||
* Only triggered while navigating in the app, not for initial load.
|
||||
*/
|
||||
export function useScrollPage() {
|
||||
const hash = useHash();
|
||||
const pathname = usePathname();
|
||||
React.useLayoutEffect(() => {
|
||||
const previousHash = usePrevious(hash);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hash) {
|
||||
const element = document.getElementById(hash);
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
return () => {
|
||||
if (hash) {
|
||||
if (previousHash !== undefined && previousHash !== hash) {
|
||||
const element = document.getElementById(hash);
|
||||
if (element) {
|
||||
element.style.scrollMarginTop = '';
|
||||
element.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [hash, pathname]);
|
||||
return;
|
||||
}
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
}, [hash, previousHash]);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ const DropdownMenuContext = createContext<{
|
||||
setOpen: () => {},
|
||||
});
|
||||
|
||||
const DROPDOWN_CONTENT_OUTER_CLASS =
|
||||
'z-50 flex max-h-(--radix-dropdown-menu-content-available-height) min-w-28 xs:min-w-40 max-w-(--radix-dropdown-menu-content-available-width) origin-top animate-scale-in flex-col pt-2 data-[state=closed]:animate-scale-out';
|
||||
const DROPDOWN_CONTENT_INNER_CLASS =
|
||||
'flex flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg';
|
||||
|
||||
/**
|
||||
* Button with a dropdown.
|
||||
*/
|
||||
@@ -82,14 +87,9 @@ export function DropdownMenu(props: {
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
align={align}
|
||||
side={side}
|
||||
className="z-50 animate-scale-in border-tint pt-2"
|
||||
className={DROPDOWN_CONTENT_OUTER_CLASS}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex max-h-[30rem] min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg sm:min-w-52 sm:max-w-80',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={tcls(DROPDOWN_CONTENT_INNER_CLASS, className)}>
|
||||
{children}
|
||||
</div>
|
||||
</RadixDropdownMenu.Content>
|
||||
@@ -162,7 +162,7 @@ export function DropdownMenuItem(
|
||||
} = props;
|
||||
|
||||
const itemClassName = tcls(
|
||||
'rounded-xs straight-corners:rounded-xs circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center',
|
||||
'rounded-sm straight-corners:rounded-none circular-corners:rounded-lg px-3 py-1 text-sm flex gap-2 items-center',
|
||||
active
|
||||
? 'bg-primary text-primary-strong data-highlighted:bg-primary-hover'
|
||||
: 'data-highlighted:bg-tint-hover',
|
||||
@@ -214,11 +214,9 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React
|
||||
<RadixDropdownMenu.SubContent
|
||||
hideWhenDetached
|
||||
collisionPadding={8}
|
||||
className="z-50 animate-present"
|
||||
className={DROPDOWN_CONTENT_OUTER_CLASS}
|
||||
>
|
||||
<div className="flex max-h-96 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto rounded-lg straight-corners:rounded-sm bg-tint-base p-2 shadow-lg ring-1 ring-tint-subtle sm:min-w-52 sm:max-w-80">
|
||||
{children}
|
||||
</div>
|
||||
<div className={DROPDOWN_CONTENT_INNER_CLASS}>{children}</div>
|
||||
</RadixDropdownMenu.SubContent>
|
||||
</RadixDropdownMenu.Portal>
|
||||
</RadixDropdownMenu.Sub>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user