mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-15 15:15:55 +00:00
Compare commits
180 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5d1bbfafe | |||
| dac5e9d444 | |||
| f647af2003 | |||
| 96e24a1f84 | |||
| 2a0f37f4b3 | |||
| 17d935dd4c | |||
| df07fbe242 | |||
| 1e53376151 | |||
| f294818775 | |||
| eed386a133 | |||
| e172b25ba8 | |||
| f7f6f9d06d | |||
| 9e01676fe9 | |||
| f6cf62a686 | |||
| a5ef44ab2a | |||
| b53cb1d2b3 | |||
| 7eeff73080 | |||
| bf63045319 | |||
| 71c2e7f8b4 | |||
| 985c1b45b4 | |||
| 35efc9d6d8 | |||
| 6b1fd43179 | |||
| 7517e987a5 | |||
| 21851d3340 | |||
| ca682362dd | |||
| 56fb754e22 | |||
| 99d788ed9e | |||
| ebf3b2beb7 | |||
| 7b7231602f | |||
| adfb118285 | |||
| a7f58ab829 | |||
| 360b525b4b | |||
| 695e99f466 | |||
| aa87699915 | |||
| d2eb3d9f5a | |||
| 77bc339fca | |||
| e7fb77116e | |||
| 050ca23ac4 | |||
| 9063f97dba | |||
| ed9e8fe93a | |||
| f478ddc2ed | |||
| bd11c0d21b | |||
| 381728970e | |||
| 4690e2087a | |||
| 224a44daf8 | |||
| 4766092ff9 | |||
| 3e4e0b0bf0 | |||
| 6b985051f3 | |||
| 2d08a6e6de | |||
| d539409fc7 | |||
| c30ed46021 | |||
| fc98a4a881 | |||
| 6278a1844e | |||
| 86e7aa6c5e | |||
| 8761cee1bb | |||
| 44feb3b140 | |||
| 6b43773752 | |||
| 62438f447e | |||
| 87d68ea59e | |||
| 5f9c80e4f2 | |||
| 9c69cc413f | |||
| b77623b9f3 | |||
| 1430ceebea | |||
| 3fbd6f0d50 | |||
| 8f0b97906b | |||
| d460dc4b39 | |||
| 27b9f7817b | |||
| 9e062b10d3 | |||
| 74f89316d9 | |||
| 9e1d7b296b | |||
| b49caafa9c | |||
| f372eec393 | |||
| 65ae941687 | |||
| 75b8efa155 | |||
| 34b0c67c91 | |||
| e0ace99707 | |||
| 3553db0989 | |||
| 5c14948cf3 | |||
| 8e4e2a2333 | |||
| 0f4c53cfbd | |||
| c156e946b0 | |||
| 461e15fd00 | |||
| 2989f79708 | |||
| 344842f5ce | |||
| c51076efc4 | |||
| 38e34dcce3 | |||
| 04a4175678 | |||
| 3e40b4db50 | |||
| 854e612e5d | |||
| 27a461be9d | |||
| 3d83dadb12 | |||
| 8dc5c18b9f | |||
| e4756c23a7 | |||
| 79ad7bcb59 | |||
| 5b0917a13a | |||
| 6d6f4ef6dd | |||
| fa69a64a2b | |||
| d1a85d1f91 | |||
| 05bf9d7a59 | |||
| 2e0b3370c8 | |||
| 10995e09ca | |||
| e7cc3ff61f | |||
| 59d259074b | |||
| 96c4ae4f2d | |||
| 64f3596df2 | |||
| 27d6c3af39 | |||
| 1de4eb7973 | |||
| df1966df25 | |||
| 1001aaf94e | |||
| 155e448c8f | |||
| ca2a94ee1a | |||
| a512c90bda | |||
| 9cbefa89c7 | |||
| c8bba7bfe1 | |||
| 4e04db8a1b | |||
| b45feafeff | |||
| 3876f7081e | |||
| b4a021a192 | |||
| 1d92dc1350 | |||
| e4ba35fdae | |||
| 35dcaca971 | |||
| 2aa4f2e0b8 | |||
| 6d56df7c10 | |||
| 4534ea83c3 | |||
| 9022d87ec7 | |||
| a49781e200 | |||
| 065a5e6e55 | |||
| e27986b17a | |||
| 8606f222c9 | |||
| 2c3066e3ae | |||
| 55c0b03a41 | |||
| 3e548e4186 | |||
| 3aea302648 | |||
| 1d1f0a7c6a | |||
| 590729cce5 | |||
| b554d7d13f | |||
| 9568b8a7c8 | |||
| 683bb8f634 | |||
| 7d6a31627f | |||
| c31e5648f5 | |||
| 0c01832684 | |||
| 6b1c9fef86 | |||
| cc976995cc | |||
| ebbb476589 | |||
| 89655cb428 | |||
| 3676d64bdc | |||
| f682df1e01 | |||
| 6c9893945d | |||
| a2fb627ae1 | |||
| f9f80117f0 | |||
| 63bf4dbb16 | |||
| b3fa503aff | |||
| 0ae7cfd790 | |||
| 8fdc5c89dc | |||
| 758b34d46b | |||
| 1b0613e482 | |||
| 3164f1a99d | |||
| 6b588af07d | |||
| d436b04ff4 | |||
| d159788d0b | |||
| 3355960d95 | |||
| c32d4494f2 | |||
| 1dcf4df51f | |||
| 3c0e49d56b | |||
| c35d0e8954 | |||
| c0d73d2f76 | |||
| 454175ab86 | |||
| 6feb8fcd6a | |||
| b8d3cb7ee5 | |||
| 8ce73229c3 | |||
| 8eae6e970b | |||
| a48b886dfd | |||
| 17de0838c5 | |||
| baf3496980 | |||
| ae33c09aa7 | |||
| 7ff69c3b72 | |||
| 6142d6bf80 | |||
| 75543b140f | |||
| 511f1cfbe5 | |||
| b9ad997fcb |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add sidesheet component, use it for TOC and AIChat
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Set <html lang> based on translated language
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gitbook/embed": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix signed JWT token usage
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Serve a markdown version of the page when the "Accept" header contains "text/markdown".
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Align styling of PageLinkItem and PageDocumentItem
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Better conditional display of page actions
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Standardise toggle chevrons across the app
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"gitbook": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Grow InlineActionButton to take available width on line
|
||||||
@@ -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
|
#### 3. Install dependencies and run the project locally
|
||||||
|
|
||||||
##### Prerequisites:
|
##### Prerequisites:
|
||||||
- Node.js (Version: >=20.6)
|
|
||||||
- Use `nvm` for easy Node management
|
- [Node.js](https://nodejs.org/en) (see "engines" in `package.json`)
|
||||||
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
- [Bun](https://bun.sh/) (see "packageManager" in `package.json`)
|
||||||
- We use a text-based lockfile which isn't supported below 1.2.15
|
|
||||||
|
|
||||||
##### Setup steps:
|
##### Setup steps:
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ runs:
|
|||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
accountId: ${{ inputs.accountId }}
|
accountId: ${{ inputs.accountId }}
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '4.10.0'
|
wranglerVersion: '4.43.0'
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
command: deployments status --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ runs:
|
|||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
accountId: ${{ inputs.accountId }}
|
accountId: ${{ inputs.accountId }}
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '4.10.0'
|
wranglerVersion: '4.43.0'
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
command: versions deploy ${{ steps.extract_current_version.outputs.version_id }}@100% ${{ inputs.serverVersionId }}@0% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ runs:
|
|||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
accountId: ${{ inputs.accountId }}
|
accountId: ${{ inputs.accountId }}
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '4.10.0'
|
wranglerVersion: '4.43.0'
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
command: versions deploy ${{ inputs.middlewareVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ runs:
|
|||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
accountId: ${{ inputs.accountId }}
|
accountId: ${{ inputs.accountId }}
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '4.10.0'
|
wranglerVersion: '4.43.0'
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
command: versions deploy ${{ inputs.serverVersionId }}@100% -y --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ runs:
|
|||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
accountId: ${{ inputs.accountId }}
|
accountId: ${{ inputs.accountId }}
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '4.10.0'
|
wranglerVersion: '4.43.0'
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
|
command: deploy --config ./packages/gitbook/openNext/customWorkers/doWrangler.jsonc
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ runs:
|
|||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
accountId: ${{ inputs.accountId }}
|
accountId: ${{ inputs.accountId }}
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '4.10.0'
|
wranglerVersion: '4.43.0'
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/defaultWrangler.jsonc
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ runs:
|
|||||||
apiToken: ${{ inputs.apiToken }}
|
apiToken: ${{ inputs.apiToken }}
|
||||||
accountId: ${{ inputs.accountId }}
|
accountId: ${{ inputs.accountId }}
|
||||||
workingDirectory: ./
|
workingDirectory: ./
|
||||||
wranglerVersion: '4.10.0'
|
wranglerVersion: '4.43.0'
|
||||||
environment: ${{ inputs.environment }}
|
environment: ${{ inputs.environment }}
|
||||||
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
command: ${{ format('versions upload --tag {0} --message "{1}"', inputs.commitTag, inputs.commitMessage) }} --config ./packages/gitbook/openNext/customWorkers/middlewareWrangler.jsonc
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- changeset-release/main
|
||||||
env:
|
env:
|
||||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
jobs:
|
jobs:
|
||||||
format:
|
format:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -41,7 +42,7 @@ jobs:
|
|||||||
name: Build (Open Source)
|
name: Build (Open Source)
|
||||||
timeout-minutes: 6
|
timeout-minutes: 6
|
||||||
env:
|
env:
|
||||||
NPM_TOKEN_READONLY: ''
|
BUN_NPM_TOKEN: ''
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
env:
|
env:
|
||||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
jobs:
|
jobs:
|
||||||
deploy-v2-vercel:
|
deploy-v2-vercel:
|
||||||
name: Deploy v2 to Vercel (preview)
|
name: Deploy v2 to Vercel (preview)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
env:
|
env:
|
||||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
jobs:
|
jobs:
|
||||||
deploy-v2-vercel:
|
deploy-v2-vercel:
|
||||||
name: Deploy v2 to Vercel (production)
|
name: Deploy v2 to Vercel (production)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
env:
|
env:
|
||||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
jobs:
|
jobs:
|
||||||
deploy-v2-vercel:
|
deploy-v2-vercel:
|
||||||
name: Deploy v2 to Vercel (staging)
|
name: Deploy v2 to Vercel (staging)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NPM_TOKEN_READONLY: ${{ secrets.NPM_TOKEN_READONLY }}
|
BUN_NPM_TOKEN: ${{ secrets.NPM_TOKEN_READONLY }}
|
||||||
|
|
||||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
||||||
@@ -14,30 +14,38 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
name: Publish
|
name: Publish
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
pull-requests: write
|
||||||
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
# Ensure npm 11.5.1 or later is installed
|
||||||
|
- name: Update npm
|
||||||
|
run: npm install -g npm@latest
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/composite/setup-bun
|
uses: ./.github/composite/setup-bun
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: 1
|
PUPPETEER_SKIP_DOWNLOAD: 1
|
||||||
- name: Create Release Pull Request or Publish to npm
|
- name: Create Release Pull Request
|
||||||
id: changesets
|
id: changesets
|
||||||
uses: changesets/action@v1
|
uses: changesets/action@v1
|
||||||
with:
|
with:
|
||||||
publish: npm run release
|
publish: bun run publish-all-packages
|
||||||
version: npm run changeset-version
|
version: bun run changeset-version
|
||||||
env:
|
env:
|
||||||
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
# Using a PAT instead of GITHUB_TOKEN because we need to run workflows when releases are created
|
||||||
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
# https://github.com/orgs/community/discussions/26875#discussioncomment-3253761
|
||||||
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,3 +25,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# TypeScript
|
# TypeScript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.npmrc
|
||||||
|
|
||||||
|
# Bun pack artifacts
|
||||||
|
packages/*/*.tgz
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ To run a local version of this project, please follow these simple steps.
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js (Version: >=20.6)
|
- Node.js (Version: >= 22.3)
|
||||||
- Use nvm for easy Node management
|
- Use nvm for easy Node management
|
||||||
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
- [Bun](https://bun.sh/) (Version: >=1.2.15)
|
||||||
- We use a text-based lockfile which isn't supported below 1.2.15
|
- We use a text-based lockfile which isn't supported below 1.2.15
|
||||||
@@ -90,7 +90,7 @@ If you are GitBook staff, you'll need our NPM token in your local environment.
|
|||||||
```
|
```
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
NPM_TOKEN_READONLY=xxx
|
BUN_NPM_TOKEN=xxx
|
||||||
```
|
```
|
||||||
|
|
||||||
and then reinstall dependencies.
|
and then reinstall dependencies.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 622 KiB After Width: | Height: | Size: 491 KiB |
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
[install.scopes]
|
[install.scopes]
|
||||||
"gitbook" = { token = "$NPM_TOKEN_READONLY", url = "https://registry.npmjs.org" }
|
"gitbook" = { token = "$BUN_NPM_TOKEN", url = "https://registry.npmjs.org" }
|
||||||
|
|||||||
+25
-9
@@ -1,17 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.3.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@changesets/cli": "^2.29.7",
|
"@changesets/cli": "^2.29.8",
|
||||||
"turbo": "^2.5.8",
|
"turbo": "^2.7.4",
|
||||||
"vercel": "^39.4.2"
|
"vercel": "^39.4.2"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.2.15",
|
"packageManager": "bun@1.3.2",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
"react": "^19.0.0",
|
"@types/react": "catalog:",
|
||||||
"react-dom": "^19.0.0",
|
"@types/react-dom": "catalog:",
|
||||||
|
"react": "catalog:",
|
||||||
|
"react-dom": "catalog:",
|
||||||
"esbuild": "0.24.2"
|
"esbuild": "0.24.2"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -27,17 +32,28 @@
|
|||||||
"e2e-customers": "turbo run e2e-customers",
|
"e2e-customers": "turbo run e2e-customers",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"changeset-version": "changeset version && bun run format && bun update",
|
"changeset-version": "changeset version && bun run format && bun update",
|
||||||
"release": "turbo run release && bun run publish-all-packages",
|
"publish-all-packages": "turbo run publish-to-npm --continue=dependencies-successful",
|
||||||
"publish-all-packages": "for dir in packages/*; do (cd \"$dir\" && bun publish || true); done && changeset tag",
|
|
||||||
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
"download:env": "op read op://gitbook-x-dev/gitbook-open/.env.local >> .env.local",
|
||||||
"clean": "turbo run clean"
|
"clean": "turbo run clean"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"packages": ["packages/*"],
|
"packages": ["packages/*"],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@gitbook/api": "0.143.2",
|
"@tsconfig/strictest": "^2.0.6",
|
||||||
|
"@tsconfig/node20": "^20.1.6",
|
||||||
|
"@gitbook/api": "0.158.0",
|
||||||
|
"@scalar/api-client-react": "^1.3.46",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"assert-never": "^1.4.0",
|
||||||
"bidc": "^0.0.2",
|
"bidc": "^0.0.2",
|
||||||
"tsdown": "^0.15.6"
|
"bun-types": "^1.1.20",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"react": "^19.0.1",
|
||||||
|
"react-dom": "^19.0.1",
|
||||||
|
"tsdown": "^0.15.6",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"usehooks-ts": "^3.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# @gitbook/browser-types
|
# @gitbook/browser-types
|
||||||
|
|
||||||
|
## 0.1.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 10995e0: Use NPM Trusted publishing for publishing the package.
|
||||||
|
- Updated dependencies [10995e0]
|
||||||
|
- @gitbook/icons@0.3.4
|
||||||
|
|
||||||
|
## 0.1.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||||
|
- Updated dependencies [6142d6b]
|
||||||
|
- @gitbook/icons@0.3.3
|
||||||
|
|
||||||
## 0.1.1
|
## 0.1.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -8,21 +8,29 @@
|
|||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.1.1",
|
"sideEffects": false,
|
||||||
|
"version": "0.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/icons": "workspace:"
|
"@gitbook/icons": "workspace:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3"
|
"bun-types": "catalog:",
|
||||||
|
"tsdown": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsdown",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "bun run build -- --watch ./src",
|
||||||
|
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||||
},
|
},
|
||||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://registry.npmjs.org/"
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/GitbookIO/gitbook"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# @gitbook/cache-tags
|
# @gitbook/cache-tags
|
||||||
|
|
||||||
|
## 0.3.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 10995e0: Use NPM Trusted publishing for publishing the package.
|
||||||
|
|
||||||
|
## 0.3.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||||
|
|
||||||
## 0.3.2
|
## 0.3.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -4,26 +4,32 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"development": "./src/index.ts",
|
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.3.2",
|
"sideEffects": false,
|
||||||
|
"version": "0.3.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"assert-never": "^1.2.1"
|
"assert-never": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3"
|
"bun-types": "catalog:",
|
||||||
|
"tsdown": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsdown",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"dev": "tsc -w"
|
"dev": "bun run build -- --watch ./src",
|
||||||
|
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||||
},
|
},
|
||||||
"files": ["dist", "src", "README.md", "CHANGELOG.md"],
|
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://registry.npmjs.org/"
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/GitbookIO/gitbook"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# @gitbook/colors
|
# @gitbook/colors
|
||||||
|
|
||||||
|
## 0.4.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 10995e0: Use NPM Trusted publishing for publishing the package.
|
||||||
|
|
||||||
|
## 0.4.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||||
|
|
||||||
## 0.4.1
|
## 0.4.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -4,22 +4,28 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"development": "./src/index.ts",
|
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.4.1",
|
"sideEffects": false,
|
||||||
|
"version": "0.4.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.5.3"
|
"bun-types": "catalog:",
|
||||||
|
"tsdown": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsdown",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"dev": "tsc -w"
|
"dev": "bun run build -- --watch ./src",
|
||||||
|
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||||
},
|
},
|
||||||
"files": ["dist", "src", "README.md", "CHANGELOG.md"],
|
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://registry.npmjs.org/"
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/GitbookIO/gitbook"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
# @gitbook/embed
|
# @gitbook/embed
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 1430cee: Improve Docs Embed with separate Assistant and Docs tabs
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [62438f4]
|
||||||
|
- @gitbook/icons@0.4.0
|
||||||
|
|
||||||
|
## 0.1.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 10995e0: Use NPM Trusted publishing for publishing the package.
|
||||||
|
- Updated dependencies [10995e0]
|
||||||
|
- @gitbook/icons@0.3.4
|
||||||
|
|
||||||
|
## 0.1.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||||
|
- Updated dependencies [6142d6b]
|
||||||
|
- @gitbook/icons@0.3.3
|
||||||
|
|
||||||
## 0.1.3
|
## 0.1.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
+370
-8
@@ -1,24 +1,61 @@
|
|||||||
# `@gitbook/embed`
|
# GitBook Docs Embed (`@gitbook/embed`)
|
||||||
|
|
||||||
Embed the GitBook Docs Assistant in your product or website.
|
Embed your GitBook docs in your product or website.
|
||||||
|
|
||||||
|
The Docs Embed can contain two tabs:
|
||||||
|
- **Assistant**: The [GitBook Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) - an AI-powered chat interface to help users find answers
|
||||||
|
- **Docs**: A browser for navigating your documentation site
|
||||||
|
|
||||||
|
The embed is set up automatically based on your site's configuration. You can optionally customize and override the configuration with custom actions, tools, suggested questions, [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access), and more. See the [Configuration](#configuration) section for all available options.
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
## As a script from your docs site
|
## As a standalone script from your docs site
|
||||||
|
|
||||||
All GitBook docs site includes a script to easily embed the docs assistant as a widget on your website.
|
All GitBook docs sites include a script to easily add the Docs Embed as a widget on your website.
|
||||||
|
|
||||||
The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
|
The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
|
||||||
|
|
||||||
You can find the embed script from your docs site settings, or you can copy the following and replace the `docs.company.com` by your docs site hostname.
|
You can find the embed script from your docs site settings, or you can copy the following and replace `docs.company.com` with your docs site hostname.
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
|
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// Initialize with Authenticated Access (optional)
|
||||||
|
window.GitBook('init',
|
||||||
|
{ siteURL: 'https://docs.company.com' },
|
||||||
|
{ visitor: { token: 'your-jwt-token' } }
|
||||||
|
);
|
||||||
window.GitBook('show');
|
window.GitBook('show');
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The standalone script provides a global `GitBook` function. See the [API Reference](#api-reference) section for all available methods.
|
||||||
|
|
||||||
|
### Example: Configuring the widget
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
GitBook('configure', {
|
||||||
|
button: {
|
||||||
|
label: 'Ask',
|
||||||
|
icon: 'assistant' // 'assistant' | 'sparkle' | 'help' | 'book'
|
||||||
|
},
|
||||||
|
tabs: ['assistant', 'docs'],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: 'circle-question',
|
||||||
|
label: 'Contact Support',
|
||||||
|
onClick: () => window.open('https://support.example.com', '_blank')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
|
||||||
|
suggestions: ['What is GitBook?', 'How do I get started?'],
|
||||||
|
tools: [/* ... */]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Configuration](#configuration) section for all available options.
|
||||||
|
|
||||||
## As a package from NPM
|
## As a package from NPM
|
||||||
|
|
||||||
Install the package: `npm install @gitbook/embed` and import it in your web application:
|
Install the package: `npm install @gitbook/embed` and import it in your web application:
|
||||||
@@ -30,10 +67,46 @@ const gitbook = createGitBook({
|
|||||||
siteURL: 'https://docs.company.com'
|
siteURL: 'https://docs.company.com'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create an iframe and get its URL
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
iframe.src = gitbook.getFrameURL();
|
iframe.src = gitbook.getFrameURL({
|
||||||
|
visitor: {
|
||||||
|
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
|
||||||
|
unsignedClaims: { // Optional: custom claims for dynamic expressions
|
||||||
|
userId: '123',
|
||||||
|
plan: 'premium'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a frame client to communicate with the iframe
|
||||||
const frame = gitbook.createFrame(iframe);
|
const frame = gitbook.createFrame(iframe);
|
||||||
|
|
||||||
|
// Use the frame client methods
|
||||||
|
frame.navigateToPage('/getting-started'); // Navigate to a page in the docs tab
|
||||||
|
frame.navigateToAssistant(); // Switch to the assistant tab
|
||||||
|
frame.postUserMessage('How do I get started?');
|
||||||
|
frame.clearChat();
|
||||||
|
|
||||||
|
// Configure the embed (see Configuration section for all options)
|
||||||
|
frame.configure({
|
||||||
|
tabs: ['assistant', 'docs'],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: 'circle-question',
|
||||||
|
label: 'Contact Support',
|
||||||
|
onClick: () => window.open('https://support.example.com', '_blank')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
greeting: { title: 'Welcome!', subtitle: 'How can I help?' },
|
||||||
|
suggestions: ['What is GitBook?', 'How do I get started?'],
|
||||||
|
tools: [/* ... */]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to events
|
||||||
|
frame.on('close', () => {
|
||||||
|
console.log('Frame closed');
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## As React components
|
## As React components
|
||||||
@@ -41,9 +114,298 @@ const frame = gitbook.createFrame(iframe);
|
|||||||
After installing the NPM package, you can import prebuilt React components:
|
After installing the NPM package, you can import prebuilt React components:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { GitBookProvider, GitBookAssistantFrame } from '@gitbook/embed/react';
|
import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
|
||||||
|
|
||||||
<GitBookProvider siteURL="https://docs.company.com">
|
<GitBookProvider siteURL="https://docs.company.com">
|
||||||
<GitBookAssistantFrame />
|
<GitBookFrame
|
||||||
|
visitor={{
|
||||||
|
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
|
||||||
|
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
|
||||||
|
}}
|
||||||
|
tabs={['assistant', 'docs']}
|
||||||
|
greeting={{ title: 'Welcome!', subtitle: 'How can I help?' }}
|
||||||
|
suggestions={['What is GitBook?', 'How do I get started?']}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
icon: 'circle-question',
|
||||||
|
label: 'Contact Support',
|
||||||
|
onClick: () => window.open('https://support.example.com', '_blank')
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
tools={[/* ... */]}
|
||||||
|
/>
|
||||||
</GitBookProvider>
|
</GitBookProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You can also use the `useGitBook` hook to access the client:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useGitBook } from '@gitbook/embed/react';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const gitbook = useGitBook();
|
||||||
|
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# API Reference
|
||||||
|
|
||||||
|
## Method Comparison
|
||||||
|
|
||||||
|
| Method | Standalone Script | NPM Package | React Components |
|
||||||
|
|--------|------------------|-------------|------------------|
|
||||||
|
| **Initialize** | `GitBook('init', options, frameOptions)` | `createGitBook(options)` | `<GitBookProvider siteURL="...">` |
|
||||||
|
| **Get frame URL** | ❌ (handled internally) | `client.getFrameURL(options)` | `useGitBook().getFrameURL(options)` |
|
||||||
|
| **Create frame client** | ❌ (handled internally) | `client.createFrame(iframe)` | `useGitBook().createFrame(iframe)` |
|
||||||
|
| **Show/Hide widget** | `GitBook('show')` / `GitBook('hide')` | ❌ | ❌ |
|
||||||
|
| **Open/Close window** | `GitBook('open')` / `GitBook('close')` / `GitBook('toggle')` | ❌ | ❌ |
|
||||||
|
| **Navigate to page** | `GitBook('navigateToPage', path)` | `frame.navigateToPage(path)` | Via frame client |
|
||||||
|
| **Navigate to assistant** | `GitBook('navigateToAssistant')` | `frame.navigateToAssistant()` | Via frame client |
|
||||||
|
| **Post message** | `GitBook('postUserMessage', message)` | `frame.postUserMessage(message)` | Via frame client |
|
||||||
|
| **Clear chat** | `GitBook('clearChat')` | `frame.clearChat()` | Via frame client |
|
||||||
|
| **Configure** | `GitBook('configure', settings)` | `frame.configure(settings)` | Props on `<GitBookFrame>` |
|
||||||
|
| **Event listeners** | ❌ | `frame.on(event, listener)` | Via frame client |
|
||||||
|
| **Unload** | `GitBook('unload')` | ❌ | ❌ |
|
||||||
|
|
||||||
|
## Method Signatures
|
||||||
|
|
||||||
|
### Standalone Script
|
||||||
|
|
||||||
|
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
|
||||||
|
- `GitBook('show')` - Show widget button
|
||||||
|
- `GitBook('hide')` - Hide widget button
|
||||||
|
- `GitBook('open')` - Open widget window
|
||||||
|
- `GitBook('close')` - Close widget window
|
||||||
|
- `GitBook('toggle')` - Toggle widget window
|
||||||
|
- `GitBook('navigateToPage', path: string)` - Navigate to page
|
||||||
|
- `GitBook('navigateToAssistant')` - Navigate to assistant tab
|
||||||
|
- `GitBook('postUserMessage', message: string)` - Post message to chat
|
||||||
|
- `GitBook('clearChat')` - Clear chat history
|
||||||
|
- `GitBook('configure', settings: {...})` - Configure widget
|
||||||
|
- `GitBook('unload')` - Unload widget
|
||||||
|
|
||||||
|
### NPM Package
|
||||||
|
|
||||||
|
**Client Factory:**
|
||||||
|
- `createGitBook(options: { siteURL: string })` → `GitBookClient`
|
||||||
|
- `client.getFrameURL(options?: { visitor?: {...} })` → `string`
|
||||||
|
- `client.createFrame(iframe: HTMLIFrameElement)` → `GitBookFrameClient`
|
||||||
|
|
||||||
|
**Frame Client:**
|
||||||
|
- `frame.navigateToPage(path: string)` → `void`
|
||||||
|
- `frame.navigateToAssistant()` → `void`
|
||||||
|
- `frame.postUserMessage(message: string)` → `void`
|
||||||
|
- `frame.clearChat()` → `void`
|
||||||
|
- `frame.configure(settings: Partial<GitBookEmbeddableConfiguration>)` → `void`
|
||||||
|
- `frame.on(event: string, listener: Function)` → `() => void` (unsubscribe)
|
||||||
|
|
||||||
|
### React Components
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
- `<GitBookProvider siteURL: string>` - Provider component
|
||||||
|
- `<GitBookFrame {...props}>` - Frame component (accepts all config options as props)
|
||||||
|
|
||||||
|
**Hooks:**
|
||||||
|
- `useGitBook()` → `GitBookClient` (must be used within `<GitBookProvider>`)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
Configuration options are available across usage methods as follows:
|
||||||
|
- **Standalone script**: via `GitBook('configure', {...})`
|
||||||
|
- **NPM package**: via `frame.configure({...})`
|
||||||
|
- **React components**: via props on `<GitBookFrame>`
|
||||||
|
|
||||||
|
### `tabs`
|
||||||
|
|
||||||
|
Available in: Standalone script, NPM package, React components
|
||||||
|
|
||||||
|
Override which tabs are displayed. Defaults to your site's configuration.
|
||||||
|
|
||||||
|
- **Type**: `('assistant' | 'docs')[]`
|
||||||
|
- **Options**:
|
||||||
|
- `['assistant', 'docs']` - Show both tabs
|
||||||
|
- `['assistant']` - Show only the assistant tab
|
||||||
|
- `['docs']` - Show only the docs tab
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
tabs: ['assistant', 'docs']
|
||||||
|
```
|
||||||
|
|
||||||
|
### `actions`
|
||||||
|
|
||||||
|
Available in: Standalone script, NPM package, React components
|
||||||
|
|
||||||
|
Custom action buttons rendered in the sidebar alongside tabs. Each action button triggers a callback when clicked.
|
||||||
|
|
||||||
|
**Note**: This prop was previously named `buttons`. Use `actions` instead, it has the same functionality.
|
||||||
|
|
||||||
|
- **Type**: `GitBookEmbeddableActionDefinition[]`
|
||||||
|
- **Properties**:
|
||||||
|
- `icon`: `string` - Icon name. Any [FontAwesome icon](https://fontawesome.com/search) is supported. (e.g., `'rocket'`, `'comments'`, `'user-circle'`, ...)
|
||||||
|
- `label`: `string` - Button label text
|
||||||
|
- `onClick`: `() => void | Promise<void>` - Callback function when clicked
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
icon: 'comments',
|
||||||
|
label: 'Contact Support',
|
||||||
|
onClick: () => window.open('https://support.example.com', '_blank')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'rocket',
|
||||||
|
label: 'Get started',
|
||||||
|
onClick: () => {
|
||||||
|
GitBook('navigateToPage', '/getting-started');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `greeting`
|
||||||
|
|
||||||
|
Available in: Standalone script, NPM package, React components
|
||||||
|
|
||||||
|
Welcome message displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) tab.
|
||||||
|
|
||||||
|
- **Type**: `{ title: string, subtitle: string }`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
greeting: {
|
||||||
|
title: 'Welcome!',
|
||||||
|
subtitle: 'How can I help you today?'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `suggestions`
|
||||||
|
|
||||||
|
Available in: Standalone script, NPM package, React components
|
||||||
|
|
||||||
|
Suggested questions displayed in the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant) welcome screen.
|
||||||
|
|
||||||
|
- **Type**: `string[]`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
suggestions: [
|
||||||
|
'What is GitBook?',
|
||||||
|
'How do I get started?',
|
||||||
|
'What can you do?'
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `tools`
|
||||||
|
|
||||||
|
Available in: Standalone script, NPM package, React components
|
||||||
|
|
||||||
|
Custom AI tools to extend the [Assistant](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant). Tools allow the assistant to execute functions and integrate with your own systems.
|
||||||
|
|
||||||
|
**Note**: In addition to custom tools, the Assistant will always have access to any [MCP servers you define](https://gitbook.com/docs/publishing-documentation/gitbook-ai-assistant#extend-gitbook-assistant-with-mcp-servers) in your site's AI settings.
|
||||||
|
|
||||||
|
- **Type**: `GitBookToolDefinition[]`
|
||||||
|
- **Properties**:
|
||||||
|
- `name`: `string` - Unique tool identifier
|
||||||
|
- `description`: `string` - Description of what the tool does (used by the AI to decide when and how to use it).
|
||||||
|
- `inputSchema`: `object` - JSON schema defining the tool's input parameters
|
||||||
|
- `execute`: `(input: object) => Promise<{ output: any, summary: string }>` - Async function that executes the tool.
|
||||||
|
- `output`: The result of the tool execution, provided to the AI to continue working with. Not shown to the user.
|
||||||
|
- `summary`: The visual summary of the tool execution, shown in the user's chat window.
|
||||||
|
- `confirmation`: `{ icon?: string, label: string }` (optional) - Confirmation button shown before execution, useful for actions that require the user's express approval.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'get_user_info',
|
||||||
|
description: 'Get information about the current user',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
userId: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The user ID to look up'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['userId']
|
||||||
|
},
|
||||||
|
execute: async (input) => {
|
||||||
|
const user = await fetch(`/api/users/${input.userId}`).then(r => r.json());
|
||||||
|
return {
|
||||||
|
output: { name: user.name, plan: user.plan },
|
||||||
|
summary: `Retrieved info for user ${user.name}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'create_ticket',
|
||||||
|
description: 'Create a support ticket',
|
||||||
|
confirmation: {
|
||||||
|
icon: 'circle-question',
|
||||||
|
label: 'Create support ticket?'
|
||||||
|
},
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
subject: { type: 'string' },
|
||||||
|
description: { type: 'string' }
|
||||||
|
},
|
||||||
|
required: ['subject', 'description']
|
||||||
|
},
|
||||||
|
execute: async (input) => {
|
||||||
|
const ticket = await fetch('/api/tickets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}).then(r => r.json());
|
||||||
|
return {
|
||||||
|
output: { ticketId: ticket.id },
|
||||||
|
summary: `Created ticket #${ticket.id}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `visitor` (Authenticated Access)
|
||||||
|
|
||||||
|
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
|
||||||
|
|
||||||
|
[Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access) options passed when creating the frame URL. Used for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) and [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access).
|
||||||
|
|
||||||
|
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
|
||||||
|
|
||||||
|
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
|
||||||
|
**NPM package**: Pass to `getFrameURL({ visitor: {...} })`
|
||||||
|
**React components**: Pass as the `visitor` prop on `<GitBookFrame>`
|
||||||
|
|
||||||
|
- **Type**: `{ token?: string, unsignedClaims?: Record<string, unknown> }`
|
||||||
|
- **Properties**:
|
||||||
|
- `token`: `string` (optional) - Signed JWT token for [Adaptive Content](https://gitbook.com/docs/publishing-documentation/adaptive-content) or [Authenticated Access](https://gitbook.com/docs/publishing-documentation/authenticated-access)
|
||||||
|
- `unsignedClaims`: `Record<string, unknown>` (optional) - Unsigned claims that can be used in dynamic expressions via `visitor.claims.unsigned.<claim-name>`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
visitor: {
|
||||||
|
token: 'your-jwt-token',
|
||||||
|
unsignedClaims: {
|
||||||
|
userId: '123',
|
||||||
|
plan: 'premium',
|
||||||
|
role: 'admin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `button`
|
||||||
|
|
||||||
|
Available in: Standalone script only
|
||||||
|
|
||||||
|
Configure the widget button for the standalone script. This option is not available when using the NPM package or React components, since they can be customized completely.
|
||||||
|
|
||||||
|
- **Type**: `{ label: string, icon: 'assistant' | 'sparkle' | 'help' | 'book' }`
|
||||||
|
- **Properties**:
|
||||||
|
- `label`: `string` - Button label text
|
||||||
|
- `icon`: `'assistant' | 'sparkle' | 'help' | 'book'` - Icon displayed on the button. Choose from one of 4 presets.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
button: {
|
||||||
|
label: 'Ask',
|
||||||
|
icon: 'assistant'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -12,31 +12,39 @@
|
|||||||
"default": "./dist/react/index.js"
|
"default": "./dist/react/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.1.3",
|
"sideEffects": false,
|
||||||
|
"version": "0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/icons": "workspace:",
|
"@gitbook/icons": "workspace:",
|
||||||
"bidc": "catalog:"
|
"bidc": "catalog:"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.0.0"
|
"react": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"bun-types": "catalog:",
|
||||||
|
"@types/react": "catalog:",
|
||||||
"tsdown": "catalog:",
|
"tsdown": "catalog:",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "catalog:",
|
||||||
"react": "^19.0.0"
|
"react": "catalog:"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run build-lib && bun run build-standalone",
|
"build": "bun run build-lib && bun run build-standalone",
|
||||||
"build-lib": "tsdown",
|
"build-lib": "tsdown",
|
||||||
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
|
||||||
"clean": "rm -rf ./dist",
|
"clean": "rm -rf ./dist",
|
||||||
|
"unit": "bun test",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"dev": "bun run build -- --watch ./src"
|
"dev": "bun run build -- --watch ./src",
|
||||||
|
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||||
},
|
},
|
||||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://registry.npmjs.org/"
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/GitbookIO/gitbook"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { createGitBook } from './createGitBook';
|
||||||
|
|
||||||
|
describe('createGitBook.getFrameURL', () => {
|
||||||
|
it('builds the embed URL when the site URL has no trailing slash', () => {
|
||||||
|
const client = createGitBook({ siteURL: 'https://example.com/docs' });
|
||||||
|
|
||||||
|
const url = new URL(client.getFrameURL({}));
|
||||||
|
|
||||||
|
expect(url.origin).toBe('https://example.com');
|
||||||
|
expect(url.pathname).toBe('/docs/~gitbook/embed');
|
||||||
|
expect(url.searchParams.toString()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a trailing slash and adds visitor parameters', () => {
|
||||||
|
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
|
||||||
|
|
||||||
|
const url = new URL(
|
||||||
|
client.getFrameURL({
|
||||||
|
visitor: {
|
||||||
|
token: 'signed-token',
|
||||||
|
unsignedClaims: {
|
||||||
|
role: 'editor',
|
||||||
|
count: 3,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(url.pathname).toBe('/docs/~gitbook/embed');
|
||||||
|
expect(url.searchParams.get('jwt_token')).toBe('signed-token');
|
||||||
|
expect(url.searchParams.get('visitor.role')).toBe('editor');
|
||||||
|
expect(url.searchParams.get('visitor.count')).toBe('3');
|
||||||
|
expect(url.searchParams.get('visitor.enabled')).toBe('false');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,10 +40,10 @@ export function createGitBook(options: CreateGitBookOptions) {
|
|||||||
const client: GitBookClient = {
|
const client: GitBookClient = {
|
||||||
getFrameURL: (frameOptions) => {
|
getFrameURL: (frameOptions) => {
|
||||||
const url = new URL(options.siteURL);
|
const url = new URL(options.siteURL);
|
||||||
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed/assistant`;
|
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
|
||||||
|
|
||||||
if (frameOptions.visitor?.token) {
|
if (frameOptions.visitor?.token) {
|
||||||
url.searchParams.set('token', frameOptions.visitor.token);
|
url.searchParams.set('jwt_token', frameOptions.visitor.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (frameOptions.visitor?.unsignedClaims) {
|
if (frameOptions.visitor?.unsignedClaims) {
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ export function createGitBookFrame(iframe: HTMLIFrameElement): GitBookFrameClien
|
|||||||
const events = new Map<string, Array<(...args: any[]) => void>>();
|
const events = new Map<string, Array<(...args: any[]) => void>>();
|
||||||
|
|
||||||
const configuration: GitBookEmbeddableConfiguration = {
|
const configuration: GitBookEmbeddableConfiguration = {
|
||||||
buttons: [],
|
tabs: ['assistant', 'docs'],
|
||||||
welcomeMessage: '',
|
actions: [],
|
||||||
|
greeting: { title: '', subtitle: '' },
|
||||||
suggestions: [],
|
suggestions: [],
|
||||||
tools: [],
|
tools: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export type GitBookToolDefinition = AIToolDefinition & {
|
|||||||
/**
|
/**
|
||||||
* Custom button definition to be passed to the embeddable GitBook.
|
* Custom button definition to be passed to the embeddable GitBook.
|
||||||
*/
|
*/
|
||||||
export type GitBookEmbeddableButtonDefinition = {
|
export type GitBookEmbeddableActionDefinition = {
|
||||||
/**
|
/**
|
||||||
* Icon to be displayed in the button.
|
* Icon to be displayed in the button.
|
||||||
*/
|
*/
|
||||||
@@ -41,16 +41,26 @@ export type GitBookEmbeddableButtonDefinition = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overall configuration for the layout of the embeddable GitBook.
|
* Overall configuration for the layout of the GitBook embed.
|
||||||
*/
|
*/
|
||||||
export type GitBookEmbeddableConfiguration = {
|
export type GitBookEmbeddableConfiguration = {
|
||||||
|
/** Tabs to display in the embed (if enabled on the site). */
|
||||||
|
tabs: ('assistant' | 'docs')[];
|
||||||
|
|
||||||
|
/** Additional buttons to be displayed in the header of the GitBook embed. */
|
||||||
|
actions: GitBookEmbeddableActionDefinition[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Buttons to be displayed in the header of the embeddable GitBook.
|
* Additional buttons to be displayed in the header of the GitBook embed.
|
||||||
|
* @deprecated Use `actions` instead.
|
||||||
*/
|
*/
|
||||||
buttons: GitBookEmbeddableButtonDefinition[];
|
buttons?: GitBookEmbeddableActionDefinition[];
|
||||||
|
|
||||||
/** Message to be displayed in the welcome page. */
|
/** Message to be displayed in the welcome page. */
|
||||||
welcomeMessage: string;
|
greeting: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
/** Suggestions of questions to be displayed in the welcome page. */
|
/** Suggestions of questions to be displayed in the welcome page. */
|
||||||
suggestions: string[];
|
suggestions: string[];
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './client';
|
export * from './client';
|
||||||
|
export * from './standalone';
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type {
|
import type {
|
||||||
GetFrameURLOptions,
|
GetFrameURLOptions,
|
||||||
GitBookEmbeddableConfiguration,
|
GitBookEmbeddableConfiguration,
|
||||||
@@ -15,28 +17,29 @@ export type GitBookFrameProps = {
|
|||||||
* Render a frame with the GitBook Assistant in it.
|
* Render a frame with the GitBook Assistant in it.
|
||||||
*/
|
*/
|
||||||
export function GitBookFrame(props: GitBookFrameProps) {
|
export function GitBookFrame(props: GitBookFrameProps) {
|
||||||
const { className, visitor, buttons, welcomeMessage, suggestions, tools } = props;
|
const { className, visitor, actions, greeting, suggestions, tools } = props;
|
||||||
|
|
||||||
const frameRef = React.useRef<HTMLIFrameElement>(null);
|
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||||
const gitbook = useGitBook();
|
const gitbook = useGitBook();
|
||||||
const [gitbookFrame, setGitbookFrame] = React.useState<GitBookFrameClient | null>(null);
|
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
|
||||||
|
|
||||||
const frameURL = React.useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (frameRef.current) {
|
if (frameRef.current) {
|
||||||
setGitbookFrame(gitbook.createFrame(frameRef.current));
|
setGitbookFrame(gitbook.createFrame(frameRef.current));
|
||||||
}
|
}
|
||||||
}, [gitbook]);
|
}, [gitbook]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
gitbookFrame?.configure({
|
gitbookFrame?.configure({
|
||||||
buttons,
|
tabs: ['assistant', 'docs'],
|
||||||
welcomeMessage,
|
actions,
|
||||||
|
greeting,
|
||||||
suggestions,
|
suggestions,
|
||||||
tools,
|
tools,
|
||||||
});
|
});
|
||||||
}, [gitbookFrame, buttons, welcomeMessage, suggestions, tools]);
|
}, [gitbookFrame, actions, greeting, suggestions, tools]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
|
|||||||
@@ -31,24 +31,50 @@ type StandaloneCalls =
|
|||||||
// Clear the chat
|
// Clear the chat
|
||||||
| ['clearChat']
|
| ['clearChat']
|
||||||
// Configure the embed
|
// Configure the embed
|
||||||
| ['configure', Partial<GitBookEmbeddableConfiguration>]
|
| ['configure', Partial<GitBookEmbeddableConfiguration & StandaloneConfiguration>]
|
||||||
// Navigate to a page
|
// Navigate to a page
|
||||||
| ['navigateToPage', string]
|
| ['navigateToPage', string]
|
||||||
// Navigate to the assistant
|
// Navigate to the assistant
|
||||||
| ['navigateToAssistant'];
|
| ['navigateToAssistant'];
|
||||||
|
|
||||||
|
type StandaloneConfiguration = {
|
||||||
|
/** Configure the button to open the embed */
|
||||||
|
button: {
|
||||||
|
/** Label to be displayed in the button. */
|
||||||
|
label: string;
|
||||||
|
/** Icon to be displayed in the button. */
|
||||||
|
icon: 'assistant' | 'sparkle' | 'help' | 'book';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
|
export type GitBookStandalone = ((...args: StandaloneCalls) => void) & {
|
||||||
q?: StandaloneCalls[];
|
q?: StandaloneCalls[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let widgetIframe: HTMLIFrameElement | undefined;
|
||||||
|
let _client: GitBookClient | undefined;
|
||||||
|
let _frame: GitBookFrameClient | undefined;
|
||||||
|
let frameOptions: GetFrameURLOptions | undefined;
|
||||||
|
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
|
||||||
|
button: {
|
||||||
|
label: 'Ask',
|
||||||
|
icon: 'assistant',
|
||||||
|
},
|
||||||
|
actions: [],
|
||||||
|
greeting: { title: '', subtitle: '' },
|
||||||
|
suggestions: [],
|
||||||
|
tools: [],
|
||||||
|
tabs: ['assistant', 'docs'],
|
||||||
|
};
|
||||||
|
|
||||||
const widgetButton = document.createElement('button');
|
const widgetButton = document.createElement('button');
|
||||||
widgetButton.id = 'gitbook-widget-button';
|
widgetButton.id = 'gitbook-widget-button';
|
||||||
widgetButton.addEventListener('click', () => {
|
widgetButton.addEventListener('click', () => {
|
||||||
GitBook('toggle');
|
GitBook('toggle');
|
||||||
});
|
});
|
||||||
widgetButton.innerHTML = `
|
widgetButton.innerHTML = `
|
||||||
<span id="gitbook-widget-button-icon"></span>
|
<span id="gitbook-widget-button-icon" data-icon="${frameConfiguration.button.icon}"></span>
|
||||||
<span id="gitbook-widget-button-label">Ask</span>
|
<span id="gitbook-widget-button-label">${frameConfiguration.button.label}</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const widgetWindow = document.createElement('div');
|
const widgetWindow = document.createElement('div');
|
||||||
@@ -58,17 +84,6 @@ widgetWindow.classList.add('hidden');
|
|||||||
document.body.appendChild(widgetButton);
|
document.body.appendChild(widgetButton);
|
||||||
document.body.appendChild(widgetWindow);
|
document.body.appendChild(widgetWindow);
|
||||||
|
|
||||||
let widgetIframe: HTMLIFrameElement | undefined;
|
|
||||||
let _client: GitBookClient | undefined;
|
|
||||||
let _frame: GitBookFrameClient | undefined;
|
|
||||||
let frameOptions: GetFrameURLOptions | undefined;
|
|
||||||
let frameConfiguration: GitBookEmbeddableConfiguration = {
|
|
||||||
buttons: [],
|
|
||||||
welcomeMessage: '',
|
|
||||||
suggestions: [],
|
|
||||||
tools: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
function getClient() {
|
function getClient() {
|
||||||
if (!_client) {
|
if (!_client) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -135,27 +150,31 @@ const GitBook = (...args: StandaloneCalls) => {
|
|||||||
case 'postUserMessage':
|
case 'postUserMessage':
|
||||||
getIframe().frame.postUserMessage(args[1]);
|
getIframe().frame.postUserMessage(args[1]);
|
||||||
break;
|
break;
|
||||||
case 'configure':
|
case 'configure': {
|
||||||
|
const settings = args[1];
|
||||||
frameConfiguration = {
|
frameConfiguration = {
|
||||||
...frameConfiguration,
|
...frameConfiguration,
|
||||||
...args[1],
|
...settings,
|
||||||
};
|
};
|
||||||
|
// Update the button label and icon
|
||||||
|
if (settings.button?.label) {
|
||||||
|
const label = widgetButton.querySelector('#gitbook-widget-button-label');
|
||||||
|
if (label) {
|
||||||
|
label.textContent = settings.button.label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (settings.button?.icon) {
|
||||||
|
const icon = widgetButton.querySelector('#gitbook-widget-button-icon');
|
||||||
|
if (icon) {
|
||||||
|
icon.setAttribute('data-icon', settings.button.icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getIframe().frame.configure({
|
getIframe().frame.configure({
|
||||||
...frameConfiguration,
|
...frameConfiguration,
|
||||||
buttons: [
|
|
||||||
...frameConfiguration.buttons,
|
|
||||||
|
|
||||||
// Always include a close button
|
|
||||||
{
|
|
||||||
icon: 'close',
|
|
||||||
label: 'Close',
|
|
||||||
onClick: () => {
|
|
||||||
GitBook('close');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'clearChat':
|
case 'clearChat':
|
||||||
getIframe().frame.clearChat();
|
getIframe().frame.clearChat();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -29,6 +29,16 @@
|
|||||||
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--gitbook-widget-text-color: #FFFFFF;
|
||||||
|
--gitbook-widget-border-color: #202020;
|
||||||
|
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
|
||||||
|
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
|
||||||
|
--gitbook-widget-background-solid: #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -103,6 +113,18 @@
|
|||||||
background-color: currentColor;
|
background-color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button-icon[data-icon="sparkle"] {
|
||||||
|
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/sparkle.svg?v=2&token=a463935e93");
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button-icon[data-icon="help"] {
|
||||||
|
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/circle-question.svg?v=2&token=a463935e93");
|
||||||
|
}
|
||||||
|
|
||||||
|
#gitbook-widget-button-icon[data-icon="book"] {
|
||||||
|
mask-image: url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/book-open.svg?v=2&token=a463935e93");
|
||||||
|
}
|
||||||
|
|
||||||
#gitbook-widget-button.open #gitbook-widget-button-icon {
|
#gitbook-widget-button.open #gitbook-widget-button-icon {
|
||||||
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
|
mask-image: url('https://ka-p.fontawesome.com/releases/v6.6.0/svgs/regular/close.svg?v=2&token=a463935e93');
|
||||||
}
|
}
|
||||||
@@ -120,7 +142,8 @@
|
|||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
|
width: calc(min(var(--gitbook-widget-window-width), calc(100vw - var(--gitbook-widget-right) - var(--gitbook-widget-left))));
|
||||||
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
|
height: calc(min(var(--gitbook-widget-window-height), calc(100vh - var(--gitbook-widget-window-bottom) - var(--gitbook-widget-top))));
|
||||||
background-color: var(--gitbook-widget-background-solid);
|
background-color: var(--gitbook-widget-background-translucent);
|
||||||
|
backdrop-filter: blur(48px);
|
||||||
border: 1px solid var(--gitbook-widget-border-color);
|
border: 1px solid var(--gitbook-widget-border-color);
|
||||||
border-radius: var(--gitbook-widget-radius);
|
border-radius: var(--gitbook-widget-radius);
|
||||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"types": []
|
"types": [
|
||||||
|
"bun-types" // add Bun global
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { defineConfig } from 'tsdown';
|
|||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
{
|
{
|
||||||
entry: ['src/index.ts', 'src/react/index.ts'],
|
entry: 'src/index.ts',
|
||||||
dts: true,
|
outDir: 'dist',
|
||||||
format: ['esm'],
|
},
|
||||||
|
{
|
||||||
|
entry: 'src/react/index.ts',
|
||||||
|
outDir: 'dist/react',
|
||||||
|
unbundle: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @gitbook/emoji-codepoints
|
# @gitbook/emoji-codepoints
|
||||||
|
|
||||||
|
## 0.2.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||||
|
|
||||||
## 0.2.1
|
## 0.2.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@gitbook/emoji-codepoints",
|
"name": "@gitbook/emoji-codepoints",
|
||||||
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
|
"description": "Optimized mapping of codepoints to the fully qualified emoji codepoints",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"exports": "./dist/index.ts",
|
"exports": "./dist/index.ts",
|
||||||
"dependencies": {},
|
"sideEffects": false,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"emoji-assets": "^8.0.0"
|
"emoji-assets": "^9.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "bun ./build.ts",
|
"generate": "bun ./build.ts",
|
||||||
"clean": "rm -rf ./dist"
|
"clean": "rm -rf ./dist"
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public",
|
|
||||||
"registry": "https://registry.npmjs.org/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# @gitbook/expr
|
# @gitbook/expr
|
||||||
|
|
||||||
|
## 1.2.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 10995e0: Use NPM Trusted publishing for publishing the package.
|
||||||
|
- 10995e0: Use NPM dependency for eval-estree-expression
|
||||||
|
|
||||||
|
## 1.2.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||||
|
|
||||||
## 1.2.2
|
## 1.2.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,42 +1,47 @@
|
|||||||
{
|
{
|
||||||
"name": "@gitbook/expr",
|
"name": "@gitbook/expr",
|
||||||
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
"description": "Safely evaluate & parse user-defined GitBook expressions.",
|
||||||
"version": "1.2.2",
|
"version": "1.2.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.js",
|
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#9cf28d2",
|
|
||||||
"acorn": "^8.14.0",
|
"acorn": "^8.14.0",
|
||||||
"acorn-loose": "8.4.0",
|
"acorn-loose": "8.4.0",
|
||||||
"acorn-walk": "^8.3.4",
|
"acorn-walk": "^8.3.4",
|
||||||
|
"assert-never": "catalog:",
|
||||||
"escodegen": "^2.1.0",
|
"escodegen": "^2.1.0",
|
||||||
"assert-never": "^1.2.1"
|
"eval-estree-expression": "github:jonschlinkert/eval-estree-expression#fb0246a"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"bun-types": "^1.1.20",
|
"@tsconfig/strictest": "catalog:",
|
||||||
"tsdown": "catalog:",
|
"@tsconfig/node20": "catalog:",
|
||||||
|
"@types/escodegen": "^0.0.10",
|
||||||
"@types/estree": "^1.0.6",
|
"@types/estree": "^1.0.6",
|
||||||
"@babel/types": "^7.26.0",
|
|
||||||
"@types/json-schema": "^7.0.15",
|
"@types/json-schema": "^7.0.15",
|
||||||
"@types/escodegen": "^0.0.10"
|
"bun-types": "catalog:",
|
||||||
|
"tsdown": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsdown",
|
"build": "tsdown",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"unit": "bun test",
|
"unit": "bun test",
|
||||||
"clean": "rm -rf ./dist",
|
"clean": "rm -rf ./dist",
|
||||||
"dev": "bun run build -- --watch ./src"
|
"dev": "bun run build -- --watch ./src",
|
||||||
|
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||||
},
|
},
|
||||||
"files": ["dist", "README.md", "CHANGELOG.md"],
|
"files": ["dist", "README.md", "CHANGELOG.md"],
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://registry.npmjs.org/"
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/GitbookIO/gitbook"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# @gitbook/fonts
|
# @gitbook/fonts
|
||||||
|
|
||||||
|
## 0.1.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 10995e0: Use NPM Trusted publishing for publishing the package.
|
||||||
|
|
||||||
|
## 0.1.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||||
|
|
||||||
## 0.1.1
|
## 0.1.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -4,32 +4,38 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"development": "./src/index.ts",
|
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.1.1",
|
"sideEffects": false,
|
||||||
|
"version": "0.1.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gitbook/api": "catalog:"
|
"@gitbook/api": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"google-font-metadata": "^6.0.3",
|
"bun-types": "catalog:",
|
||||||
"typescript": "^5.5.3"
|
"google-font-metadata": "^6.0.7",
|
||||||
|
"tsdown": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "bun ./bin/generate.js",
|
"generate": "bun ./bin/generate.js",
|
||||||
"build": "tsc --project tsconfig.build.json",
|
"build": "tsdown",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"dev": "tsc -w",
|
"dev": "bun run build -- --watch ./src",
|
||||||
"clean": "rm -rf ./dist && rm -rf ./src/data",
|
"clean": "rm -rf ./dist && rm -rf ./src/data",
|
||||||
"unit": "bun test"
|
"unit": "bun test",
|
||||||
|
"publish-to-npm": "../../scripts/publish-if-new.sh"
|
||||||
},
|
},
|
||||||
"files": ["dist", "src", "bin", "README.md", "CHANGELOG.md"],
|
"files": ["dist", "bin", "README.md", "CHANGELOG.md"],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://registry.npmjs.org/"
|
"registry": "https://registry.npmjs.org/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/GitbookIO/gitbook"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,57 @@
|
|||||||
// Bun Snapshot v1, https://goo.gl/fbAQLP
|
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||||
|
|
||||||
exports[`getDefaultFont should return correct object for Latin text 1`] = `
|
exports[`getDefaultFont should return correct object for Latin text 1`] = `
|
||||||
{
|
{
|
||||||
"font": "Inter",
|
"font": "Inter",
|
||||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
|
exports[`getDefaultFont should return correct object for Cyrillic text 1`] = `
|
||||||
{
|
{
|
||||||
"font": "Inter",
|
"font": "Inter",
|
||||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`getDefaultFont should return correct object for Greek text 1`] = `
|
exports[`getDefaultFont should return correct object for Greek text 1`] = `
|
||||||
{
|
{
|
||||||
"font": "Inter",
|
"font": "Inter",
|
||||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
|
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZxhjp-Ek-_0ewmM.woff",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`getDefaultFont should handle mixed script text 1`] = `
|
exports[`getDefaultFont should handle mixed script text 1`] = `
|
||||||
{
|
{
|
||||||
"font": "Inter",
|
"font": "Inter",
|
||||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZthjp-Ek-_0ewmM.woff",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`getDefaultFont should handle different font weights: regular 1`] = `
|
exports[`getDefaultFont should handle different font weights: regular 1`] = `
|
||||||
{
|
{
|
||||||
"font": "Inter",
|
"font": "Inter",
|
||||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`getDefaultFont should handle different font weights: bold 1`] = `
|
exports[`getDefaultFont should handle different font weights: bold 1`] = `
|
||||||
{
|
{
|
||||||
"font": "Inter",
|
"font": "Inter",
|
||||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
|
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYAZ9hjp-Ek-_0ew.woff",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`getDefaultFont should handle different fonts: inter 1`] = `
|
exports[`getDefaultFont should handle different fonts: inter 1`] = `
|
||||||
{
|
{
|
||||||
"font": "Inter",
|
"font": "Inter",
|
||||||
"url": "https://fonts.gstatic.com/s/inter/v18/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
"url": "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfAZ9hjp-Ek-_0ew.woff",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`getDefaultFont should handle different fonts: roboto 1`] = `
|
exports[`getDefaultFont should handle different fonts: roboto 1`] = `
|
||||||
{
|
{
|
||||||
"font": "Roboto",
|
"font": "Roboto",
|
||||||
"url": "https://fonts.gstatic.com/s/roboto/v32/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff",
|
"url": "https://fonts.gstatic.com/s/roboto/v49/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAz0klQm_20.woff",
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"exclude": ["node_modules", "src/**/*.test.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,148 @@
|
|||||||
# gitbook
|
# gitbook
|
||||||
|
|
||||||
|
## 0.21.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [bf63045]
|
||||||
|
- @gitbook/react-openapi@1.5.6
|
||||||
|
|
||||||
|
## 0.21.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- ca68236: Fix 404 when accessing rss.xml for the root page.
|
||||||
|
- 21851d3: Round page cover corners on extrawide screens
|
||||||
|
- 695e99f: Update button sizing
|
||||||
|
- 7517e98: Clean up button and input across the app
|
||||||
|
- 99d788e: Update page cover styling
|
||||||
|
- ebf3b2b: Fix an issue where content refs to spaces outside of site was resolving with share links
|
||||||
|
- 35efc9d: Fix TOCPageIcons backgrounds on gradient and bold themes
|
||||||
|
- a7f58ab: Fix embed tab button size regression
|
||||||
|
- 360b525: Switch icon masking method to fix visual cutoffs in FA7
|
||||||
|
- Updated dependencies [985c1b4]
|
||||||
|
- Updated dependencies [360b525]
|
||||||
|
- @gitbook/openapi-parser@3.0.7
|
||||||
|
- @gitbook/icons@0.4.2
|
||||||
|
- @gitbook/react-openapi@1.5.5
|
||||||
|
- @gitbook/react-contentkit@0.7.11
|
||||||
|
|
||||||
|
## 0.21.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [e7fb771]
|
||||||
|
- @gitbook/icons@0.4.1
|
||||||
|
- @gitbook/react-contentkit@0.7.10
|
||||||
|
|
||||||
|
## 0.21.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 3553db0: Add a RSS feed to all pages with update blocks.
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- f478ddc: Add Input component
|
||||||
|
- 3817289: Fix overflowing section groups
|
||||||
|
- 86e7aa6: Fix search results cursor
|
||||||
|
- 1430cee: Improve Docs Embed with separate Assistant and Docs tabs
|
||||||
|
- ed9e8fe: Add support for button actions
|
||||||
|
- 9c69cc4: Add customizable suggested questions
|
||||||
|
- c51076e: Fix links to other spaces and root page in embeddable view.
|
||||||
|
- 6278a18: Support `greeting` and fix suggested questions if there are no custom ones defined
|
||||||
|
- 04a4175: Remove hidden site spaces from navigation
|
||||||
|
- 74f8931: Fix hidden section not found
|
||||||
|
- 344842f: Improve OpenAPI circular references
|
||||||
|
- Updated dependencies [4766092]
|
||||||
|
- Updated dependencies [8761cee]
|
||||||
|
- Updated dependencies [461e15f]
|
||||||
|
- Updated dependencies [1430cee]
|
||||||
|
- Updated dependencies [62438f4]
|
||||||
|
- Updated dependencies [3e40b4d]
|
||||||
|
- Updated dependencies [87d68ea]
|
||||||
|
- Updated dependencies [344842f]
|
||||||
|
- @gitbook/react-openapi@1.5.4
|
||||||
|
- @gitbook/openapi-parser@3.0.6
|
||||||
|
- @gitbook/embed@0.2.0
|
||||||
|
- @gitbook/icons@0.4.0
|
||||||
|
- @gitbook/react-contentkit@0.7.9
|
||||||
|
|
||||||
|
## 0.20.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 1d92dc1: Update site layout and blocks with container queries and better transitions
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8fdc5c8: Fix webframe height issue
|
||||||
|
- 9022d87: Add breadcrumbs to search results
|
||||||
|
- 0c01832: SearchInput fixes — Translate Clear button, Bold theme color, "Esc" keyboard shortcut
|
||||||
|
- 1d1f0a7: Fix search container shadow
|
||||||
|
- 2aa4f2e: Update dropdown menu styling
|
||||||
|
- a49781e: Printable collapsed code block
|
||||||
|
- 7d6a316: Revert contentref anchor title
|
||||||
|
- e27986b: Reword "Everywhere" search scope to "All docs"
|
||||||
|
- 758b34d: Move the "Ask <query>" to the bottom of search results if query is not a question
|
||||||
|
- 0ae7cfd: Improve AI Search branding
|
||||||
|
- f9f8011: Add alt text support to card covers
|
||||||
|
- ebbb476: Improve ancestor label in references
|
||||||
|
- 1b0613e: Tweak language dropdown styling
|
||||||
|
- 3355960: Fix ToggeableLinkItem display
|
||||||
|
- c31e564: Fix icon resolution for page group
|
||||||
|
- 55c0b03: Support translations and generic variants together
|
||||||
|
- 3876f70: Edgeless Assistant window on `depth: flat` sites
|
||||||
|
- 683bb8f: Add confirmation to "Copy MCP URL" page action
|
||||||
|
- 4534ea8: Implement unique title tags for sections & variants
|
||||||
|
- 6d56df7: Treat pages without visible TOC items as `no-toc`
|
||||||
|
- 8606f22: Add padding to TOC page group items
|
||||||
|
- 3676d64: Update search scope UI
|
||||||
|
- c32d449: Fix OpenAPI path display
|
||||||
|
- 8ce7322: Add OpenAPI servers selection
|
||||||
|
- 2c3066e: Improve OAuth2 scopes handling in OpenAPI
|
||||||
|
- 6b1c9fe: Fix opacity of ScrollContainer buttons on bold themes
|
||||||
|
- Updated dependencies [b4a021a]
|
||||||
|
- Updated dependencies [a512c90]
|
||||||
|
- Updated dependencies [df1966d]
|
||||||
|
- Updated dependencies [b45feaf]
|
||||||
|
- Updated dependencies [10995e0]
|
||||||
|
- Updated dependencies [f9f8011]
|
||||||
|
- Updated dependencies [10995e0]
|
||||||
|
- Updated dependencies [8ce7322]
|
||||||
|
- Updated dependencies [2c3066e]
|
||||||
|
- @gitbook/react-openapi@1.5.3
|
||||||
|
- @gitbook/openapi-parser@3.0.5
|
||||||
|
- @gitbook/react-contentkit@0.7.8
|
||||||
|
- @gitbook/browser-types@0.1.3
|
||||||
|
- @gitbook/cache-tags@0.3.4
|
||||||
|
- @gitbook/react-math@0.6.4
|
||||||
|
- @gitbook/colors@0.4.3
|
||||||
|
- @gitbook/embed@0.1.5
|
||||||
|
- @gitbook/fonts@0.1.3
|
||||||
|
- @gitbook/icons@0.3.4
|
||||||
|
- @gitbook/expr@1.2.4
|
||||||
|
|
||||||
|
## 0.19.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6142d6b: Mark as sideEffects, fix all package bundles
|
||||||
|
- 75543b1: Update minimum Node version in .npmrc
|
||||||
|
- 511f1cf: Add scroll margin to Expandable
|
||||||
|
- Updated dependencies [6142d6b]
|
||||||
|
- @gitbook/emoji-codepoints@0.2.2
|
||||||
|
- @gitbook/react-contentkit@0.7.7
|
||||||
|
- @gitbook/openapi-parser@3.0.4
|
||||||
|
- @gitbook/browser-types@0.1.2
|
||||||
|
- @gitbook/react-openapi@1.5.2
|
||||||
|
- @gitbook/cache-tags@0.3.3
|
||||||
|
- @gitbook/react-math@0.6.3
|
||||||
|
- @gitbook/colors@0.4.2
|
||||||
|
- @gitbook/fonts@0.1.2
|
||||||
|
- @gitbook/icons@0.3.3
|
||||||
|
- @gitbook/expr@1.2.3
|
||||||
|
|
||||||
## 0.19.1
|
## 0.19.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -10,18 +10,18 @@ const testCases: TestsCase[] = [
|
|||||||
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
|
{ name: 'OpenAPI', url: '/snyk-api/reference/apps', run: waitForCookiesDialog },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// {
|
{
|
||||||
// name: 'Nexthink',
|
name: 'Nexthink',
|
||||||
// contentBaseURL: 'https://docs.nexthink.com',
|
contentBaseURL: 'https://docs.nexthink.com',
|
||||||
// tests: [
|
tests: [
|
||||||
// {
|
{
|
||||||
// name: 'Home',
|
name: 'Home',
|
||||||
// url: '/',
|
url: '/',
|
||||||
// screenshot: { waitForTOCScrolling: false },
|
screenshot: { waitForTOCScrolling: false },
|
||||||
// run: waitForCookiesDialog,
|
run: waitForCookiesDialog,
|
||||||
// },
|
},
|
||||||
// ],
|
],
|
||||||
// },
|
},
|
||||||
{
|
{
|
||||||
name: 'asiksupport-stg.dto.kemkes.go.id',
|
name: 'asiksupport-stg.dto.kemkes.go.id',
|
||||||
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
|
contentBaseURL: 'https://asiksupport-stg.dto.kemkes.go.id',
|
||||||
@@ -37,11 +37,6 @@ const testCases: TestsCase[] = [
|
|||||||
contentBaseURL: 'https://faq.deltaemulator.com',
|
contentBaseURL: 'https://faq.deltaemulator.com',
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'docs.dify.ai',
|
|
||||||
contentBaseURL: 'https://docs.dify.ai',
|
|
||||||
tests: [{ name: 'Home', url: '/', run: waitForCookiesDialog }],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'seeddao.gitbook.io',
|
name: 'seeddao.gitbook.io',
|
||||||
contentBaseURL: 'https://seeddao.gitbook.io',
|
contentBaseURL: 'https://seeddao.gitbook.io',
|
||||||
@@ -112,11 +107,11 @@ const testCases: TestsCase[] = [
|
|||||||
contentBaseURL: 'https://adiblar.gitbook.io',
|
contentBaseURL: 'https://adiblar.gitbook.io',
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
name: 'docs.gradient.network',
|
// name: 'docs.gradient.network',
|
||||||
contentBaseURL: 'https://docs.gradient.network',
|
// contentBaseURL: 'https://docs.gradient.network',
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
// tests: [{ name: 'Home', url: '/' }],
|
||||||
},
|
// },
|
||||||
// {
|
// {
|
||||||
// name: 'mygate-network.gitbook.io',
|
// name: 'mygate-network.gitbook.io',
|
||||||
// contentBaseURL: 'https://mygate-network.gitbook.io',
|
// contentBaseURL: 'https://mygate-network.gitbook.io',
|
||||||
@@ -172,11 +167,6 @@ const testCases: TestsCase[] = [
|
|||||||
contentBaseURL: 'https://docs.realapp.link',
|
contentBaseURL: 'https://docs.realapp.link',
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
tests: [{ name: 'Home', url: '/' }],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'docs.plaza.finance',
|
|
||||||
contentBaseURL: 'https://docs.plaza.finance',
|
|
||||||
tests: [{ name: 'Home', url: '/' }],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'docs.publicai.io',
|
name: 'docs.publicai.io',
|
||||||
contentBaseURL: 'https://docs.publicai.io',
|
contentBaseURL: 'https://docs.publicai.io',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
CustomizationSidebarListStyle,
|
CustomizationSidebarListStyle,
|
||||||
CustomizationThemeMode,
|
CustomizationThemeMode,
|
||||||
} from '@gitbook/api';
|
} from '@gitbook/api';
|
||||||
|
import type { GitBookStandalone } from '@gitbook/embed';
|
||||||
import { expect } from '@playwright/test';
|
import { expect } from '@playwright/test';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
@@ -33,9 +34,35 @@ import {
|
|||||||
headerLinks,
|
headerLinks,
|
||||||
runTestCases,
|
runTestCases,
|
||||||
waitForCookiesDialog,
|
waitForCookiesDialog,
|
||||||
|
waitForCoverImages,
|
||||||
waitForNotFound,
|
waitForNotFound,
|
||||||
} from './util';
|
} from './util';
|
||||||
|
|
||||||
|
const AI_PROMPT = `You're being invoked by the GitBook CI/CD pipeline. To make screenshot testing of the GitBook Assistant visually consistent, look up the title of the first page you find and respond with only EXACTLY its title. To find the page title, invoke the search tool with the query "GitBook". Before invoking the search tool, respond with the exact text: "I'm going to look up 'GitBook' and then respond with only the page title.". Do not execute any other tools or output any other text.`;
|
||||||
|
|
||||||
|
const overrideAIInitialState = () => {
|
||||||
|
const greeting = document.querySelector('[data-testid="ai-chat-greeting-title"]');
|
||||||
|
if (greeting) {
|
||||||
|
greeting.textContent = 'Good morning';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const overrideAIResponse = () => {
|
||||||
|
const userMessage = document.querySelector('[data-testid="ai-chat-message-user"]');
|
||||||
|
if (userMessage) {
|
||||||
|
userMessage.textContent = '[Replaced message] Chat message sent by the user';
|
||||||
|
}
|
||||||
|
const assistantMessage = document.querySelectorAll(
|
||||||
|
'[data-testid="ai-chat-message-assistant"] .ai-response-document'
|
||||||
|
);
|
||||||
|
assistantMessage.forEach((message) => {
|
||||||
|
message.innerHTML = '[Replaced message] AI chat response';
|
||||||
|
});
|
||||||
|
const suggestions = document.querySelectorAll('[data-testid="ai-chat-followup-suggestion"]');
|
||||||
|
suggestions.forEach((suggestion) => {
|
||||||
|
suggestion.textContent = 'Follow-up suggestion';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const searchTestCases: Test[] = [
|
const searchTestCases: Test[] = [
|
||||||
{
|
{
|
||||||
name: 'Search - AI Mode: None - Complete flow',
|
name: 'Search - AI Mode: None - Complete flow',
|
||||||
@@ -52,7 +79,9 @@ const searchTestCases: Test[] = [
|
|||||||
|
|
||||||
// Fill search input, expecting search results
|
// Fill search input, expecting search results
|
||||||
await searchInput.fill('gitbook');
|
await searchInput.fill('gitbook');
|
||||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
await expect(page.getByTestId('search-results')).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
const pageResults = await page.getByTestId('search-page-result').all();
|
const pageResults = await page.getByTestId('search-page-result').all();
|
||||||
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
await expect(pageResults.length).toBeGreaterThanOrEqual(1);
|
||||||
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
|
const pageSectionResults = await page.getByTestId('search-page-section-result').all();
|
||||||
@@ -97,9 +126,6 @@ const searchTestCases: Test[] = [
|
|||||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
|
|
||||||
// - Search - AI Mode: Search - Complete flow
|
|
||||||
// - Search - AI Mode: Search - URL query (Initial)
|
|
||||||
{
|
{
|
||||||
name: 'Search - AI Mode: Search - URL query (Results)',
|
name: 'Search - AI Mode: Search - URL query (Results)',
|
||||||
url: `${getCustomizationURL({
|
url: `${getCustomizationURL({
|
||||||
@@ -107,16 +133,12 @@ const searchTestCases: Test[] = [
|
|||||||
mode: CustomizationAIMode.Search,
|
mode: CustomizationAIMode.Search,
|
||||||
},
|
},
|
||||||
})}&q=gitbook`,
|
})}&q=gitbook`,
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
await expect(page.getByTestId('search-input')).toBeFocused();
|
await expect(page.getByTestId('search-input')).toBeFocused();
|
||||||
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
await expect(page.getByTestId('search-input')).toHaveValue('gitbook');
|
||||||
await expect(page.getByTestId('search-results')).toBeVisible();
|
await expect(page.getByTestId('search-results')).toBeVisible();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// TODO: Re-enable the following tests when we have fixed the AI Search timing out:
|
|
||||||
// - Ask - AI Mode: Search - URL query (Ask initial)
|
|
||||||
// - Ask - AI Mode: Search - URL query (Ask results)
|
|
||||||
{
|
{
|
||||||
name: 'Ask - AI Mode: Assistant - Complete flow',
|
name: 'Ask - AI Mode: Assistant - Complete flow',
|
||||||
url: getCustomizationURL({
|
url: getCustomizationURL({
|
||||||
@@ -124,25 +146,32 @@ const searchTestCases: Test[] = [
|
|||||||
mode: CustomizationAIMode.Assistant,
|
mode: CustomizationAIMode.Assistant,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
const searchInput = page.locator('css=[data-testid="search-input"]');
|
const searchInput = page.locator('css=[data-testid="search-input"]');
|
||||||
|
|
||||||
// Focus search input, expecting recommended questions
|
// Focus search input, expecting recommended questions
|
||||||
await searchInput.focus();
|
await searchInput.focus();
|
||||||
// TODO: Re-enable this part of the test when we have fixed the AI Search timing out
|
await expect(page.getByTestId('search-results')).toBeVisible({
|
||||||
// await expect(page.getByTestId('search-results')).toBeVisible();
|
timeout: 30_000,
|
||||||
// const recommendedQuestions = await page
|
});
|
||||||
// .getByTestId('search-recommended-question')
|
const recommendedQuestions = await page
|
||||||
// .all();
|
.getByTestId('search-recommended-question')
|
||||||
// await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
.all();
|
||||||
|
await expect(recommendedQuestions.length).toBeGreaterThan(2); // Expect at least 3 questions
|
||||||
|
|
||||||
// Fill search input, expecting AI search option
|
// Fill search input, expecting AI search option
|
||||||
await searchInput.fill('What is gitbook?');
|
await searchInput.fill(AI_PROMPT);
|
||||||
const aiSearchResult = page.getByTestId('search-ask-question');
|
const aiSearchResult = page.getByTestId('search-ask-question');
|
||||||
await expect(aiSearchResult).toBeVisible();
|
await expect(aiSearchResult).toBeVisible();
|
||||||
await aiSearchResult.click();
|
await aiSearchResult.click();
|
||||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
|
||||||
|
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
|
||||||
|
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
// Override text content for visual consistency in screenshots
|
||||||
|
await page.evaluate(overrideAIResponse);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,11 +181,12 @@ const searchTestCases: Test[] = [
|
|||||||
mode: CustomizationAIMode.Assistant,
|
mode: CustomizationAIMode.Assistant,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
await page.keyboard.press('ControlOrMeta+I');
|
await page.keyboard.press('ControlOrMeta+I');
|
||||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||||
|
// Override text content for visual consistency in screenshots
|
||||||
|
await page.evaluate(overrideAIInitialState);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -171,6 +201,8 @@ const searchTestCases: Test[] = [
|
|||||||
await page.getByTestId('ai-chat-button').click();
|
await page.getByTestId('ai-chat-button').click();
|
||||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||||
|
// Override text content for visual consistency in screenshots
|
||||||
|
await page.evaluate(overrideAIInitialState);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -180,12 +212,13 @@ const searchTestCases: Test[] = [
|
|||||||
mode: CustomizationAIMode.Assistant,
|
mode: CustomizationAIMode.Assistant,
|
||||||
},
|
},
|
||||||
})}&ask=`,
|
})}&ask=`,
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||||
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
await expect(page.getByTestId('search-input')).toBeEmpty();
|
||||||
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||||
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
await expect(page.getByTestId('ai-chat-input')).toBeFocused();
|
||||||
|
// Override text content for visual consistency in screenshots
|
||||||
|
await page.evaluate(overrideAIInitialState);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -194,17 +227,18 @@ const searchTestCases: Test[] = [
|
|||||||
ai: {
|
ai: {
|
||||||
mode: CustomizationAIMode.Assistant,
|
mode: CustomizationAIMode.Assistant,
|
||||||
},
|
},
|
||||||
})}&ask=What+is+GitBook%3F`,
|
})}&ask=${encodeURIComponent(AI_PROMPT)}`,
|
||||||
screenshot: false,
|
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
await expect(page.getByTestId('search-input')).not.toBeFocused();
|
||||||
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
await expect(page.getByTestId('search-input')).not.toHaveValue('What is GitBook?');
|
||||||
await expect(page.getByTestId('ai-chat')).toBeVisible({
|
await expect(page.getByTestId('ai-chat')).toBeVisible();
|
||||||
timeout: 15_000,
|
await expect(page.getByTestId('ai-chat-message-user').first()).toHaveText(AI_PROMPT);
|
||||||
|
await expect(page.getByTestId('ai-chat-message-assistant').first()).toBeVisible();
|
||||||
|
await expect(page.getByTestId('ai-chat-followup-suggestion')).toHaveCount(3, {
|
||||||
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId('ai-chat-message').first()).toHaveText(
|
// Override text content for visual consistency in screenshots
|
||||||
'What is GitBook?'
|
await page.evaluate(overrideAIResponse);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -228,6 +262,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 button inside the link
|
||||||
|
const editorChevron = page
|
||||||
|
.getByRole('link', { name: 'Editor' })
|
||||||
|
.locator('button');
|
||||||
|
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 button inside the link
|
||||||
|
const editorChevron = page
|
||||||
|
.getByRole('link', { name: 'Editor' })
|
||||||
|
.locator('button');
|
||||||
|
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('button');
|
||||||
|
await contentStructureChevron.click();
|
||||||
|
|
||||||
|
// Verify "Spaces" link becomes visible after expansion
|
||||||
|
await expect(navigationLink).toBeVisible();
|
||||||
|
},
|
||||||
|
},
|
||||||
...searchTestCases,
|
...searchTestCases,
|
||||||
{
|
{
|
||||||
name: 'Not found',
|
name: 'Not found',
|
||||||
@@ -285,6 +371,37 @@ const testCases: TestsCase[] = [
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Switch variant with alternate link in metadata',
|
||||||
|
url: 'rfcs',
|
||||||
|
run: async (page) => {
|
||||||
|
const spaceDropdown = page
|
||||||
|
.locator('[data-testid="space-dropdown-button"]')
|
||||||
|
.locator('visible=true');
|
||||||
|
await spaceDropdown.click();
|
||||||
|
|
||||||
|
const variantSelectionDropdown = page.locator(
|
||||||
|
'css=[data-testid="dropdown-menu"]'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the variant space called 'Multi-Variants' for which
|
||||||
|
// there is an alternate link in the current (RFC variant) page metadata
|
||||||
|
await variantSelectionDropdown
|
||||||
|
.getByRole('menuitem', {
|
||||||
|
name: 'Multi-Variants',
|
||||||
|
})
|
||||||
|
.click();
|
||||||
|
|
||||||
|
// It should navigate to the alternate link defined in the metadata (a completely different page)
|
||||||
|
await page.waitForURL((url) =>
|
||||||
|
url.pathname.includes('multi-variants/reference/api-reference/pets')
|
||||||
|
);
|
||||||
|
// Verify we are on the correct page by checking the h1
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { level: 1, name: 'Pets' })
|
||||||
|
).toBeVisible();
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -560,70 +677,6 @@ const testCases: TestsCase[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Markdown page',
|
|
||||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
|
||||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'Text page',
|
|
||||||
url: 'text-page.md',
|
|
||||||
screenshot: false,
|
|
||||||
run: async (_page, response) => {
|
|
||||||
expect(response?.status()).toBe(200);
|
|
||||||
expect(response?.headers()['content-type']).toContain('text/markdown');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'llms.txt',
|
|
||||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
|
||||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'llms.txt',
|
|
||||||
url: 'llms.txt',
|
|
||||||
screenshot: false,
|
|
||||||
run: async (_page, response) => {
|
|
||||||
expect(response?.status()).toBe(200);
|
|
||||||
expect(response?.headers()['content-type']).toContain('text/markdown');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'llms-full.txt',
|
|
||||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
|
||||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'llms-full.txt',
|
|
||||||
url: 'llms-full.txt',
|
|
||||||
screenshot: false,
|
|
||||||
run: async (_page, response) => {
|
|
||||||
expect(response?.status()).toBe(200);
|
|
||||||
expect(response?.headers()['content-type']).toContain('text/markdown');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '[page].md',
|
|
||||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
|
||||||
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/',
|
|
||||||
tests: [
|
|
||||||
{
|
|
||||||
name: 'blocks.md',
|
|
||||||
url: 'blocks.md',
|
|
||||||
screenshot: false,
|
|
||||||
run: async (_page, response) => {
|
|
||||||
expect(response?.status()).toBe(200);
|
|
||||||
expect(response?.headers()['content-type']).toContain('text/markdown');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Site subdirectory (proxy)',
|
name: 'Site subdirectory (proxy)',
|
||||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||||
@@ -727,7 +780,10 @@ const testCases: TestsCase[] = [
|
|||||||
url: 'blocks/integrations',
|
url: 'blocks/integrations',
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
await waitForCookiesDialog(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('Mermaid', { exact: true })).toBeVisible();
|
||||||
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
|
await expect(mermaidIframe.getByText('Diagram', { exact: true })).toBeVisible();
|
||||||
},
|
},
|
||||||
@@ -793,6 +849,11 @@ const testCases: TestsCase[] = [
|
|||||||
url: 'blocks/cards',
|
url: 'blocks/cards',
|
||||||
fullPage: true,
|
fullPage: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Updates',
|
||||||
|
url: 'blocks/updates',
|
||||||
|
fullPage: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Math',
|
name: 'Math',
|
||||||
url: 'blocks/math',
|
url: 'blocks/math',
|
||||||
@@ -851,7 +912,10 @@ const testCases: TestsCase[] = [
|
|||||||
{
|
{
|
||||||
name: 'With cover',
|
name: 'With cover',
|
||||||
url: 'page-options/page-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',
|
name: 'With cover for dark mode',
|
||||||
@@ -866,12 +930,18 @@ const testCases: TestsCase[] = [
|
|||||||
{
|
{
|
||||||
name: 'With hero cover',
|
name: 'With hero cover',
|
||||||
url: 'page-options/page-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',
|
name: 'With cover and no TOC',
|
||||||
url: 'page-options/page-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: {
|
screenshot: {
|
||||||
waitForTOCScrolling: false,
|
waitForTOCScrolling: false,
|
||||||
},
|
},
|
||||||
@@ -1158,7 +1228,7 @@ const testCases: TestsCase[] = [
|
|||||||
name: 'Redirect to Quickstart page',
|
name: 'Redirect to Quickstart page',
|
||||||
url: 'sections-2/redirect-test',
|
url: 'sections-2/redirect-test',
|
||||||
run: async (page) => {
|
run: async (page) => {
|
||||||
await expect(page.locator('h1')).toHaveText('Quickstart');
|
await expect(page.locator('h1')).toContainText('Quickstart');
|
||||||
},
|
},
|
||||||
screenshot: false,
|
screenshot: false,
|
||||||
},
|
},
|
||||||
@@ -1184,6 +1254,46 @@ const testCases: TestsCase[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Navigation of links on a share links site',
|
||||||
|
contentBaseURL: 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: 'Link in same site has the share link token preserved',
|
||||||
|
url: '8tNo6MeXg7CkFMzSSz81/link-in-same-site',
|
||||||
|
run: async (page) => {
|
||||||
|
const linkToDifferentPage = page.getByRole('link', { name: 'Other Page' });
|
||||||
|
await linkToDifferentPage.click();
|
||||||
|
await page.waitForURL((url) =>
|
||||||
|
url.pathname.includes(
|
||||||
|
'/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/3.0/other-page'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { level: 1, name: 'Other Page' })
|
||||||
|
).toBeVisible();
|
||||||
|
},
|
||||||
|
screenshot: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Link to different site should not have the share link token preserved',
|
||||||
|
url: '8tNo6MeXg7CkFMzSSz81/link-in-different-site',
|
||||||
|
run: async (page) => {
|
||||||
|
const linkToDifferentSite = page.getByRole('link', { name: 'Basics' });
|
||||||
|
await linkToDifferentSite.click();
|
||||||
|
await page.waitForURL(
|
||||||
|
(url) =>
|
||||||
|
url.toString() ===
|
||||||
|
'https://gitbook-open-e2e-sites.gitbook.io/sections/sections-4/basics/editor'
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { level: 1, name: 'Editor' })
|
||||||
|
).toBeVisible();
|
||||||
|
},
|
||||||
|
screenshot: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Visitor Auth - Space',
|
name: 'Visitor Auth - Space',
|
||||||
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
|
contentBaseURL: 'https://gitbook.gitbook.io/gbo-va-space/',
|
||||||
@@ -1454,10 +1564,7 @@ const testCases: TestsCase[] = [
|
|||||||
locale,
|
locale,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
run: async (page) => {
|
run: waitForCookiesDialog,
|
||||||
const dialog = page.getByTestId('cookies-dialog');
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
},
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1816,6 +1923,324 @@ const testCases: TestsCase[] = [
|
|||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Docs Embed - Basic',
|
||||||
|
contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: 'Standalone UX',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
const button = page.locator('#gitbook-widget-button');
|
||||||
|
await expect(button).toBeVisible();
|
||||||
|
await expect(button.locator('#gitbook-widget-button-label')).toHaveText('Ask');
|
||||||
|
await expect(button.locator('#gitbook-widget-button-icon')).toHaveAttribute(
|
||||||
|
'data-icon',
|
||||||
|
'assistant'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||||
|
await button.click(); // Toggle the window off for the screenshot
|
||||||
|
await expect(page.locator('#gitbook-widget-window')).not.toBeVisible();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Change standalone button label and icon',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||||
|
GitBook('configure', {
|
||||||
|
button: {
|
||||||
|
label: 'Docs',
|
||||||
|
icon: 'book',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const button = page.locator('#gitbook-widget-button');
|
||||||
|
await expect(button).toBeVisible();
|
||||||
|
button.click();
|
||||||
|
await expect(page.locator('#gitbook-widget-window')).not.toBeVisible();
|
||||||
|
await expect(page.locator('#gitbook-widget-button-label')).toHaveText('Docs');
|
||||||
|
await expect(page.locator('#gitbook-widget-button-icon')).toHaveAttribute(
|
||||||
|
'data-icon',
|
||||||
|
'book'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Docs Embed - Assistant + Docs',
|
||||||
|
contentBaseURL: 'https://gitbook.com/docs/~gitbook/embed/demo/',
|
||||||
|
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: 'Switch between tabs',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||||
|
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||||
|
await iframe.getByTestId('embed-tab-docs').click(); // Switch to docs tab
|
||||||
|
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await iframe.getByTestId('embed-tab-assistant').click(); // Switch to assistant tab
|
||||||
|
await expect(iframe.getByTestId('ai-chat')).toBeVisible();
|
||||||
|
|
||||||
|
await iframe.owner().evaluate(overrideAIInitialState);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API - navigateToPage',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||||
|
GitBook('navigateToPage', '/getting-started/quickstart');
|
||||||
|
});
|
||||||
|
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||||
|
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||||
|
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
await expect(iframe.owner()).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
expect.stringContaining('getting-started/quickstart')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API - postUserMessage',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await page.evaluate((aiPrompt) => {
|
||||||
|
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||||
|
GitBook('postUserMessage', aiPrompt);
|
||||||
|
}, AI_PROMPT);
|
||||||
|
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||||
|
await expect(iframe.getByTestId('ai-chat')).toBeVisible();
|
||||||
|
await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText(
|
||||||
|
AI_PROMPT
|
||||||
|
);
|
||||||
|
await iframe.owner().evaluate(overrideAIResponse);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Configuration - Suggested questions',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||||
|
GitBook('configure', {
|
||||||
|
suggestions: [
|
||||||
|
'What is GitBook?',
|
||||||
|
'How do I get started?',
|
||||||
|
'What can you do?',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||||
|
await expect(
|
||||||
|
iframe.getByTestId('ai-chat-suggested-question').nth(0)
|
||||||
|
).toHaveText('What is GitBook?');
|
||||||
|
await expect(
|
||||||
|
iframe.getByTestId('ai-chat-suggested-question').nth(1)
|
||||||
|
).toHaveText('How do I get started?');
|
||||||
|
await expect(
|
||||||
|
iframe.getByTestId('ai-chat-suggested-question').nth(2)
|
||||||
|
).toHaveText('What can you do?');
|
||||||
|
await iframe.owner().evaluate(overrideAIInitialState);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Configuration - Custom action buttons',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await page.evaluate((aiPrompt) => {
|
||||||
|
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||||
|
GitBook('configure', {
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Open internal link',
|
||||||
|
icon: 'bolt',
|
||||||
|
onClick: () => {
|
||||||
|
const GitBook =
|
||||||
|
window.GitBook as unknown as GitBookStandalone;
|
||||||
|
GitBook('navigateToPage', '/getting-started/quickstart');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Open external link',
|
||||||
|
icon: 'sparkle',
|
||||||
|
onClick: () => {
|
||||||
|
window.open('https://gitbook.com', '_blank');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Post message',
|
||||||
|
icon: 'message',
|
||||||
|
onClick: () => {
|
||||||
|
const GitBook =
|
||||||
|
window.GitBook as unknown as GitBookStandalone;
|
||||||
|
GitBook('postUserMessage', aiPrompt);
|
||||||
|
GitBook('navigateToAssistant');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Close',
|
||||||
|
icon: 'xmark',
|
||||||
|
onClick: () => {
|
||||||
|
const GitBook =
|
||||||
|
window.GitBook as unknown as GitBookStandalone;
|
||||||
|
GitBook('close');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}, AI_PROMPT);
|
||||||
|
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||||
|
await expect(iframe.getByTestId('embed-action')).toHaveCount(4);
|
||||||
|
const actions = iframe.getByTestId('embed-action');
|
||||||
|
|
||||||
|
await expect(actions.nth(0)).toHaveAccessibleName('Open internal link');
|
||||||
|
await actions.nth(0).click();
|
||||||
|
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible();
|
||||||
|
await expect(iframe.owner()).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
expect.stringContaining('getting-started/quickstart')
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(actions.nth(1)).toHaveAccessibleName('Open external link');
|
||||||
|
// Intercept the new page event without navigating
|
||||||
|
const [newPage] = await Promise.all([
|
||||||
|
page.context().waitForEvent('page', { timeout: 5000 }),
|
||||||
|
actions.nth(1).click(),
|
||||||
|
]);
|
||||||
|
// Verify the new page would have opened with the expected URL
|
||||||
|
expect(newPage.url()).toContain('gitbook.com');
|
||||||
|
// Close it immediately to avoid navigation
|
||||||
|
await newPage.close();
|
||||||
|
|
||||||
|
await expect(actions.nth(2)).toHaveAccessibleName('Post message');
|
||||||
|
await actions.nth(2).click();
|
||||||
|
await expect(iframe.getByTestId('ai-chat')).toBeVisible();
|
||||||
|
await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText(
|
||||||
|
AI_PROMPT
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(actions.nth(3)).toHaveAccessibleName('Close');
|
||||||
|
await actions.nth(3).click();
|
||||||
|
await expect(page.locator('#gitbook-widget-window')).not.toBeVisible();
|
||||||
|
await page.locator('#gitbook-widget-button').click();
|
||||||
|
await iframe.owner().evaluate(overrideAIResponse);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Configuration - Custom tools',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const GitBook = window.GitBook as unknown as GitBookStandalone;
|
||||||
|
GitBook('configure', {
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'contact_support',
|
||||||
|
description: 'Contact support on behalf of the user',
|
||||||
|
execute: async () => {
|
||||||
|
return {
|
||||||
|
output: { message: 'Support message' },
|
||||||
|
summary: {
|
||||||
|
text: 'Contacted support',
|
||||||
|
icon: 'circle-question',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
confirmation: {
|
||||||
|
icon: 'circle-question',
|
||||||
|
label: 'Contact support',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
GitBook(
|
||||||
|
'postUserMessage',
|
||||||
|
'I want to contact support. Call the tool directly without a preamble. Do not respond with anything else.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||||
|
await expect(iframe.getByTestId('ai-chat-message-user').first()).toHaveText(
|
||||||
|
'I want to contact support. Call the tool directly without a preamble. Do not respond with anything else.'
|
||||||
|
);
|
||||||
|
const toolConfirmation = iframe
|
||||||
|
.getByTestId('ai-chat-tool-confirmation')
|
||||||
|
.first();
|
||||||
|
await expect(toolConfirmation).toBeVisible({
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
await iframe.owner().evaluate(overrideAIResponse);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Docs Embed - Docs Only',
|
||||||
|
contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/~gitbook/embed/demo/',
|
||||||
|
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
name: 'Docs only',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||||
|
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||||
|
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Table of contents',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||||
|
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||||
|
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
const tocButton = iframe.getByTestId('toc-button');
|
||||||
|
await expect(tocButton).toBeVisible();
|
||||||
|
await tocButton.click();
|
||||||
|
await expect(iframe.getByTestId('table-of-contents')).toBeVisible();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Open in new tab',
|
||||||
|
url: '',
|
||||||
|
run: async (page) => {
|
||||||
|
await expect(page.locator('#gitbook-widget-window')).toBeVisible();
|
||||||
|
const iframe = page.frameLocator('#gitbook-widget-iframe');
|
||||||
|
await expect(iframe.getByTestId('embed-docs-page')).toBeVisible({
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
const openInNewTabButton = iframe.getByTestId(
|
||||||
|
'embed-docs-page-open-in-new-tab'
|
||||||
|
);
|
||||||
|
await expect(openInNewTabButton).toBeVisible();
|
||||||
|
// Intercept the new page event without navigating
|
||||||
|
const [newPage] = await Promise.all([
|
||||||
|
page.context().waitForEvent('page', { timeout: 5000 }),
|
||||||
|
openInNewTabButton.click(),
|
||||||
|
]);
|
||||||
|
// Verify the new page would have opened with the expected URL
|
||||||
|
expect(newPage.url()).toContain('gitbook.gitbook.io');
|
||||||
|
// Close it immediately to avoid navigation
|
||||||
|
await newPage.close();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
runTestCases(testCases);
|
runTestCases(testCases);
|
||||||
|
|||||||
@@ -143,7 +143,10 @@ export const headerLinks: CustomizationHeaderItem[] = [
|
|||||||
|
|
||||||
export async function waitForCookiesDialog(page: Page) {
|
export async function waitForCookiesDialog(page: Page) {
|
||||||
const dialog = page.getByTestId('cookies-dialog');
|
const dialog = page.getByTestId('cookies-dialog');
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible({
|
||||||
|
// Cookies dialog may take some times to appear
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForNotFound(_page: Page, response: Response | null) {
|
export async function waitForNotFound(_page: Page, response: Response | null) {
|
||||||
@@ -151,6 +154,13 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
|
|||||||
expect(response?.status()).toBe(404);
|
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.
|
* Transform test cases into Playwright tests and run it.
|
||||||
*/
|
*/
|
||||||
@@ -345,6 +355,7 @@ export function getCustomizationURL(partial: DeepPartial<SiteCustomizationSettin
|
|||||||
url: 'https://www.gitbook.com/privacy',
|
url: 'https://www.gitbook.com/privacy',
|
||||||
},
|
},
|
||||||
socialPreview: {},
|
socialPreview: {},
|
||||||
|
socialAccounts: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
|
const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial));
|
||||||
@@ -407,14 +418,15 @@ export async function waitForIcons(page: Page) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// url("https://ka-p.fontawesome.com/releases/v6.6.0/svgs/light/moon.svg?v=2&token=a463935e93")
|
const maskImage = icon.querySelector('[data-testid="mask-image"]');
|
||||||
const maskImage = window.getComputedStyle(icon).getPropertyValue('mask-image');
|
if (!maskImage) {
|
||||||
const urlMatch = maskImage.match(/url\("([^"]+)"\)/);
|
throw new Error('No mask-image element');
|
||||||
const url = urlMatch?.[1];
|
}
|
||||||
|
|
||||||
|
const url = maskImage.getAttribute('href');
|
||||||
// If URL is invalid we throw an error.
|
// If URL is invalid we throw an error.
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error('No mask-image');
|
throw new Error('No mask-image url');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the URL is already queued for loading, we return the state.
|
// If the URL is already queued for loading, we return the state.
|
||||||
@@ -444,7 +456,8 @@ export async function waitForIcons(page: Page) {
|
|||||||
*/
|
*/
|
||||||
async function waitForTOCScrolling(page: Page) {
|
async function waitForTOCScrolling(page: Page) {
|
||||||
const viewport = await page.viewportSize();
|
const viewport = await page.viewportSize();
|
||||||
if (viewport && viewport.width >= 1024) {
|
if (viewport && viewport.width >= 1024 && !page.url().includes('~gitbook/embed/demo')) {
|
||||||
|
// The embed demo is an iframe, which means the viewport is only a fraction of the main document. So there is no open TOC to scroll to.
|
||||||
const toc = page.getByTestId('table-of-contents');
|
const toc = page.getByTestId('table-of-contents');
|
||||||
await expect(toc).toBeVisible();
|
await expect(toc).toBeVisible();
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ export default {
|
|||||||
converter: 'edge',
|
converter: 'edge',
|
||||||
proxyExternalRequest: 'fetch',
|
proxyExternalRequest: 'fetch',
|
||||||
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
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),
|
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -18,7 +19,8 @@ export default {
|
|||||||
converter: 'edge',
|
converter: 'edge',
|
||||||
proxyExternalRequest: 'fetch',
|
proxyExternalRequest: 'fetch',
|
||||||
queue: () => import('./openNext/queue/middleware').then((m) => m.default),
|
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),
|
tagCache: () => import('./openNext/tagCache/middleware').then((m) => m.default),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
{
|
{
|
||||||
"main": "default.js",
|
"main": "default.js",
|
||||||
"name": "gitbook-open-v2-server",
|
"name": "gitbook-open-v2-server",
|
||||||
|
"keep_names": false,
|
||||||
"compatibility_date": "2025-04-14",
|
"compatibility_date": "2025-04-14",
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
"nodejs_compat",
|
"nodejs_compat",
|
||||||
"allow_importable_env",
|
"allow_importable_env",
|
||||||
"global_fetch_strictly_public"
|
"global_fetch_strictly_public"
|
||||||
],
|
],
|
||||||
"observability": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"vars": {
|
"vars": {
|
||||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,14 +2,12 @@
|
|||||||
"main": "do.js",
|
"main": "do.js",
|
||||||
"name": "gitbook-open-v2-do",
|
"name": "gitbook-open-v2-do",
|
||||||
"compatibility_date": "2025-04-14",
|
"compatibility_date": "2025-04-14",
|
||||||
|
"keep_names": false,
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
"nodejs_compat",
|
"nodejs_compat",
|
||||||
"allow_importable_env",
|
"allow_importable_env",
|
||||||
"global_fetch_strictly_public"
|
"global_fetch_strictly_public"
|
||||||
],
|
],
|
||||||
"observability": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"env": {
|
"env": {
|
||||||
"preview": {
|
"preview": {
|
||||||
"vars": {
|
"vars": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"main": "middleware.js",
|
"main": "middleware.js",
|
||||||
"name": "gitbook-open-v2",
|
"name": "gitbook-open-v2",
|
||||||
"compatibility_date": "2025-04-14",
|
"compatibility_date": "2025-04-14",
|
||||||
|
"keep_names": false,
|
||||||
"compatibility_flags": [
|
"compatibility_flags": [
|
||||||
"nodejs_compat",
|
"nodejs_compat",
|
||||||
"allow_importable_env",
|
"allow_importable_env",
|
||||||
@@ -11,9 +12,6 @@
|
|||||||
"directory": "../../.open-next/assets",
|
"directory": "../../.open-next/assets",
|
||||||
"binding": "ASSETS"
|
"binding": "ASSETS"
|
||||||
},
|
},
|
||||||
"observability": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"vars": {
|
"vars": {
|
||||||
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
"NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE": "true"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-12
@@ -7,8 +7,6 @@ import type {
|
|||||||
} from '@opennextjs/aws/types/overrides.js';
|
} from '@opennextjs/aws/types/overrides.js';
|
||||||
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
import { getCloudflareContext } from '@opennextjs/cloudflare';
|
||||||
|
|
||||||
import { withRegionalCache } from '@opennextjs/cloudflare/overrides/incremental-cache/regional-cache';
|
|
||||||
|
|
||||||
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
|
import type { DurableObjectNamespace, Rpc } from '@cloudflare/workers-types';
|
||||||
|
|
||||||
export const BINDING_NAME = 'NEXT_INC_CACHE_R2_BUCKET';
|
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
|
* 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.
|
* 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';
|
name = 'GitbookIncrementalCache';
|
||||||
|
|
||||||
async get<CacheType extends CacheEntryType = 'cache'>(
|
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,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "gitbook",
|
"name": "gitbook",
|
||||||
"version": "0.19.1",
|
"version": "0.21.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20251011.0",
|
||||||
"@gitbook/api": "catalog:",
|
"@gitbook/api": "catalog:",
|
||||||
"@gitbook/browser-types": "workspace:*",
|
"@gitbook/browser-types": "workspace:*",
|
||||||
"@gitbook/cache-tags": "workspace:*",
|
"@gitbook/cache-tags": "workspace:*",
|
||||||
"@gitbook/colors": "workspace:*",
|
"@gitbook/colors": "workspace:*",
|
||||||
|
"@gitbook/embed": "workspace:*",
|
||||||
"@gitbook/emoji-codepoints": "workspace:*",
|
"@gitbook/emoji-codepoints": "workspace:*",
|
||||||
"@gitbook/expr": "workspace:*",
|
"@gitbook/expr": "workspace:*",
|
||||||
"@gitbook/fonts": "workspace:*",
|
"@gitbook/fonts": "workspace:*",
|
||||||
@@ -15,8 +17,9 @@
|
|||||||
"@gitbook/react-contentkit": "workspace:*",
|
"@gitbook/react-contentkit": "workspace:*",
|
||||||
"@gitbook/react-math": "workspace:*",
|
"@gitbook/react-math": "workspace:*",
|
||||||
"@gitbook/react-openapi": "workspace:*",
|
"@gitbook/react-openapi": "workspace:*",
|
||||||
"@modelcontextprotocol/sdk": "^1.17.5",
|
"@modelcontextprotocol/sdk": "1.17.5",
|
||||||
"@opennextjs/cloudflare": "^1.9.2",
|
"@opennextjs/aws": "^3.8.5",
|
||||||
|
"@opennextjs/cloudflare": "^1.14.4",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
"@radix-ui/react-dropdown-menu": "^2.1.12",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
@@ -25,22 +28,18 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@sindresorhus/fnv1a": "^3.1.0",
|
"@sindresorhus/fnv1a": "^3.1.0",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
|
||||||
"@tusbar/cache-control": "^1.0.2",
|
"@tusbar/cache-control": "^1.0.2",
|
||||||
"ai": "^4.2.2",
|
"ai": "^4.2.2",
|
||||||
"assert-never": "^1.2.1",
|
"assert-never": "catalog:",
|
||||||
"bidc": "catalog:",
|
"bidc": "catalog:",
|
||||||
"bun-types": "^1.1.20",
|
"classnames": "catalog:",
|
||||||
"classnames": "^2.5.1",
|
|
||||||
"direction": "^2.0.1",
|
"direction": "^2.0.1",
|
||||||
"event-iterator": "^2.0.0",
|
"event-iterator": "^2.0.0",
|
||||||
"framer-motion": "^10.16.14",
|
"feed": "^5.1.0",
|
||||||
"image-size": "^2.0.2",
|
"image-size": "^2.0.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"jsontoxml": "^1.0.1",
|
"jsontoxml": "^1.0.1",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"katex": "^0.16.9",
|
|
||||||
"mathjax": "^3.2.2",
|
|
||||||
"mcp-handler": "^1.0.2",
|
"mcp-handler": "^1.0.2",
|
||||||
"mdast-util-from-markdown": "^2.0.2",
|
"mdast-util-from-markdown": "^2.0.2",
|
||||||
"mdast-util-frontmatter": "^2.0.1",
|
"mdast-util-frontmatter": "^2.0.1",
|
||||||
@@ -49,8 +48,8 @@
|
|||||||
"memoizee": "^0.4.17",
|
"memoizee": "^0.4.17",
|
||||||
"micromark-extension-frontmatter": "^2.0.0",
|
"micromark-extension-frontmatter": "^2.0.0",
|
||||||
"micromark-extension-gfm": "^3.0.0",
|
"micromark-extension-gfm": "^3.0.0",
|
||||||
"motion": "^12.23.12",
|
"motion": "^12.23.24",
|
||||||
"next": "15.4.0",
|
"next": "15.4.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nuqs": "^2.2.3",
|
"nuqs": "^2.2.3",
|
||||||
"object-hash": "^3.0.0",
|
"object-hash": "^3.0.0",
|
||||||
@@ -58,6 +57,8 @@
|
|||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"p-map": "^7.0.3",
|
"p-map": "^7.0.3",
|
||||||
"quick-lru": "^7.0.1",
|
"quick-lru": "^7.0.1",
|
||||||
|
"react": "catalog:",
|
||||||
|
"react-dom": "catalog:",
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
@@ -73,14 +74,16 @@
|
|||||||
"unist-util-remove": "^4.0.0",
|
"unist-util-remove": "^4.0.0",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"url-join": "^5.0.0",
|
"url-join": "^5.0.0",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "catalog:",
|
||||||
"warn-once": "^0.1.1",
|
"warn-once": "^0.1.1",
|
||||||
"zod": "^3",
|
"zod": "^3",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3",
|
||||||
|
"negotiator": "^1.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@argos-ci/playwright": "^5.0.9",
|
"@argos-ci/playwright": "^5.0.9",
|
||||||
"@playwright/test": "^1.54.2",
|
"@playwright/test": "^1.54.2",
|
||||||
|
"@scalar/api-client-react": "catalog:",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/jsontoxml": "^1.0.5",
|
"@types/jsontoxml": "^1.0.5",
|
||||||
@@ -89,9 +92,11 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/object-hash": "^3.0.6",
|
"@types/object-hash": "^3.0.6",
|
||||||
"@types/parse-cache-control": "^1.0.4",
|
"@types/parse-cache-control": "^1.0.4",
|
||||||
"@types/react": "18.3.13",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "catalog:",
|
||||||
"@types/rison": "^0.0.9",
|
"@types/rison": "^0.0.9",
|
||||||
|
"@types/negotiator": "^0.6.4",
|
||||||
|
"bun-types": "catalog:",
|
||||||
"deepmerge": "^4.3.1",
|
"deepmerge": "^4.3.1",
|
||||||
"env-cmd": "^10.1.0",
|
"env-cmd": "^10.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@@ -99,8 +104,10 @@
|
|||||||
"stylelint": "^16.16.0",
|
"stylelint": "^16.16.0",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"ts-essentials": "^10.0.1",
|
"ts-essentials": "^10.0.1",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "catalog:",
|
||||||
"vercel": "^39.3.0"
|
"vercel": "^39.3.0",
|
||||||
|
"wrangler": "^4.43.0",
|
||||||
|
"rss-parser": "^3.13.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "./scripts/generate.sh",
|
"generate": "./scripts/generate.sh",
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ export default async function Page(props: PageProps) {
|
|||||||
const { context } = await getDynamicSiteContext(params);
|
const { context } = await getDynamicSiteContext(params);
|
||||||
const pathname = getPagePathFromParams(params);
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
return <SitePage context={context} pageParams={{ pathname }} staticRoute={false} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||||
|
|||||||
+6
-1
@@ -22,7 +22,12 @@ export default async function SiteDynamicLayout({
|
|||||||
const withTracking = shouldTrackEvents(await headers());
|
const withTracking = shouldTrackEvents(await headers());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout forcedTheme={forcedTheme} context={context}>
|
<CustomizationRootLayout
|
||||||
|
htmlClassName="sheet-open:gutter-stable"
|
||||||
|
bodyClassName="site-background"
|
||||||
|
forcedTheme={forcedTheme}
|
||||||
|
context={context}
|
||||||
|
>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
context={context}
|
context={context}
|
||||||
forcedTheme={forcedTheme}
|
forcedTheme={forcedTheme}
|
||||||
|
|||||||
+15
-3
@@ -1,16 +1,28 @@
|
|||||||
import type { RouteLayoutParams } from '@/app/utils';
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||||
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||||
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
export const dynamic = 'force-static';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<RouteLayoutParams>;
|
params: Promise<RouteLayoutParams>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
export default async function Page(props: PageProps) {
|
export default async function Page(props: PageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { context } = await getEmbeddableDynamicContext(params);
|
const { context } = await getEmbeddableDynamicContext(params);
|
||||||
|
|
||||||
return <EmbeddableAssistantPage context={context} />;
|
// If the assistant is not enabled, redirect to the docs
|
||||||
|
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
|
||||||
|
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddableAssistantPage
|
||||||
|
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
|
||||||
|
siteTitle={context.site.title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
|
import { getEmbeddableDynamicContext } from '@/lib/embeddable';
|
||||||
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const { context } = await getEmbeddableDynamicContext(params);
|
||||||
|
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
|
||||||
|
|
||||||
|
// If assistant is enabled, redirect to assistant, otherwise to docs
|
||||||
|
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
|
||||||
|
redirect(`${baseURL}/assistant`);
|
||||||
|
} else {
|
||||||
|
redirect(`${baseURL}/page/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -12,7 +12,7 @@ export default async function Page(props: PageProps) {
|
|||||||
const { context } = await getEmbeddableDynamicContext(params);
|
const { context } = await getEmbeddableDynamicContext(params);
|
||||||
const pathname = getPagePathFromParams(params);
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
|
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} staticRoute={false} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ export default async function Page(props: PageProps) {
|
|||||||
const { context } = await getStaticSiteContext(params);
|
const { context } = await getStaticSiteContext(params);
|
||||||
const pathname = getPagePathFromParams(params);
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
return <SitePage context={context} pageParams={{ pathname }} staticRoute />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||||
|
|||||||
+5
-1
@@ -19,7 +19,11 @@ export default async function SiteStaticLayout({
|
|||||||
const withTracking = shouldTrackEvents();
|
const withTracking = shouldTrackEvents();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomizationRootLayout context={context}>
|
<CustomizationRootLayout
|
||||||
|
htmlClassName="sheet-open:gutter-stable"
|
||||||
|
bodyClassName="site-background"
|
||||||
|
context={context}
|
||||||
|
>
|
||||||
<SiteLayout
|
<SiteLayout
|
||||||
context={context}
|
context={context}
|
||||||
withTracking={withTracking}
|
withTracking={withTracking}
|
||||||
|
|||||||
+15
-3
@@ -1,16 +1,28 @@
|
|||||||
import type { RouteLayoutParams } from '@/app/utils';
|
import type { RouteParams } from '@/app/utils';
|
||||||
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
import { EmbeddableAssistantPage } from '@/components/Embeddable';
|
||||||
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export const dynamic = 'force-static';
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<RouteLayoutParams>;
|
params: Promise<RouteParams>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function Page(props: PageProps) {
|
export default async function Page(props: PageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { context } = await getEmbeddableStaticContext(params);
|
const { context } = await getEmbeddableStaticContext(params);
|
||||||
|
|
||||||
return <EmbeddableAssistantPage context={context} />;
|
// If the assistant is not enabled, redirect to the docs
|
||||||
|
if (context.customization.ai.mode !== CustomizationAIMode.Assistant) {
|
||||||
|
redirect(`${context.linker.toPathInSite('~gitbook/embed/page/')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbeddableAssistantPage
|
||||||
|
baseURL={context.linker.toPathInSite('~gitbook/embed/')}
|
||||||
|
siteTitle={context.site.title}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import type { RouteLayoutParams } from '@/app/utils';
|
||||||
|
import { getEmbeddableStaticContext } from '@/lib/embeddable';
|
||||||
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<RouteLayoutParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Page(props: PageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
const { context } = await getEmbeddableStaticContext(params);
|
||||||
|
const baseURL = context.linker.toPathInSite('~gitbook/embed/');
|
||||||
|
|
||||||
|
// If assistant is enabled, redirect to assistant, otherwise to docs
|
||||||
|
if (context.customization.ai.mode === CustomizationAIMode.Assistant) {
|
||||||
|
redirect(`${baseURL}/assistant`);
|
||||||
|
} else {
|
||||||
|
redirect(`${baseURL}/page/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -14,7 +14,7 @@ export default async function Page(props: PageProps) {
|
|||||||
const { context } = await getEmbeddableStaticContext(params);
|
const { context } = await getEmbeddableStaticContext(params);
|
||||||
const pathname = getPagePathFromParams(params);
|
const pathname = getPagePathFromParams(params);
|
||||||
|
|
||||||
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} />;
|
return <EmbeddableDocsPage context={context} pageParams={{ pathname }} staticRoute />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
import { type RouteParams, getPagePathFromParams, getStaticSiteContext } from '@/app/utils';
|
||||||
|
import { servePageRSS } from '@/routes/rss';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: { params: Promise<RouteParams> }) {
|
||||||
|
const { context } = await getStaticSiteContext(await params);
|
||||||
|
const pathname = getPagePathFromParams(await params);
|
||||||
|
|
||||||
|
return servePageRSS(context, pathname);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { type SiteURLData, fetchSiteContextByURLLookup, getBaseContext } from '@
|
|||||||
import { getDynamicCustomizationSettings } from '@/lib/customization';
|
import { getDynamicCustomizationSettings } from '@/lib/customization';
|
||||||
import type { SiteAPIToken } from '@gitbook/api';
|
import type { SiteAPIToken } from '@gitbook/api';
|
||||||
import { jwtDecode } from 'jwt-decode';
|
import { jwtDecode } from 'jwt-decode';
|
||||||
import { forbidden } from 'next/navigation';
|
import { forbidden, notFound } from 'next/navigation';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
|
|
||||||
export type RouteParamMode = 'url-host' | 'url';
|
export type RouteParamMode = 'url-host' | 'url';
|
||||||
@@ -80,14 +80,32 @@ export async function getDynamicSiteContext(params: RouteLayoutParams) {
|
|||||||
* Get the decoded page path from the params.
|
* Get the decoded page path from the params.
|
||||||
*/
|
*/
|
||||||
export function getPagePathFromParams(params: RouteParams) {
|
export function getPagePathFromParams(params: RouteParams) {
|
||||||
const decoded = decodeURIComponent(params.pagePath);
|
// If decoding the param fails, return a 404 instead of crashing
|
||||||
return decoded;
|
try {
|
||||||
|
const decoded = decodeURIComponent(params.pagePath);
|
||||||
|
|
||||||
|
// For the root page, we encode '/' to avoid an empty param being passed.
|
||||||
|
if (decoded === '/') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Returning 404 after failing to decode page path ${params.pagePath}: ${error}`
|
||||||
|
);
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSiteURLFromParams(params: RouteLayoutParams) {
|
function getSiteURLFromParams(params: RouteLayoutParams) {
|
||||||
const decoded = decodeURIComponent(params.siteURL);
|
try {
|
||||||
const url = new URL(`https://${decoded}`);
|
const decoded = decodeURIComponent(params.siteURL);
|
||||||
return url;
|
const url = new URL(`https://${decoded}`);
|
||||||
|
return url;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Returning 404 after failing to decode site URL ${params.siteURL}: ${error}`);
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModeFromParams(mode: string): RouteParamMode {
|
function getModeFromParams(mode: string): RouteParamMode {
|
||||||
@@ -102,6 +120,13 @@ function getModeFromParams(mode: string): RouteParamMode {
|
|||||||
* Get the decoded site data from the params.
|
* Get the decoded site data from the params.
|
||||||
*/
|
*/
|
||||||
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
function getSiteURLDataFromParams(params: RouteLayoutParams): SiteURLData {
|
||||||
const decoded = decodeURIComponent(params.siteData);
|
try {
|
||||||
return rison.decode(decoded);
|
const decoded = decodeURIComponent(params.siteData);
|
||||||
|
return rison.decode(decoded);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Returning 404 after failing to decode site data ${params.siteData}: ${error}`
|
||||||
|
);
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ export function AIMessageView(
|
|||||||
) {
|
) {
|
||||||
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
|
const { message, context, withToolCalls = true, withLinkPreviews = true } = props;
|
||||||
|
|
||||||
return (
|
return message.steps.length > 0 ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{message.steps.map((step, index) => {
|
{message.steps.map((step, index) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'flex animate-fade-in-slow flex-col gap-2',
|
'flex flex-col gap-2',
|
||||||
step.content.nodes.length > 0 ? 'has-content' : ''
|
step.content.nodes.length > 0 ? 'has-content' : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -35,7 +35,7 @@ export function AIMessageView(
|
|||||||
wrapBlocksInSuspense: false,
|
wrapBlocksInSuspense: false,
|
||||||
withLinkPreviews,
|
withLinkPreviews,
|
||||||
}}
|
}}
|
||||||
style="mt-2 space-y-4 empty:hidden"
|
style="ai-response-document mt-2 space-y-4 *:origin-top-left *:animate-blur-in-slow"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
{withToolCalls && step.toolCalls && step.toolCalls.length > 0 ? (
|
||||||
@@ -45,5 +45,5 @@ export function AIMessageView(
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ function ToolCallSummary(props: { toolCall: AIToolCall; context: GitBookSiteCont
|
|||||||
const { toolCall, context } = props;
|
const { toolCall, context } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex origin-left animate-scale-in-slow items-start gap-2 text-sm text-tint-subtle">
|
<div
|
||||||
|
data-testid="ai-chat-tool-summary"
|
||||||
|
className="mt-2 flex origin-top-left animate-blur-in-slow items-start gap-2 text-sm text-tint-subtle"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
icon={getIconForToolCall(toolCall)}
|
icon={getIconForToolCall(toolCall)}
|
||||||
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
|
className="mt-1 size-3 shrink-0 text-tint-subtle/8"
|
||||||
@@ -160,7 +163,7 @@ async function DescriptionForSearchToolCall(props: {
|
|||||||
const hasResults = toolCall.results.length > 0;
|
const hasResults = toolCall.results.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details className={tcls('-ml-5 group flex w-full flex-col', hasResults ? 'gap-2' : '')}>
|
<details className="-ml-5 group flex w-full flex-col">
|
||||||
<summary
|
<summary
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden',
|
'-mx-2 flex list-none items-center gap-2 circular-corners:rounded-2xl rounded-corners:rounded-md pr-4 pl-7 transition-colors marker:hidden',
|
||||||
@@ -187,7 +190,7 @@ async function DescriptionForSearchToolCall(props: {
|
|||||||
) : null}
|
) : null}
|
||||||
</summary>
|
</summary>
|
||||||
{hasResults ? (
|
{hasResults ? (
|
||||||
<div className="hide-scrollbar mt-1 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
|
<div className="hide-scrollbar mt-4 max-h-0 overflow-y-auto circular-corners:rounded-2xl rounded-corners:rounded-lg border border-tint-subtle p-2 opacity-0 transition-all transition-discrete duration-500 group-open:max-h-96 group-open:opacity-11">
|
||||||
<ol className="space-y-1">
|
<ol className="space-y-1">
|
||||||
{searchResultsWithHrefs.map((result, index) => (
|
{searchResultsWithHrefs.map((result, index) => (
|
||||||
<li
|
<li
|
||||||
|
|||||||
@@ -128,10 +128,10 @@ function parseResponse<T>(
|
|||||||
parse: (response: AIStreamResponse) => T | undefined | Promise<T | undefined>
|
parse: (response: AIStreamResponse) => T | undefined | Promise<T | undefined>
|
||||||
): {
|
): {
|
||||||
stream: EventIterator<T>;
|
stream: EventIterator<T>;
|
||||||
response: Promise<{ responseId: string }>;
|
response: Promise<{ responseId: string | null }>;
|
||||||
} {
|
} {
|
||||||
let resolveResponse: (value: { responseId: string }) => void;
|
let resolveResponse: (value: { responseId: string | null }) => void;
|
||||||
const response = new Promise<{ responseId: string }>((resolve) => {
|
const response = new Promise<{ responseId: string | null }>((resolve) => {
|
||||||
resolveResponse = resolve;
|
resolveResponse = resolve;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ function parseResponse<T>(
|
|||||||
|
|
||||||
if (event.type === 'response_finish') {
|
if (event.type === 'response_finish') {
|
||||||
foundResponse = true;
|
foundResponse = true;
|
||||||
resolveResponse({ responseId: event.responseId });
|
resolveResponse({ responseId: event.response.id ?? null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { CustomizationAIMode } from '@gitbook/api';
|
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 * as React from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
import { tString, useLanguage } from '@/intl/client';
|
||||||
import type { GitBookAssistant } from '@gitbook/browser-types';
|
import type { GitBookAssistant } from '@gitbook/browser-types';
|
||||||
import { useAIChatController, useAIChatState } from '.';
|
import { useAIChatController, useAIChatState } from '.';
|
||||||
import { AIChatIcon, getAIChatName } from '../AIChat';
|
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
|
||||||
import { useIntegrationAssistants } from '../Integrations';
|
import { useIntegrationAssistants } from '../Integrations';
|
||||||
import { useSearch } from '../Search/useSearch';
|
import { useSearch } from '../Search/useSearch';
|
||||||
|
|
||||||
// Unify assistants configuration context with the assistants hook in one place
|
// Unify assistants configuration context with the assistants hook in one place
|
||||||
export type AIConfig = {
|
export type AIConfig = {
|
||||||
aiMode: CustomizationAIMode;
|
aiMode: CustomizationAIMode;
|
||||||
|
suggestions?: string[];
|
||||||
trademark: boolean;
|
trademark: boolean;
|
||||||
|
greeting?: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
|
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
|
||||||
@@ -49,8 +54,11 @@ export type Assistant = Omit<GitBookAssistant, 'icon'> & {
|
|||||||
const AIContext = React.createContext<AIConfig | null>(null);
|
const AIContext = React.createContext<AIConfig | null>(null);
|
||||||
|
|
||||||
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
|
export function AIContextProvider(props: React.PropsWithChildren<AIConfig>): React.ReactElement {
|
||||||
const { aiMode, trademark, children } = props;
|
const { aiMode, trademark, suggestions, greeting, children } = props;
|
||||||
const value = React.useMemo(() => ({ aiMode, trademark }), [aiMode, trademark]);
|
const value = React.useMemo(
|
||||||
|
() => ({ aiMode, trademark, suggestions, greeting }),
|
||||||
|
[aiMode, trademark, suggestions, greeting]
|
||||||
|
);
|
||||||
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
return <AIContext.Provider value={value}>{children}</AIContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +96,7 @@ export function useAI(): AIContext {
|
|||||||
<AIChatIcon
|
<AIChatIcon
|
||||||
state={chat.loading ? 'thinking' : 'default'}
|
state={chat.loading ? 'thinking' : 'default'}
|
||||||
trademark={config.trademark}
|
trademark={config.trademark}
|
||||||
|
className="size-text-lg"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
open: (query?: string) => {
|
open: (query?: string) => {
|
||||||
@@ -104,16 +113,7 @@ export function useAI(): AIContext {
|
|||||||
assistants.push({
|
assistants.push({
|
||||||
id: 'gitbook-ai-search',
|
id: 'gitbook-ai-search',
|
||||||
label: tString(language, 'ai_chat_context_badge'),
|
label: tString(language, 'ai_chat_context_badge'),
|
||||||
icon: (
|
icon: <AISearchIcon />,
|
||||||
<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>
|
|
||||||
),
|
|
||||||
open: (query?: string) => {
|
open: (query?: string) => {
|
||||||
if (query) {
|
if (query) {
|
||||||
setSearchState((prev) =>
|
setSearchState((prev) =>
|
||||||
|
|||||||
@@ -87,6 +87,19 @@ export type AIChatState = {
|
|||||||
error: boolean;
|
error: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AIChatEvent =
|
||||||
|
| { type: 'open' }
|
||||||
|
| { type: 'postMessage'; message: string }
|
||||||
|
| { type: 'clear' }
|
||||||
|
| { type: 'close' };
|
||||||
|
|
||||||
|
type AIChatEventData<T extends AIChatEvent['type']> = Omit<
|
||||||
|
Extract<AIChatEvent, { type: T }>,
|
||||||
|
'type'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
|
||||||
|
|
||||||
export type AIChatController = {
|
export type AIChatController = {
|
||||||
/** Open the dialog */
|
/** Open the dialog */
|
||||||
open: () => void;
|
open: () => void;
|
||||||
@@ -96,6 +109,11 @@ export type AIChatController = {
|
|||||||
postMessage: (input: { message: string }) => void;
|
postMessage: (input: { message: string }) => void;
|
||||||
/** Clear the conversation */
|
/** Clear the conversation */
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
/** Register an event listener */
|
||||||
|
on: <T extends AIChatEvent['type']>(
|
||||||
|
event: T,
|
||||||
|
listener: (input?: AIChatEventData<T>) => void
|
||||||
|
) => () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
const AIChatControllerContext = React.createContext<AIChatController | null>(null);
|
||||||
@@ -123,6 +141,17 @@ export function useAIChatState(): AIChatState {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notify(
|
||||||
|
listeners: AIChatEventListener[] | undefined,
|
||||||
|
input: Omit<AIChatEvent, 'type'>
|
||||||
|
): void {
|
||||||
|
if (!listeners) return;
|
||||||
|
// Defer event listeners to next tick so React can process state updates first
|
||||||
|
setTimeout(() => {
|
||||||
|
listeners.forEach((listener) => listener(input));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provide the controller to interact with the AI chat.
|
* Provide the controller to interact with the AI chat.
|
||||||
*/
|
*/
|
||||||
@@ -137,6 +166,9 @@ export function AIChatProvider(props: {
|
|||||||
const [, setSearchState] = useSearch();
|
const [, setSearchState] = useSearch();
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
|
// Event listeners storage
|
||||||
|
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
|
||||||
|
|
||||||
// Open AI chat and sync with search state
|
// Open AI chat and sync with search state
|
||||||
const onOpen = React.useCallback(() => {
|
const onOpen = React.useCallback(() => {
|
||||||
const { initialQuery } = globalState.getState();
|
const { initialQuery } = globalState.getState();
|
||||||
@@ -149,6 +181,8 @@ export function AIChatProvider(props: {
|
|||||||
scope: prev?.scope ?? 'default',
|
scope: prev?.scope ?? 'default',
|
||||||
open: false, // Close search popover when opening chat
|
open: false, // Close search popover when opening chat
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
notify(eventsRef.current.get('open'), {});
|
||||||
}, [setSearchState]);
|
}, [setSearchState]);
|
||||||
|
|
||||||
// Close AI chat and clear ask parameter
|
// Close AI chat and clear ask parameter
|
||||||
@@ -162,6 +196,8 @@ export function AIChatProvider(props: {
|
|||||||
scope: prev?.scope ?? 'default',
|
scope: prev?.scope ?? 'default',
|
||||||
open: false,
|
open: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
notify(eventsRef.current.get('close'), {});
|
||||||
}, [setSearchState]);
|
}, [setSearchState]);
|
||||||
|
|
||||||
// Stream a message with the AI backend
|
// Stream a message with the AI backend
|
||||||
@@ -260,7 +296,7 @@ export function AIChatProvider(props: {
|
|||||||
case 'response_finish': {
|
case 'response_finish': {
|
||||||
globalState.setState((state) => ({
|
globalState.setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
responseId: event.responseId,
|
responseId: event.response.id ?? null,
|
||||||
// Mark as not loading when the response is finished
|
// Mark as not loading when the response is finished
|
||||||
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
// Even if the stream might continue as we receive 'response_followup_suggestion'
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -379,8 +415,14 @@ export function AIChatProvider(props: {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notify(eventsRef.current.get('postMessage'), { message: input.message });
|
||||||
|
|
||||||
if (query === input.message) {
|
if (query === input.message) {
|
||||||
// Return early if the message is the same as the previous message
|
// Return early if the message is the same as the previous message
|
||||||
|
globalState.setState((state) => ({
|
||||||
|
...state,
|
||||||
|
opened: true,
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,14 +482,34 @@ export function AIChatProvider(props: {
|
|||||||
}));
|
}));
|
||||||
}, [setSearchState]);
|
}, [setSearchState]);
|
||||||
|
|
||||||
|
const onEvent = React.useCallback(
|
||||||
|
<T extends AIChatEvent['type']>(
|
||||||
|
event: T,
|
||||||
|
listener: (input?: AIChatEventData<T>) => void
|
||||||
|
) => {
|
||||||
|
const listeners = eventsRef.current.get(event) || [];
|
||||||
|
listeners.push(listener as AIChatEventListener);
|
||||||
|
eventsRef.current.set(event, listeners);
|
||||||
|
return () => {
|
||||||
|
const currentListeners = eventsRef.current.get(event) || [];
|
||||||
|
eventsRef.current.set(
|
||||||
|
event,
|
||||||
|
currentListeners.filter((l) => l !== listener)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const controller = React.useMemo(() => {
|
const controller = React.useMemo(() => {
|
||||||
return {
|
return {
|
||||||
open: onOpen,
|
open: onOpen,
|
||||||
close: onClose,
|
close: onClose,
|
||||||
clear: onClear,
|
clear: onClear,
|
||||||
postMessage: onPostMessage,
|
postMessage: onPostMessage,
|
||||||
|
on: onEvent,
|
||||||
};
|
};
|
||||||
}, [onOpen, onClose, onClear, onPostMessage]);
|
}, [onOpen, onClose, onClear, onPostMessage, onEvent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AIChatControllerContext.Provider value={controller}>
|
<AIChatControllerContext.Provider value={controller}>
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { t, tString, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
import type { TranslationLanguage } from '@/intl/translations';
|
import type { TranslationLanguage } from '@/intl/translations';
|
||||||
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import {
|
import {
|
||||||
type AIChatController,
|
type AIChatController,
|
||||||
type AIChatState,
|
type AIChatState,
|
||||||
|
useAI,
|
||||||
useAIChatController,
|
useAIChatController,
|
||||||
useAIChatState,
|
useAIChatState,
|
||||||
} from '../AI';
|
} from '../AI';
|
||||||
@@ -17,21 +19,23 @@ import {
|
|||||||
EmbeddableFrameButtons,
|
EmbeddableFrameButtons,
|
||||||
EmbeddableFrameHeader,
|
EmbeddableFrameHeader,
|
||||||
EmbeddableFrameHeaderMain,
|
EmbeddableFrameHeaderMain,
|
||||||
|
EmbeddableFrameMain,
|
||||||
EmbeddableFrameSubtitle,
|
EmbeddableFrameSubtitle,
|
||||||
EmbeddableFrameTitle,
|
EmbeddableFrameTitle,
|
||||||
} from '../Embeddable/EmbeddableFrame';
|
} from '../Embeddable/EmbeddableFrame';
|
||||||
import { useTrackEvent } from '../Insights';
|
import { useTrackEvent } from '../Insights';
|
||||||
import { useNow } from '../hooks';
|
import { useNow } from '../hooks';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
|
import { ScrollContainer } from '../primitives/ScrollContainer';
|
||||||
|
import { SideSheet } from '../primitives/SideSheet';
|
||||||
import { AIChatControlButton } from './AIChatControlButton';
|
import { AIChatControlButton } from './AIChatControlButton';
|
||||||
import { AIChatIcon } from './AIChatIcon';
|
import { AIChatIcon } from './AIChatIcon';
|
||||||
import { AIChatInput } from './AIChatInput';
|
import { AIChatInput } from './AIChatInput';
|
||||||
import { AIChatMessages } from './AIChatMessages';
|
import { AIChatMessages } from './AIChatMessages';
|
||||||
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
import AIChatSuggestedQuestions from './AIChatSuggestedQuestions';
|
||||||
|
|
||||||
export function AIChat(props: { trademark: boolean }) {
|
export function AIChat() {
|
||||||
const { trademark } = props;
|
const { config } = useAI();
|
||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const chat = useAIChatState();
|
const chat = useAIChatState();
|
||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
@@ -65,41 +69,53 @@ export function AIChat(props: { trademark: boolean }) {
|
|||||||
}
|
}
|
||||||
}, [chat.opened, trackEvent]);
|
}, [chat.opened, trackEvent]);
|
||||||
|
|
||||||
if (!chat.opened) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<SideSheet
|
||||||
data-testid="ai-chat"
|
side="right"
|
||||||
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"
|
open={chat.opened}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
chatController.open();
|
||||||
|
} else {
|
||||||
|
chatController.close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
withOverlay={true}
|
||||||
|
className={tcls(
|
||||||
|
'ai-chat mx-auto ml-8 not-hydrated:hidden w-96 transition-[width] duration-300 ease-quint lg:max-xl:w-80'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<EmbeddableFrame className="relative circular-corners:rounded-3xl rounded-corners:rounded-md depth-subtle:shadow-lg shadow-tint ring-1 ring-tint-subtle">
|
<EmbeddableFrame className="relative shrink-0 border-tint-subtle border-l to-tint-base">
|
||||||
<EmbeddableFrameHeader>
|
<EmbeddableFrameMain data-testid="ai-chat">
|
||||||
<AIChatDynamicIcon trademark={trademark} />
|
<EmbeddableFrameHeader>
|
||||||
<EmbeddableFrameHeaderMain>
|
<AIChatDynamicIcon trademark={config.trademark} />
|
||||||
<EmbeddableFrameTitle>
|
<EmbeddableFrameHeaderMain>
|
||||||
{getAIChatName(language, trademark)}
|
<EmbeddableFrameTitle>
|
||||||
</EmbeddableFrameTitle>
|
{getAIChatName(language, config.trademark)}
|
||||||
<AIChatSubtitle chat={chat} />
|
</EmbeddableFrameTitle>
|
||||||
</EmbeddableFrameHeaderMain>
|
<AIChatSubtitle chat={chat} />
|
||||||
<EmbeddableFrameButtons>
|
</EmbeddableFrameHeaderMain>
|
||||||
<AIChatControlButton />
|
<EmbeddableFrameButtons>
|
||||||
<Button
|
<AIChatControlButton />
|
||||||
onClick={() => chatController.close()}
|
<Button
|
||||||
iconOnly
|
onClick={() => chatController.close()}
|
||||||
icon="close"
|
iconOnly
|
||||||
label={tString(language, 'close')}
|
icon="close"
|
||||||
variant="blank"
|
label={tString(language, 'close')}
|
||||||
size="default"
|
variant="blank"
|
||||||
|
/>
|
||||||
|
</EmbeddableFrameButtons>
|
||||||
|
</EmbeddableFrameHeader>
|
||||||
|
<EmbeddableFrameBody>
|
||||||
|
<AIChatBody
|
||||||
|
chatController={chatController}
|
||||||
|
chat={chat}
|
||||||
|
suggestions={config.suggestions}
|
||||||
/>
|
/>
|
||||||
</EmbeddableFrameButtons>
|
</EmbeddableFrameBody>
|
||||||
</EmbeddableFrameHeader>
|
</EmbeddableFrameMain>
|
||||||
<EmbeddableFrameBody>
|
|
||||||
<AIChatBody chatController={chatController} chat={chat} trademark={trademark} />
|
|
||||||
</EmbeddableFrameBody>
|
|
||||||
</EmbeddableFrame>
|
</EmbeddableFrame>
|
||||||
</div>
|
</SideSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,13 +124,14 @@ export function AIChat(props: { trademark: boolean }) {
|
|||||||
*/
|
*/
|
||||||
export function AIChatDynamicIcon(props: {
|
export function AIChatDynamicIcon(props: {
|
||||||
trademark: boolean;
|
trademark: boolean;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { trademark } = props;
|
const { trademark, className } = props;
|
||||||
const chat = useAIChatState();
|
const chat = useAIChatState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AIChatIcon
|
<AIChatIcon
|
||||||
className="size-5 text-tint"
|
className={tcls('size-5 text-tint', className)}
|
||||||
trademark={trademark}
|
trademark={trademark}
|
||||||
state={
|
state={
|
||||||
chat.error
|
chat.error
|
||||||
@@ -143,10 +160,33 @@ export function AIChatSubtitle(props: {
|
|||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmbeddableFrameSubtitle className={chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0'}>
|
<EmbeddableFrameSubtitle
|
||||||
{chat.messages[chat.messages.length - 1]?.content
|
className={tcls('relative', chat.loading ? 'h-3 opacity-11' : 'h-0 opacity-0')}
|
||||||
? tString(language, 'ai_chat_working')
|
>
|
||||||
: tString(language, 'ai_chat_thinking')}
|
<span
|
||||||
|
className={tcls(
|
||||||
|
'absolute left-0',
|
||||||
|
chat.loading
|
||||||
|
? chat.messages[chat.messages.length - 1]?.content
|
||||||
|
? 'animate-blur-in-slow'
|
||||||
|
: 'hidden'
|
||||||
|
: 'animate-blur-out-slow'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(language, 'ai_chat_working')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={tcls(
|
||||||
|
'absolute left-0',
|
||||||
|
chat.loading
|
||||||
|
? chat.messages[chat.messages.length - 1]?.content
|
||||||
|
? 'animate-blur-out-slow'
|
||||||
|
: 'animate-blur-in-slow'
|
||||||
|
: 'hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(language, 'ai_chat_thinking')}
|
||||||
|
</span>
|
||||||
</EmbeddableFrameSubtitle>
|
</EmbeddableFrameSubtitle>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -157,20 +197,16 @@ export function AIChatSubtitle(props: {
|
|||||||
export function AIChatBody(props: {
|
export function AIChatBody(props: {
|
||||||
chatController: AIChatController;
|
chatController: AIChatController;
|
||||||
chat: AIChatState;
|
chat: AIChatState;
|
||||||
trademark: boolean;
|
|
||||||
welcomeMessage?: string;
|
welcomeMessage?: string;
|
||||||
suggestions?: string[];
|
suggestions?: string[];
|
||||||
|
greeting?: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
const { chatController, chat, trademark, suggestions } = props;
|
const { chatController, chat, suggestions, greeting } = props;
|
||||||
|
const { trademark } = useAI().config;
|
||||||
|
|
||||||
const [input, setInput] = React.useState('');
|
|
||||||
|
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
// Ref for the last user message element
|
|
||||||
const lastUserMessageRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [inputHeight, setInputHeight] = React.useState(0);
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
|
const now = useNow(60 * 60 * 1000); // Refresh every hour for greeting
|
||||||
|
|
||||||
@@ -184,67 +220,45 @@ export function AIChatBody(props: {
|
|||||||
return tString(language, 'ai_chat_assistant_greeting_evening');
|
return tString(language, 'ai_chat_assistant_greeting_evening');
|
||||||
}, [now, language]);
|
}, [now, language]);
|
||||||
|
|
||||||
// Auto-scroll to the latest user message when messages change
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (chat.messages.length > 0 && lastUserMessageRef.current) {
|
|
||||||
lastUserMessageRef.current.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'start',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [chat.messages.length]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (lastUserMessageRef.current) {
|
|
||||||
lastUserMessageRef.current.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'start',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// We want the chat messages to scroll underneath the input, but they should scroll past the input when scrolling all the way down.
|
|
||||||
// The best way to do this is to observe the input height and adjust the padding bottom of the scroll container accordingly.
|
|
||||||
const observer = new ResizeObserver((entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
setInputHeight(entry.contentRect.height + 32);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (inputRef.current) {
|
|
||||||
observer.observe(inputRef.current);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<ScrollContainer
|
||||||
ref={scrollContainerRef}
|
className="shrink grow basis-80 animate-fade-in-slow [container-type:size]"
|
||||||
className="gutter-stable flex grow scroll-pt-4 flex-col gap-4 overflow-y-auto p-4"
|
contentClassName="p-4 gutter-stable flex flex-col gap-4"
|
||||||
style={{
|
orientation="vertical"
|
||||||
paddingBottom: `${inputHeight}px`,
|
trailing={{ fade: false, button: true }}
|
||||||
}}
|
active={`#message-group-${chat.messages.filter((message) => message.role === 'user').length - 1}`}
|
||||||
>
|
>
|
||||||
{isEmpty ? (
|
{isEmpty ? (
|
||||||
<div className="flex min-h-full w-full shrink-0 flex-col items-center justify-center gap-6 py-4">
|
<div className="flex grow flex-col">
|
||||||
<div className="flex size-32 animate-fade-in-slow items-center justify-center rounded-full bg-tint-subtle">
|
<div className="my-auto flex flex-row items-center gap-4 pb-6 [@container(min-height:400px)]:flex-col">
|
||||||
<AIChatIcon
|
<div
|
||||||
state="intro"
|
className="flex size-16 shrink-0 animate-scale-in items-center justify-center rounded-full bg-primary-solid/1 [@container(min-height:400px)]:size-32"
|
||||||
trademark={trademark}
|
style={{ animationDelay: '.3s' }}
|
||||||
className="size-16 animate-[present_500ms_200ms_both]"
|
>
|
||||||
/>
|
<AIChatIcon
|
||||||
</div>
|
state="intro"
|
||||||
<div className="animate-[fadeIn_500ms_400ms_both]">
|
trademark={trademark}
|
||||||
<h5 className=" text-center font-bold text-lg text-tint-strong">
|
className="size-8 text-primary [@container(min-height:400px)]:size-16"
|
||||||
{timeGreeting}
|
/>
|
||||||
</h5>
|
</div>
|
||||||
<p className="text-center text-tint">
|
<div className="flex flex-col items-start gap-1 [@container(min-height:400px)]:items-center">
|
||||||
{t(language, 'ai_chat_assistant_description')}
|
<h5
|
||||||
</p>
|
className="animate-blur-in-slow font-bold text-lg text-tint-strong leading-tight [@container(min-height:400px)]:text-center"
|
||||||
|
style={{ animationDelay: '.5s' }}
|
||||||
|
data-testid="ai-chat-greeting-title"
|
||||||
|
>
|
||||||
|
{greeting?.title || timeGreeting}
|
||||||
|
</h5>
|
||||||
|
<p
|
||||||
|
className="animate-blur-in-slow text-tint leading-tight [@container(min-height:400px)]:text-center"
|
||||||
|
style={{ animationDelay: '.6s' }}
|
||||||
|
data-testid="ai-chat-greeting-subtitle"
|
||||||
|
>
|
||||||
|
{greeting?.subtitle ||
|
||||||
|
t(language, 'ai_chat_assistant_description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!chat.error ? (
|
{!chat.error ? (
|
||||||
<AIChatSuggestedQuestions
|
<AIChatSuggestedQuestions
|
||||||
@@ -254,28 +268,19 @@ export function AIChatBody(props: {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AIChatMessages
|
<AIChatMessages chat={chat} chatController={chatController} />
|
||||||
chat={chat}
|
|
||||||
chatController={chatController}
|
|
||||||
lastUserMessageRef={lastUserMessageRef}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</ScrollContainer>
|
||||||
<div
|
|
||||||
ref={inputRef}
|
<div className="flex flex-col gap-2 px-4 pb-4">
|
||||||
className="absolute inset-x-0 bottom-0 mr-2 flex select-none flex-col gap-4 bg-linear-to-b from-transparent to-50% to-tint-base/9 p-4 pr-2"
|
|
||||||
>
|
|
||||||
{/* Display an error banner when something went wrong. */}
|
{/* Display an error banner when something went wrong. */}
|
||||||
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
{chat.error ? <AIChatError chatController={chatController} /> : null}
|
||||||
|
|
||||||
<AIChatInput
|
<AIChatInput
|
||||||
value={input}
|
|
||||||
onChange={setInput}
|
|
||||||
loading={chat.loading}
|
loading={chat.loading}
|
||||||
disabled={chat.loading || chat.error}
|
disabled={chat.loading || chat.error}
|
||||||
onSubmit={() => {
|
onSubmit={(value) => {
|
||||||
chatController.postMessage({ message: input });
|
chatController.postMessage({ message: value });
|
||||||
setInput('');
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
import { useLanguage } from '@/intl/client';
|
import { useLanguage } from '@/intl/client';
|
||||||
import { t } from '@/intl/translate';
|
import { t } from '@/intl/translate';
|
||||||
import type { Assistant } from '../AI';
|
import type { Assistant } from '../AI';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { Button } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 672; // 42rem, equal to Tailwind's @max-2xl container breakpoint
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Button to open/close the AI chat.
|
* Button to open/close the AI chat.
|
||||||
*/
|
*/
|
||||||
@@ -15,15 +18,15 @@ export function AIChatButton(props: {
|
|||||||
}) {
|
}) {
|
||||||
const { assistant, showLabel = true, withShortcut = true } = props;
|
const { assistant, showLabel = true, withShortcut = true } = props;
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
const isMobile = useIsMobile(MOBILE_BREAKPOINT, '#header-content');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
icon={assistant.icon}
|
icon={assistant.icon}
|
||||||
data-testid="ai-chat-button"
|
data-testid="ai-chat-button"
|
||||||
iconOnly={!showLabel}
|
iconOnly={!showLabel || isMobile}
|
||||||
size="medium"
|
size="medium"
|
||||||
variant="header"
|
variant="header"
|
||||||
className="h-9 px-2.5 max-md:[&_.button-content]:hidden"
|
|
||||||
label={
|
label={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{t(language, 'ai_chat_ask', assistant.label)}
|
{t(language, 'ai_chat_ask', assistant.label)}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useLanguage } from '@/intl/client';
|
import { useLanguage } from '@/intl/client';
|
||||||
import { t, tString } from '@/intl/translate';
|
import { t } from '@/intl/translate';
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import { useAIChatController, useAIChatState } from '../AI';
|
import { useAIChatController, useAIChatState } from '../AI';
|
||||||
import { Button, DropdownMenu, DropdownMenuItem } from '../primitives';
|
import { Button } from '../primitives';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Button to control the chat (clear, etc.)
|
* Button to control the chat (clear, etc.)
|
||||||
@@ -15,26 +14,15 @@ export function AIChatControlButton() {
|
|||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
|
|
||||||
return chat.messages.length > 0 ? (
|
return chat.messages.length > 0 ? (
|
||||||
<DropdownMenu
|
<Button
|
||||||
button={
|
onClick={() => {
|
||||||
<Button
|
chatController.clear();
|
||||||
onClick={() => {}}
|
}}
|
||||||
iconOnly
|
iconOnly
|
||||||
icon="ellipsis"
|
icon="trash-can"
|
||||||
label={tString(language, 'actions')}
|
label={t(language, 'ai_chat_clear_conversation')}
|
||||||
variant="blank"
|
variant="blank"
|
||||||
size="default"
|
className="animate-blur-in-slow"
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
chatController.clear();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon="broom-wide" className="size-3 shrink-0 text-tint-subtle" />
|
|
||||||
{t(language, 'ai_chat_clear_conversation')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { tcls } from '@/lib/tailwind';
|
import { Icon, IconStyle } from '@gitbook/icons';
|
||||||
import { Icon } from '@gitbook/icons';
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
@@ -9,23 +8,30 @@ interface AIChatIconProps extends React.SVGProps<SVGSVGElement> {
|
|||||||
trademark?: boolean;
|
trademark?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AIChatIcon = ({
|
export function AIChatIcon({
|
||||||
className = 'size-4',
|
className = 'size-4',
|
||||||
size,
|
size,
|
||||||
trademark = true,
|
trademark = true,
|
||||||
state = 'default',
|
state = 'default',
|
||||||
...props
|
...props
|
||||||
}: AIChatIconProps) => {
|
}: AIChatIconProps) {
|
||||||
if (!trademark) {
|
if (!trademark) {
|
||||||
return (
|
return (
|
||||||
<Icon
|
<Icon
|
||||||
icon="sparkle"
|
icon="sparkle"
|
||||||
className={tcls(
|
{...props}
|
||||||
className,
|
className={className}
|
||||||
(state === 'thinking' || state === 'working') &&
|
style={{
|
||||||
'animate-[spin_2s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
animation: {
|
||||||
state === 'intro' && 'animate-[spin_2s_forwards_cubic-bezier(0.16,1,0.3,1)]'
|
intro: 'spin 2s forwards cubic-bezier(0.16,1,0.3,1)',
|
||||||
)}
|
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||||
|
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||||
|
done: '',
|
||||||
|
confirm: '',
|
||||||
|
default: '',
|
||||||
|
error: '',
|
||||||
|
}[state],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -49,28 +55,36 @@ export const AIChatIcon = ({
|
|||||||
<path
|
<path
|
||||||
d="M12.8916 1.06265C12.921 0.979101 13.0392 0.979127 13.0685 1.06267C13.239 1.5478 13.3439 1.84646 13.516 2.1032C13.6683 2.33042 13.8578 2.53033 14.0766 2.6945C14.3239 2.88 14.6165 3.00068 15.0919 3.19671C15.1761 3.23142 15.1761 3.3506 15.0919 3.38531C14.6165 3.58134 14.3239 3.70203 14.0766 3.88752C13.8578 4.05169 13.6683 4.2516 13.516 4.47882C13.3439 4.73556 13.239 5.03423 13.0685 5.51937C13.0392 5.60291 12.921 5.60292 12.8916 5.51938C12.7212 5.03423 12.6162 4.73557 12.4442 4.47882C12.2919 4.2516 12.1023 4.05169 11.8835 3.88752C11.6363 3.70202 11.3436 3.58134 10.8682 3.38531C10.7841 3.3506 10.7841 3.23141 10.8683 3.1967C11.3436 3.00067 11.6363 2.87999 11.8835 2.6945C12.1023 2.53033 12.2919 2.33042 12.4442 2.1032C12.6162 1.84646 12.7212 1.54779 12.8916 1.06265Z"
|
d="M12.8916 1.06265C12.921 0.979101 13.0392 0.979127 13.0685 1.06267C13.239 1.5478 13.3439 1.84646 13.516 2.1032C13.6683 2.33042 13.8578 2.53033 14.0766 2.6945C14.3239 2.88 14.6165 3.00068 15.0919 3.19671C15.1761 3.23142 15.1761 3.3506 15.0919 3.38531C14.6165 3.58134 14.3239 3.70203 14.0766 3.88752C13.8578 4.05169 13.6683 4.2516 13.516 4.47882C13.3439 4.73556 13.239 5.03423 13.0685 5.51937C13.0392 5.60291 12.921 5.60292 12.8916 5.51938C12.7212 5.03423 12.6162 4.73557 12.4442 4.47882C12.2919 4.2516 12.1023 4.05169 11.8835 3.88752C11.6363 3.70202 11.3436 3.58134 10.8682 3.38531C10.7841 3.3506 10.7841 3.23141 10.8683 3.1967C11.3436 3.00067 11.6363 2.87999 11.8835 2.6945C12.1023 2.53033 12.2919 2.33042 12.4442 2.1032C12.6162 1.84646 12.7212 1.54779 12.8916 1.06265Z"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.2"
|
strokeWidth={state === 'intro' ? '1.2' : '1'}
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className={tcls(
|
shapeRendering="crispEdges"
|
||||||
state === 'intro' &&
|
className="transition-opacity duration-300"
|
||||||
'animate-[fadeIn_.5s_.7s_both,spin_2s_1s_forwards_cubic-bezier(.43,1.54,.64,1)]',
|
style={{
|
||||||
(state === 'working' || state === 'thinking') &&
|
animation: {
|
||||||
'animate-[fadeIn_.5s_.3s_both,spin_2s_1s_infinite_forwards_cubic-bezier(0.16,1,0.3,1)]',
|
intro: 'fadeIn .5s .5s backwards, spin 2s .5s forwards cubic-bezier(0.43,1.54,0.64,1)',
|
||||||
state === 'done' && 'animate-[fadeOut_.5s_both]',
|
thinking: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||||
state === 'confirm' && 'animate-[fadeOut_.5s_both]',
|
working: 'spin 2s 1s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||||
state === 'default' && 'animate-[fadeIn_0s_both]',
|
done: '',
|
||||||
state === 'error' && 'hidden'
|
confirm: '',
|
||||||
)}
|
default: '',
|
||||||
style={{ transformOrigin: '13px 3.5px' }}
|
error: '',
|
||||||
|
}[state],
|
||||||
|
transitionDelay:
|
||||||
|
state === 'default' || state === 'thinking' ? '.3s' : undefined,
|
||||||
|
opacity: ['done', 'confirm', 'error'].includes(state) ? 0 : 1,
|
||||||
|
transformOrigin: '13px 3.5px',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
<g
|
<g
|
||||||
clipPath="url(#clip0_153_2034)"
|
clipPath="url(#clip0_153_2034)"
|
||||||
className={tcls(
|
className="transition-opacity duration-300"
|
||||||
'text-danger-subtle',
|
style={{
|
||||||
state === 'error' ? 'animate-[fadeIn_.5s_.3s_both]' : 'hidden'
|
color: 'rgb(var(--danger-9))',
|
||||||
)}
|
opacity: state === 'error' ? 1 : 0,
|
||||||
|
transitionDelay: state === 'error' ? '.3s' : undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M13.0312 1.42059L13.0312 3.95184"
|
d="M13.0312 1.42059L13.0312 3.95184"
|
||||||
@@ -93,29 +107,34 @@ export const AIChatIcon = ({
|
|||||||
strokeWidth="1.2"
|
strokeWidth="1.2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className={tcls(
|
className="transition-opacity duration-300"
|
||||||
state === 'done'
|
style={{
|
||||||
? 'animate-[fadeIn_.5s_.3s_both]'
|
opacity: state === 'done' ? 1 : 0,
|
||||||
: 'animate-[fadeOut_.5s_both]',
|
transitionDelay: state === 'done' ? '.3s' : undefined,
|
||||||
state === 'intro' && 'hidden',
|
}}
|
||||||
state === 'confirm' && 'hidden'
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Confirm */}
|
{/* Confirm */}
|
||||||
<path
|
<path
|
||||||
className={tcls(
|
className="transition-opacity duration-300"
|
||||||
'fill-primary-9',
|
style={{
|
||||||
state === 'confirm'
|
fill: 'rgb(var(--primary-9))',
|
||||||
? 'animate-[fadeIn_.5s_.3s_both,bounceSmall_1s_infinite_both]'
|
opacity: state === 'confirm' ? 1 : 0,
|
||||||
: state === 'thinking'
|
transitionDelay: state === 'confirm' ? '.3s' : undefined,
|
||||||
? 'animate-[fadeOut_.5s_both]'
|
animation: {
|
||||||
: 'hidden'
|
intro: '',
|
||||||
)}
|
thinking: '',
|
||||||
|
working: '',
|
||||||
|
done: '',
|
||||||
|
confirm: 'bounceSmall 1s infinite both',
|
||||||
|
default: '',
|
||||||
|
error: '',
|
||||||
|
}[state],
|
||||||
|
}}
|
||||||
d="M12.9463 5.24512C13.3688 5.24422 13.713 5.58625 13.7139 6.00879C13.7146 6.43114 13.3725 6.77338 12.9502 6.77441C12.5279 6.77505 12.1845 6.43408 12.1836 6.01172C12.1828 5.58953 12.5242 5.24649 12.9463 5.24512ZM13.0391 0.0751953C14.0688 0.0730893 14.9049 0.90586 14.9072 1.93555C14.9084 2.5063 14.6484 3.04679 14.2012 3.40137L13.7773 3.7373C13.6151 3.86604 13.5201 4.06239 13.5205 4.26953V4.30371C13.5211 4.62139 13.2639 4.879 12.9463 4.87988C12.6288 4.88032 12.3701 4.62417 12.3691 4.30664V4.27246C12.3679 3.71272 12.6238 3.18263 13.0625 2.83496L13.4854 2.49902C13.6565 2.36341 13.7562 2.1568 13.7559 1.93848C13.755 1.54463 13.4358 1.22503 13.042 1.22559H12.9385C12.488 1.22679 12.1225 1.59352 12.123 2.04395L12.124 2.21875C12.1245 2.53649 11.8676 2.79522 11.5498 2.7959C11.2321 2.79653 10.9746 2.53928 10.9736 2.22168L10.9727 2.04688C10.9706 0.960578 11.8493 0.0778178 12.9355 0.0751953H13.0391Z"
|
d="M12.9463 5.24512C13.3688 5.24422 13.713 5.58625 13.7139 6.00879C13.7146 6.43114 13.3725 6.77338 12.9502 6.77441C12.5279 6.77505 12.1845 6.43408 12.1836 6.01172C12.1828 5.58953 12.5242 5.24649 12.9463 5.24512ZM13.0391 0.0751953C14.0688 0.0730893 14.9049 0.90586 14.9072 1.93555C14.9084 2.5063 14.6484 3.04679 14.2012 3.40137L13.7773 3.7373C13.6151 3.86604 13.5201 4.06239 13.5205 4.26953V4.30371C13.5211 4.62139 13.2639 4.879 12.9463 4.87988C12.6288 4.88032 12.3701 4.62417 12.3691 4.30664V4.27246C12.3679 3.71272 12.6238 3.18263 13.0625 2.83496L13.4854 2.49902C13.6565 2.36341 13.7562 2.1568 13.7559 1.93848C13.755 1.54463 13.4358 1.22503 13.042 1.22559H12.9385C12.488 1.22679 12.1225 1.59352 12.123 2.04395L12.124 2.21875C12.1245 2.53649 11.8676 2.79522 11.5498 2.7959C11.2321 2.79653 10.9746 2.53928 10.9736 2.22168L10.9727 2.04688C10.9706 0.960578 11.8493 0.0778178 12.9355 0.0751953H13.0391Z"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<path
|
<path
|
||||||
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
d="M3.5625 8.78512L7.26347 10.9219C7.88227 11.2791 8.64467 11.2791 9.26347 10.9219L14.25 8.0429C14.5833 7.85045 15 8.09101 15 8.47591V10.2777C15 10.4563 14.9047 10.6214 14.75 10.7107L9.26347 13.8784C8.64467 14.2356 7.88228 14.2356 7.26347 13.8784L3.5625 11.7416C2.70833 11.2978 1 9.93199 1 8.01949M1 8.01949C1 6.6448 1.84765 5.98698 2.62903 5.71701C3.15426 5.53555 3.71577 5.70568 4.19701 5.98353L7.26347 7.75395C7.88228 8.11122 8.64467 8.11122 9.26347 7.75395L10.9095 6.80362M1 8.01949C1 6.4945 2.03973 5.30731 2.5596 4.90434L7.37937 2.12165C7.79013 1.88449 8.26417 1.80476 8.71747 1.88245"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -123,7 +142,17 @@ export const AIChatIcon = ({
|
|||||||
strokeWidth="1.2"
|
strokeWidth="1.2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className={tcls(state === 'intro' && 'animate-[fadeIn_2s_forwards]')}
|
style={{
|
||||||
|
animation: {
|
||||||
|
intro: 'fadeIn 2s forwards',
|
||||||
|
thinking: '',
|
||||||
|
working: '',
|
||||||
|
done: '',
|
||||||
|
confirm: '',
|
||||||
|
default: '',
|
||||||
|
error: '',
|
||||||
|
}[state],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@@ -153,14 +182,66 @@ export const AIChatIcon = ({
|
|||||||
strokeWidth="1.2"
|
strokeWidth="1.2"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
className={tcls(
|
style={{
|
||||||
(state === 'thinking' || state === 'working') &&
|
animation: {
|
||||||
'animate-[pathLoading_2s_infinite_both]',
|
intro: 'pathEnter 1.5s both ease-out',
|
||||||
state === 'intro' && 'animate-[pathEnter_2s_both]',
|
thinking: 'pathLoading 2s infinite both',
|
||||||
state === 'done' && 'animate-[pathEnter_1s_forwards_ease]'
|
working: 'pathLoading 2s infinite both',
|
||||||
)}
|
done: 'pathEnter 1s forwards ease',
|
||||||
|
confirm: '',
|
||||||
|
default: '',
|
||||||
|
error: '',
|
||||||
|
}[state],
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
interface AISearchIconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
|
className?: string;
|
||||||
|
state?: 'default' | 'intro' | 'thinking' | 'working' | 'done' | 'error' | 'confirm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AISearchIcon({ className = 'size-4', state = 'default' }: AISearchIconProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
animation: {
|
||||||
|
intro: 'fadeIn 1s both, orbit 1s cubic-bezier(0.16,1,0.3,1)',
|
||||||
|
thinking:
|
||||||
|
'fadeIn 1s both, orbit 1s orbit 2s 1s infinite forwards linear ease-out,',
|
||||||
|
working: '',
|
||||||
|
done: '',
|
||||||
|
confirm: '',
|
||||||
|
default: '',
|
||||||
|
error: '',
|
||||||
|
}[state],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="search" className={className} />
|
||||||
|
<Icon
|
||||||
|
icon="sparkle"
|
||||||
|
iconStyle={IconStyle.Solid}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '15.7%',
|
||||||
|
left: '15.6%',
|
||||||
|
width: '50%',
|
||||||
|
height: '50%',
|
||||||
|
animation: {
|
||||||
|
intro: 'spin 2s .5s forwards cubic-bezier(0.16,1,0.3,1)',
|
||||||
|
thinking: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||||
|
working: 'spin 2s infinite forwards cubic-bezier(0.16,1,0.3,1)',
|
||||||
|
done: '',
|
||||||
|
confirm: '',
|
||||||
|
default: '',
|
||||||
|
error: '',
|
||||||
|
}[state],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,38 +1,28 @@
|
|||||||
import { t, tString, useLanguage } from '@/intl/client';
|
import { t, tString, useLanguage } from '@/intl/client';
|
||||||
import { tcls } from '@/lib/tailwind';
|
|
||||||
import { Icon } from '@gitbook/icons';
|
import { Icon } from '@gitbook/icons';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
import { useAIChatState } from '../AI/useAIChat';
|
||||||
import { KeyboardShortcut } from '../primitives/KeyboardShortcut';
|
import { HoverCard, HoverCardRoot, HoverCardTrigger } from '../primitives';
|
||||||
|
import { Input } from '../primitives/Input';
|
||||||
|
|
||||||
export function AIChatInput(props: {
|
export function AIChatInput(props: {
|
||||||
value: string;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/**
|
/**
|
||||||
* When true, the input is disabled
|
* When true, the input is disabled
|
||||||
*/
|
*/
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onChange: (value: string) => void;
|
|
||||||
onSubmit: (value: string) => void;
|
onSubmit: (value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { value, onChange, onSubmit, disabled, loading } = props;
|
const { onSubmit, disabled, loading } = props;
|
||||||
|
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
const chat = useAIChatState();
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const textarea = event.currentTarget;
|
|
||||||
onChange(textarea.value);
|
|
||||||
|
|
||||||
// Auto-resize
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disabled && !loading) {
|
if (chat.opened && !disabled && !loading) {
|
||||||
// Add a small delay to ensure the input is rendered before focusing
|
// Add a small delay to ensure the input is rendered before focusing
|
||||||
// This fixes inconsistent focus behaviour across browsers
|
// This fixes inconsistent focus behaviour across browsers
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -41,7 +31,7 @@ export function AIChatInput(props: {
|
|||||||
|
|
||||||
return () => clearTimeout(timeout);
|
return () => clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
}, [disabled, loading]);
|
}, [disabled, loading, chat.opened]);
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'mod+i',
|
'mod+i',
|
||||||
@@ -55,58 +45,35 @@ export function AIChatInput(props: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="depth-subtle:has-[textarea:focus]:-translate-y-px relative flex flex-col overflow-hidden circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-base/9 depth-subtle:shadow-sm shadow-tint/6 ring-1 ring-tint-subtle backdrop-blur-lg transition-all depth-subtle:has-[textarea:focus]:shadow-lg has-[textarea:focus]:shadow-primary-subtle has-[textarea:focus]:ring-2 has-[textarea:focus]:ring-primary-hover contrast-more:bg-tint-base dark:shadow-tint-1">
|
<Input
|
||||||
<textarea
|
data-testid="ai-chat-input"
|
||||||
ref={inputRef}
|
name="ai-chat-input"
|
||||||
disabled={disabled || loading}
|
multiline
|
||||||
data-loading={loading}
|
resize
|
||||||
data-testid="ai-chat-input"
|
sizing="large"
|
||||||
className={tcls(
|
label="Assistant chat input"
|
||||||
'resize-none',
|
placeholder={tString(language, 'ai_chat_input_placeholder')}
|
||||||
'focus:outline-hidden',
|
onSubmit={(val) => onSubmit(val as string)}
|
||||||
'focus:ring-0',
|
submitButton={{
|
||||||
'w-full',
|
size: 'small',
|
||||||
'px-3',
|
label: tString(language, 'send'),
|
||||||
'py-3',
|
}}
|
||||||
'pb-12',
|
className="animate-blur-in-slow bg-tint-base/9 backdrop-blur-lg contrast-more:bg-tint-base"
|
||||||
'h-auto',
|
rows={1}
|
||||||
'bg-transparent',
|
maxLength={2048}
|
||||||
'peer',
|
keyboardShortcut={
|
||||||
'max-h-64',
|
!disabled && !loading
|
||||||
'placeholder:text-tint/8',
|
? {
|
||||||
'transition-colors',
|
keys: ['mod', 'i'],
|
||||||
'disabled:bg-tint-subtle',
|
className: 'bg-tint-base group-focus-within/input:hidden',
|
||||||
'delay-300',
|
}
|
||||||
'disabled:delay-0',
|
: undefined
|
||||||
'disabled:cursor-not-allowed',
|
}
|
||||||
'data-[loading=true]:cursor-progress',
|
disabled={disabled || loading}
|
||||||
'data-[loading=true]:opacity-50'
|
aria-busy={loading}
|
||||||
)}
|
ref={inputRef}
|
||||||
value={value}
|
trailing={
|
||||||
rows={1}
|
<HoverCardRoot openDelay={500}>
|
||||||
placeholder={tString(language, 'ai_chat_input_placeholder')}
|
|
||||||
onChange={handleInput}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.currentTarget.blur();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === 'Enter' && !event.shiftKey && value.trim()) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.currentTarget.style.height = 'auto';
|
|
||||||
onSubmit(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!disabled ? (
|
|
||||||
<div className="absolute top-2.5 right-3 animate-[fadeIn_0.2s_0.5s_ease-in-out_both] peer-focus:hidden">
|
|
||||||
<KeyboardShortcut keys={['mod', 'i']} className="bg-tint-base" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 px-2 py-2">
|
|
||||||
<HoverCardRoot>
|
|
||||||
<HoverCard
|
<HoverCard
|
||||||
className="max-w-xs bg-tint p-2 text-sm text-tint"
|
className="max-w-xs bg-tint p-2 text-sm text-tint"
|
||||||
arrow={{ className: 'fill-tint-3' }}
|
arrow={{ className: 'fill-tint-3' }}
|
||||||
@@ -133,7 +100,8 @@ export function AIChatInput(props: {
|
|||||||
</div>
|
</div>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
<HoverCardTrigger>
|
<HoverCardTrigger>
|
||||||
<div className="flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
|
{/* Negative margin to compensate for Input's padding, so the badge appears flush with the cursor */}
|
||||||
|
<div className="-ml-1 flex cursor-help items-center gap-1 circular-corners:rounded-2xl rounded-corners:rounded-md px-2.5 py-1.5 text-tint/7 text-xs transition-all hover:bg-tint">
|
||||||
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-sm bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
|
<span className="-ml-1 circular-corners:rounded-2xl rounded-corners:rounded-sm bg-tint-11/7 px-1 py-0.5 font-mono font-semibold text-[0.65rem] text-contrast-tint-11 leading-none">
|
||||||
{t(language, 'ai_chat_context_badge')}
|
{t(language, 'ai_chat_context_badge')}
|
||||||
</span>{' '}
|
</span>{' '}
|
||||||
@@ -144,14 +112,7 @@ export function AIChatInput(props: {
|
|||||||
</div>
|
</div>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
</HoverCardRoot>
|
</HoverCardRoot>
|
||||||
<Button
|
}
|
||||||
label={tString(language, 'send')}
|
/>
|
||||||
size="medium"
|
|
||||||
className="ml-auto"
|
|
||||||
disabled={disabled || !value.trim()}
|
|
||||||
onClick={() => onSubmit(value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useLanguage } from '@/intl/client';
|
|||||||
import { tString } from '@/intl/translate';
|
import { tString } from '@/intl/translate';
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { AIMessageRole } from '@gitbook/api';
|
import { AIMessageRole } from '@gitbook/api';
|
||||||
import type React from 'react';
|
|
||||||
import type { AIChatController, AIChatState } from '../AI';
|
import type { AIChatController, AIChatState } from '../AI';
|
||||||
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
import { AIChatToolConfirmations } from './AIChatToolConfirmations';
|
||||||
import { AIResponseFeedback } from './AIResponseFeedback';
|
import { AIResponseFeedback } from './AIResponseFeedback';
|
||||||
@@ -11,87 +10,133 @@ import { AIChatFollowupSuggestions } from './AiChatFollowupSuggestions';
|
|||||||
export function AIChatMessages(props: {
|
export function AIChatMessages(props: {
|
||||||
chat: AIChatState;
|
chat: AIChatState;
|
||||||
chatController: AIChatController;
|
chatController: AIChatController;
|
||||||
lastUserMessageRef?: React.RefObject<HTMLDivElement>;
|
|
||||||
}) {
|
}) {
|
||||||
const { chat, chatController, lastUserMessageRef } = props;
|
const { chat, chatController } = props;
|
||||||
|
|
||||||
return (
|
// Group messages: user messages start a new group, all following messages until next user message belong to that group
|
||||||
<>
|
type Message = (typeof chat.messages)[0];
|
||||||
{chat.messages.map((message, index) => {
|
type MessageGroup = { message: Message; originalIndex: number };
|
||||||
const isLastMessage = index === chat.messages.length - 1;
|
const messageGroups: Array<Array<MessageGroup>> = [];
|
||||||
const isLastUserMessage =
|
let currentGroup: Array<MessageGroup> = [];
|
||||||
message.role === AIMessageRole.User &&
|
|
||||||
index === chat.messages.map((m) => m.role).lastIndexOf(AIMessageRole.User);
|
|
||||||
|
|
||||||
return (
|
chat.messages.forEach((message, index) => {
|
||||||
<div
|
if (message.role === AIMessageRole.User) {
|
||||||
ref={isLastUserMessage ? lastUserMessageRef : undefined}
|
// Start a new group
|
||||||
data-testid="ai-chat-message"
|
if (currentGroup.length > 0) {
|
||||||
className={tcls(
|
messageGroups.push(currentGroup);
|
||||||
message.content ? 'animate-fade-in-slow' : '',
|
}
|
||||||
'shrink-0',
|
currentGroup = [{ message, originalIndex: index }];
|
||||||
'last:min-h-[calc(100%-5rem)]',
|
} else {
|
||||||
'scroll-mt-36',
|
// Add to current group
|
||||||
'lg:scroll-mt-0',
|
currentGroup.push({ message, originalIndex: index });
|
||||||
'flex flex-col gap-6',
|
}
|
||||||
'break-words',
|
});
|
||||||
'group/message',
|
|
||||||
message.role === AIMessageRole.User
|
|
||||||
? 'max-w-[80%] self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
|
||||||
: 'text-tint-strong'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
animationDelay: `${Math.min(index * 0.05, 0.5)}s`,
|
|
||||||
}}
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
{message.content ? message.content : null}
|
|
||||||
|
|
||||||
{isLastMessage && chat.loading ? (
|
// Add the last group if it exists
|
||||||
<div className="flex w-full animate-fade-in-slow flex-col gap-2">
|
if (currentGroup.length > 0) {
|
||||||
{!message.content ? <HoldMessage /> : null}
|
messageGroups.push(currentGroup);
|
||||||
<LoadingSkeleton />
|
}
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isLastMessage ? (
|
return messageGroups.map((group, groupIndex) => {
|
||||||
<>
|
const isLastGroup = group === messageGroups[messageGroups.length - 1];
|
||||||
{!chat.loading &&
|
return (
|
||||||
!chat.error &&
|
<div
|
||||||
chat.query &&
|
key={groupIndex}
|
||||||
chat.responseId &&
|
id={`message-group-${groupIndex}`}
|
||||||
chat.pendingTools.length === 0 ? (
|
className={tcls(
|
||||||
<AIResponseFeedback
|
'flex flex-col gap-2 pt-2',
|
||||||
responseId={chat.responseId}
|
isLastGroup ? 'shrink-0 basis-full' : '',
|
||||||
query={chat.query}
|
|
||||||
className="-ml-1 -mt-4"
|
'transition-discrete'
|
||||||
|
)}
|
||||||
|
style={{ animationDelay: '.2s' }}
|
||||||
|
>
|
||||||
|
{group.map(({ message, originalIndex }) => {
|
||||||
|
const isLastMessage = originalIndex === chat.messages.length - 1;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={originalIndex}
|
||||||
|
data-testid={
|
||||||
|
message.role === AIMessageRole.User
|
||||||
|
? 'ai-chat-message-user'
|
||||||
|
: 'ai-chat-message-assistant'
|
||||||
|
}
|
||||||
|
id={`message-${originalIndex}`}
|
||||||
|
className={tcls(
|
||||||
|
'flex flex-col gap-6',
|
||||||
|
'break-words',
|
||||||
|
'group/message',
|
||||||
|
'animate-blur-in-slow',
|
||||||
|
isLastMessage ? 'basis-full' : '',
|
||||||
|
message.role === AIMessageRole.User
|
||||||
|
? 'max-w-[80%] origin-top-right self-end circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint px-4 py-2'
|
||||||
|
: 'origin-top-left text-tint-strong'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${Math.min(originalIndex * 0.1, 0.6)}s`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message.content}
|
||||||
|
|
||||||
|
{isLastMessage && message.role === AIMessageRole.Assistant ? (
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex w-full shrink-0 flex-col gap-2 starting:opacity-0 transition-all transition-discrete duration-500',
|
||||||
|
chat.loading ? '' : 'hidden opacity-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<HoldMessage className={message.content ? 'hidden' : ''} />
|
||||||
|
<LoadingSkeleton />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLastMessage ? (
|
||||||
|
<>
|
||||||
|
{!chat.loading &&
|
||||||
|
!chat.error &&
|
||||||
|
chat.query &&
|
||||||
|
chat.responseId &&
|
||||||
|
chat.pendingTools.length === 0 ? (
|
||||||
|
<AIResponseFeedback
|
||||||
|
responseId={chat.responseId}
|
||||||
|
query={chat.query}
|
||||||
|
className="-ml-1 -mt-4"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<AIChatToolConfirmations chat={chat} />
|
||||||
|
<AIChatFollowupSuggestions
|
||||||
|
chat={chat}
|
||||||
|
chatController={chatController}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</>
|
||||||
<AIChatToolConfirmations chat={chat} />
|
) : null}
|
||||||
<AIChatFollowupSuggestions
|
</div>
|
||||||
chat={chat}
|
);
|
||||||
chatController={chatController}
|
})}
|
||||||
/>
|
</div>
|
||||||
</>
|
);
|
||||||
) : null}
|
});
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function HoldMessage() {
|
export function HoldMessage({
|
||||||
|
breakLines = false,
|
||||||
|
className,
|
||||||
|
}: { breakLines?: boolean; className?: string }) {
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-[heightIn_500ms_4500ms_ease_both] py-2 text-tint-subtle">
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'animate-[heightIn_.5s_5s_ease_both] overflow-hidden py-2 text-tint-subtle [&.hidden]:animate-[heightOut_1s_ease-in_both]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{tString(language, 'ai_chat_hold_message_1')
|
{tString(language, 'ai_chat_hold_message_1')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((word, index) => (
|
.map((word, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="animate-fade-in-slow"
|
className="animate-blur-in-slow"
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${5000 + index * 200}ms`,
|
animationDelay: `${5000 + index * 200}ms`,
|
||||||
}}
|
}}
|
||||||
@@ -99,12 +144,13 @@ function HoldMessage() {
|
|||||||
{word}{' '}
|
{word}{' '}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{breakLines ? <br /> : null}
|
||||||
{tString(language, 'ai_chat_hold_message_2')
|
{tString(language, 'ai_chat_hold_message_2')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((word, index) => (
|
.map((word, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="animate-fade-in-slow"
|
className="animate-blur-in-slow"
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${10000 + index * 200}ms`,
|
animationDelay: `${10000 + index * 200}ms`,
|
||||||
}}
|
}}
|
||||||
@@ -122,9 +168,9 @@ function LoadingSkeleton() {
|
|||||||
{Array.from({ length: 7 }).map((_, index) => (
|
{Array.from({ length: 7 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="h-4 animate-[fadeIn_500ms_ease_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-4"
|
className="h-4 animate-[blurIn_500ms_ease-out_both,pulse_1.5s_infinite] circular-corners:rounded-2xl rounded-corners:rounded-md bg-tint-solid/2"
|
||||||
style={{
|
style={{
|
||||||
width: `calc(${(index % 4) * 20 + 10}% - 4px)`,
|
width: `calc(${(4 - (index % 4)) * 8 + 14}% - 4px)`,
|
||||||
animationDelay: `${index * 0.1}s`,
|
animationDelay: `${index * 0.1}s`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,25 +7,31 @@ export default function AIChatSuggestedQuestions(props: {
|
|||||||
suggestions?: string[];
|
suggestions?: string[];
|
||||||
}) {
|
}) {
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
const {
|
const { chatController, suggestions: _suggestions } = props;
|
||||||
chatController,
|
|
||||||
suggestions = [
|
const suggestions =
|
||||||
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
_suggestions && _suggestions.length > 0
|
||||||
tString(language, 'ai_chat_suggested_questions_read_next'),
|
? _suggestions
|
||||||
tString(language, 'ai_chat_suggested_questions_example'),
|
: [
|
||||||
],
|
tString(language, 'ai_chat_suggested_questions_about_this_page'),
|
||||||
} = props;
|
tString(language, 'ai_chat_suggested_questions_read_next'),
|
||||||
|
tString(language, 'ai_chat_suggested_questions_example'),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div
|
||||||
|
className="flex flex-col items-start gap-2 self-start"
|
||||||
|
data-testid="ai-chat-suggested-questions"
|
||||||
|
>
|
||||||
{suggestions.map((question, index) => (
|
{suggestions.map((question, index) => (
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="ai-chat-suggested-question"
|
||||||
key={question}
|
key={question}
|
||||||
variant="secondary"
|
variant="blank"
|
||||||
size="medium"
|
size="small"
|
||||||
className="max-w-full animate-[present_500ms_both] whitespace-normal"
|
className="max-w-full animate-blur-in-slow border-none bg-primary-solid/1 hover:bg-primary-hover"
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${800 + index * 100}ms`,
|
animationDelay: `${1000 + index * 100}ms`,
|
||||||
}}
|
}}
|
||||||
onClick={() => chatController.postMessage({ message: question })}
|
onClick={() => chatController.postMessage({ message: question })}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function AIChatToolConfirmations(props: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
<div className="flex w-full flex-wrap justify-end gap-2 empty:hidden">
|
||||||
{chat.pendingTools.map((tool, index) => (
|
{chat.pendingTools.map((tool, index) => (
|
||||||
<div
|
<div
|
||||||
className="flex w-full animate-present-slow flex-col gap-1"
|
className="flex w-full animate-present-slow flex-col gap-1"
|
||||||
@@ -37,13 +37,14 @@ export function AIChatToolConfirmations(props: {
|
|||||||
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
|
style={{ animationDelay: `${0.5 + index * 0.1}s` }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="ai-chat-tool-confirmation"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
tool.confirm();
|
tool.confirm();
|
||||||
}}
|
}}
|
||||||
tabIndex={index}
|
tabIndex={index}
|
||||||
label={tool.label}
|
label={tool.label}
|
||||||
className="w-full justify-center"
|
className="w-full justify-center"
|
||||||
size={index === 0 ? 'default' : 'medium'}
|
size={index === 0 ? 'medium' : 'small'}
|
||||||
variant={index === 0 ? 'primary' : 'secondary'}
|
variant={index === 0 ? 'primary' : 'secondary'}
|
||||||
icon={tool.icon}
|
icon={tool.icon}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export function AIResponseFeedback(props: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={tcls('flex h-fit items-center', className)}>
|
<div
|
||||||
|
className={tcls('flex h-fit animate-[heightIn_.5s_ease_both] items-center', className)}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
icon="thumbs-up"
|
icon="thumbs-up"
|
||||||
iconOnly
|
iconOnly
|
||||||
@@ -34,8 +36,8 @@ export function AIResponseFeedback(props: {
|
|||||||
'animate-fade-in overflow-hidden text-tint-subtle transition-all',
|
'animate-fade-in overflow-hidden text-tint-subtle transition-all',
|
||||||
rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0'
|
rating !== null && rating !== 1 && 'px-0 text-[0rem] opacity-0'
|
||||||
)}
|
)}
|
||||||
size="medium"
|
size="small"
|
||||||
style={{ animationDuration: '.5s' }}
|
style={{ animationDelay: '.5s', animationDuration: '.5s' }}
|
||||||
onClick={() => handleRating(1)}
|
onClick={() => handleRating(1)}
|
||||||
disabled={rating !== null}
|
disabled={rating !== null}
|
||||||
active={rating === 1}
|
active={rating === 1}
|
||||||
@@ -50,8 +52,8 @@ export function AIResponseFeedback(props: {
|
|||||||
'animate-fade-in overflow-hidden text-tint-subtle transition-all',
|
'animate-fade-in overflow-hidden text-tint-subtle transition-all',
|
||||||
rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0'
|
rating !== null && rating !== -1 && 'px-0 text-[0rem] opacity-0'
|
||||||
)}
|
)}
|
||||||
size="medium"
|
size="small"
|
||||||
style={{ animationDelay: '.2s', animationDuration: '.5s' }}
|
style={{ animationDelay: '.7s', animationDuration: '.5s' }}
|
||||||
onClick={() => handleRating(-1)}
|
onClick={() => handleRating(-1)}
|
||||||
disabled={rating !== null}
|
disabled={rating !== null}
|
||||||
active={rating === -1}
|
active={rating === -1}
|
||||||
@@ -59,8 +61,8 @@ export function AIResponseFeedback(props: {
|
|||||||
/>
|
/>
|
||||||
{rating !== null ? (
|
{rating !== null ? (
|
||||||
<span
|
<span
|
||||||
className="ml-2 animate-fade-in-slow text-tint-subtle"
|
className="ml-2 animate-blur-in-slow text-tint-subtle"
|
||||||
style={{ animationDelay: '.3s' }}
|
style={{ animationDelay: '.5s' }}
|
||||||
>
|
>
|
||||||
{t(language, 'was_this_helpful_thank_you')}
|
{t(language, 'was_this_helpful_thank_you')}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -15,22 +15,28 @@ export function AIChatFollowupSuggestions(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-auto flex w-full flex-wrap justify-end gap-2">
|
<div className="flex grow flex-col">
|
||||||
{chat.followUpSuggestions.map((suggestion, index) => (
|
<div
|
||||||
<Button
|
className="sticky bottom-0 mt-auto flex flex-col items-start gap-2"
|
||||||
key={index}
|
data-testid="ai-chat-followup-suggestions"
|
||||||
onClick={() => {
|
>
|
||||||
chatController.postMessage({ message: suggestion });
|
{chat.followUpSuggestions.map((suggestion, index) => (
|
||||||
}}
|
<Button
|
||||||
label={suggestion}
|
data-testid="ai-chat-followup-suggestion"
|
||||||
className="whitespace-normal! max-w-full animate-[present_500ms_both] text-left ring-1 ring-tint-subtle"
|
key={index}
|
||||||
size="medium"
|
onClick={() => {
|
||||||
variant="blank"
|
chatController.postMessage({ message: suggestion });
|
||||||
style={{
|
}}
|
||||||
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
|
label={suggestion}
|
||||||
}}
|
className="starting:h-0 max-w-full origin-left animate-blur-in-slow whitespace-normal border-none bg-primary-11/1 starting:py-0 text-left transition-all transition-discrete duration-500 *:whitespace-normal hover:bg-primary-hover"
|
||||||
/>
|
size="small"
|
||||||
))}
|
variant="blank"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${250 + Math.min(index * 50, 150)}ms`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { MotionConfig } from 'motion/react';
|
|||||||
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||||
import { useVisitorSession } from '../Insights';
|
import { useVisitorSession } from '../Insights';
|
||||||
import { useCurrentPagePath } from '../hooks';
|
import { useCurrentPagePath } from '../hooks';
|
||||||
import { DateRelative } from '../primitives';
|
|
||||||
import { HideToolbarButton } from './HideToolbarButton';
|
import { HideToolbarButton } from './HideToolbarButton';
|
||||||
import { IframeWrapper } from './IframeWrapper';
|
import { IframeWrapper } from './IframeWrapper';
|
||||||
import { RefreshContentButton } from './RefreshContentButton';
|
import { RefreshContentButton } from './RefreshContentButton';
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
type ToolbarControlsContextValue,
|
type ToolbarControlsContextValue,
|
||||||
ToolbarControlsProvider,
|
ToolbarControlsProvider,
|
||||||
} from './ToolbarControlsContext';
|
} from './ToolbarControlsContext';
|
||||||
|
import { ToolbarDate } from './ToolbarDate';
|
||||||
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
|
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
|
||||||
import { useToolbarVisibility } from './utils';
|
import { useToolbarVisibility } from './utils';
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||||
<ToolbarBody>
|
<ToolbarBody>
|
||||||
<ToolbarTitle
|
<ToolbarTitle
|
||||||
prefix={`Change #${changeRequest.number}:`}
|
prefix={`Change #${changeRequest.number}:`}
|
||||||
@@ -141,7 +141,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
|||||||
<ToolbarSubtitle
|
<ToolbarSubtitle
|
||||||
subtitle={
|
subtitle={
|
||||||
<>
|
<>
|
||||||
<DateRelative value={changeRequest.updatedAt} /> by {author}
|
<ToolbarDate value={changeRequest.updatedAt} /> by {author}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -167,10 +167,10 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
|||||||
icon="comment"
|
icon="comment"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Open production site */}
|
{/* Open published/live site */}
|
||||||
{site.urls.published ? (
|
{site.urls.published ? (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
title="Open production site"
|
title="Open live site"
|
||||||
href={getToolbarHref({
|
href={getToolbarHref({
|
||||||
href: site.urls.published,
|
href: site.urls.published,
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
@@ -207,13 +207,13 @@ function RevisionToolbar(props: ToolbarViewProps) {
|
|||||||
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||||
<ToolbarBody>
|
<ToolbarBody>
|
||||||
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
|
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
|
||||||
<ToolbarSubtitle
|
<ToolbarSubtitle
|
||||||
subtitle={
|
subtitle={
|
||||||
<>
|
<>
|
||||||
Created <DateRelative value={revision.createdAt} />
|
Created <ToolbarDate value={revision.createdAt} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -249,7 +249,7 @@ function RevisionToolbar(props: ToolbarViewProps) {
|
|||||||
/>
|
/>
|
||||||
{site.urls.published ? (
|
{site.urls.published ? (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
title="Open production site"
|
title="Open live site"
|
||||||
href={getToolbarHref({
|
href={getToolbarHref({
|
||||||
href: site.urls.published,
|
href: site.urls.published,
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
@@ -280,20 +280,10 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Toolbar
|
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||||
minified={minified}
|
|
||||||
onMinifiedChange={onMinifiedChange}
|
|
||||||
label="Only visible to your GitBook organization"
|
|
||||||
>
|
|
||||||
<ToolbarBody>
|
<ToolbarBody>
|
||||||
<ToolbarTitle suffix={context.site.title} />
|
<ToolbarTitle suffix={context.site.title} />
|
||||||
<ToolbarSubtitle
|
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
|
||||||
subtitle={
|
|
||||||
<>
|
|
||||||
Updated <DateRelative value={revision.createdAt} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ToolbarBody>
|
</ToolbarBody>
|
||||||
<ToolbarSeparator />
|
<ToolbarSeparator />
|
||||||
<ToolbarActions>
|
<ToolbarActions>
|
||||||
@@ -305,13 +295,13 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
|||||||
|
|
||||||
{/* Open site in GitBook */}
|
{/* Open site in GitBook */}
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
title="Open site in GitBook"
|
title="View site configuration"
|
||||||
href={getToolbarHref({
|
href={getToolbarHref({
|
||||||
href: site.urls.app,
|
href: site.urls.app,
|
||||||
siteId: site.id,
|
siteId: site.id,
|
||||||
buttonId: 'site',
|
buttonId: 'site',
|
||||||
})}
|
})}
|
||||||
icon="gears"
|
icon="folder-gear"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Customize in GitBook */}
|
{/* Customize in GitBook */}
|
||||||
@@ -361,13 +351,13 @@ function EditPageButton(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
title="Edit in GitBook"
|
title="Edit this page"
|
||||||
href={getToolbarHref({
|
href={getToolbarHref({
|
||||||
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
||||||
siteId,
|
siteId,
|
||||||
buttonId: 'edit',
|
buttonId: 'edit',
|
||||||
})}
|
})}
|
||||||
icon="pencil"
|
icon="pen-to-square"
|
||||||
motionValues={motionValues}
|
motionValues={motionValues}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,10 +8,20 @@ import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
|
|||||||
import styles from './Toolbar.module.css';
|
import styles from './Toolbar.module.css';
|
||||||
import { useToolbarControls } from './ToolbarControlsContext';
|
import { useToolbarControls } from './ToolbarControlsContext';
|
||||||
|
|
||||||
const ARC_DURATION_SECONDS = 0.4;
|
const ARC_PARAMS = {
|
||||||
const ARC_STAGGER_MS = 80;
|
arcWidth: 505,
|
||||||
const BASE_ROTATION_DEG = 95;
|
arcHeight: 400,
|
||||||
const ROTATION_STEP_DEG = 18;
|
arcRadius: 34,
|
||||||
|
startDistance: -240,
|
||||||
|
spreadDistance: 45,
|
||||||
|
fromDistance: -286,
|
||||||
|
fromSpread: 0,
|
||||||
|
durationSeconds: 0.6,
|
||||||
|
staggerMs: 80,
|
||||||
|
baseRotationDeg: 95,
|
||||||
|
rotationStepDeg: 18,
|
||||||
|
offsetAnchorY: 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
interface HideToolbarButtonProps {
|
interface HideToolbarButtonProps {
|
||||||
motionValues?: ToolbarButtonProps['motionValues'];
|
motionValues?: ToolbarButtonProps['motionValues'];
|
||||||
@@ -23,29 +33,75 @@ interface HideToolbarButtonProps {
|
|||||||
export function HideToolbarButton(props: HideToolbarButtonProps) {
|
export function HideToolbarButton(props: HideToolbarButtonProps) {
|
||||||
const { motionValues } = props;
|
const { motionValues } = props;
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [closing, setClosing] = React.useState(false);
|
||||||
|
const [shouldMirror, setShouldMirror] = React.useState(false);
|
||||||
|
const [shouldFlipVertical, setShouldFlipVertical] = React.useState(false);
|
||||||
const controls = useToolbarControls();
|
const controls = useToolbarControls();
|
||||||
|
const closingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLDivElement>(null);
|
const buttonRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const close = React.useCallback(() => {
|
||||||
|
if (!open || closing) return;
|
||||||
|
setClosing(true);
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (closingTimeoutRef.current) {
|
||||||
|
clearTimeout(closingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the exit animation to complete before unmounting
|
||||||
|
const totalDuration = ARC_PARAMS.durationSeconds * 1000 + 3 * ARC_PARAMS.staggerMs;
|
||||||
|
closingTimeoutRef.current = setTimeout(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setClosing(false);
|
||||||
|
closingTimeoutRef.current = null;
|
||||||
|
}, totalDuration);
|
||||||
|
}, [open, closing]);
|
||||||
|
|
||||||
|
// Clean up timeout on unmount
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (closingTimeoutRef.current) {
|
||||||
|
clearTimeout(closingTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClickOutsideArcMenu = (event: Event) => {
|
const handleClickOutsideArcMenu = (event: Event) => {
|
||||||
// Don't close the arc if we are clicking on the button itself
|
// Don't close the arc if we are clicking on the button itself
|
||||||
if (buttonRef.current?.contains(event.target as Node)) {
|
if (buttonRef.current?.contains(event.target as Node)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setOpen(false);
|
close();
|
||||||
};
|
};
|
||||||
|
// @ts-expect-error wrong type for ref
|
||||||
useOnClickOutside(ref, handleClickOutsideArcMenu);
|
useOnClickOutside(ref, handleClickOutsideArcMenu);
|
||||||
|
|
||||||
// Close arc menu on scroll
|
// Measure button position to decide arc direction when opening
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open && buttonRef.current) {
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const distanceFromRight = window.innerWidth - rect.right;
|
||||||
|
setShouldMirror(distanceFromRight < 250);
|
||||||
|
setShouldFlipVertical(rect.top < 250);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Close arc menu on scroll or resize
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
const handleScroll = () => setOpen(false);
|
const handleClose = () => close();
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
window.addEventListener('scroll', handleClose, { passive: true });
|
||||||
|
window.addEventListener('resize', handleClose, { passive: true });
|
||||||
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => {
|
||||||
}, [open]);
|
window.removeEventListener('scroll', handleClose);
|
||||||
|
window.removeEventListener('resize', handleClose);
|
||||||
|
};
|
||||||
|
}, [open, close]);
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
controls?.minimize
|
controls?.minimize
|
||||||
@@ -83,36 +139,80 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
|
|||||||
return (
|
return (
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
title={open ? 'Hide options' : 'Hide toolbar'}
|
title={open ? undefined : 'Hide toolbar'}
|
||||||
|
className={
|
||||||
|
open || closing
|
||||||
|
? 'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen((v) => !v);
|
if (open || closing) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
motionValues={motionValues}
|
motionValues={motionValues}
|
||||||
icon="eye-slash"
|
icon="gear"
|
||||||
>
|
>
|
||||||
{/* Expanding arc menu */}
|
{/* Expanding arc menu */}
|
||||||
{open && (
|
{(open || closing) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
|
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
|
||||||
style={sharedMotionStyle as React.CSSProperties | undefined}
|
style={
|
||||||
|
{
|
||||||
|
...sharedMotionStyle,
|
||||||
|
'--arc-width': `${ARC_PARAMS.arcWidth}px`,
|
||||||
|
'--arc-height': `${ARC_PARAMS.arcHeight}px`,
|
||||||
|
'--arc-radius': `${ARC_PARAMS.arcRadius}%`,
|
||||||
|
'--start-distance': `${ARC_PARAMS.startDistance}px`,
|
||||||
|
'--spread-distance': `${ARC_PARAMS.spreadDistance}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'pointer-events-none absolute left-0 overflow-visible',
|
'pointer-events-none absolute overflow-visible',
|
||||||
|
shouldMirror ? '' : 'left-0',
|
||||||
styles.arcMenuPath
|
styles.arcMenuPath
|
||||||
)}
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
...(shouldMirror
|
||||||
|
? {
|
||||||
|
'--arc-mirror-offset': '-345px',
|
||||||
|
'--spread-distance': '37px',
|
||||||
|
right: 'var(--arc-mirror-offset)',
|
||||||
|
}
|
||||||
|
: undefined),
|
||||||
|
...(shouldFlipVertical
|
||||||
|
? {
|
||||||
|
bottom: 'auto',
|
||||||
|
top: 'calc(var(--arc-height) / -2)',
|
||||||
|
transform: 'scaleY(-1)',
|
||||||
|
}
|
||||||
|
: undefined),
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<ArcToolbarButton
|
<ArcToolbarButton
|
||||||
index={index}
|
index={index}
|
||||||
staggerIndex={items.length - 1 - index}
|
staggerIndex={closing ? index : items.length - 1 - index}
|
||||||
key={item.icon}
|
key={item.id}
|
||||||
|
mirrored={shouldMirror}
|
||||||
|
flippedVertical={shouldFlipVertical}
|
||||||
|
closing={closing}
|
||||||
{...item}
|
{...item}
|
||||||
onClick={() => {
|
onClick={
|
||||||
setOpen(false);
|
item.isLabel
|
||||||
item.onClick?.();
|
? undefined
|
||||||
}}
|
: () => {
|
||||||
|
close();
|
||||||
|
item.onClick?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -126,13 +226,17 @@ type ArcMenuItem = {
|
|||||||
id: string;
|
id: string;
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
isLabel?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
|
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick' | 'isLabel'> & {
|
||||||
index: number;
|
index: number;
|
||||||
staggerIndex?: number;
|
staggerIndex?: number;
|
||||||
|
mirrored?: boolean;
|
||||||
|
flippedVertical?: boolean;
|
||||||
|
closing?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
@@ -142,40 +246,41 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
|
|||||||
const {
|
const {
|
||||||
index,
|
index,
|
||||||
staggerIndex = index,
|
staggerIndex = index,
|
||||||
|
mirrored = false,
|
||||||
|
flippedVertical = false,
|
||||||
|
closing = false,
|
||||||
label,
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
className,
|
className,
|
||||||
onClick = () => {},
|
onClick,
|
||||||
icon,
|
icon,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
|
isLabel = false,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
|
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
|
||||||
|
const fromOffset = `calc(${ARC_PARAMS.fromDistance}px + ${index} * ${ARC_PARAMS.fromSpread}px)`;
|
||||||
|
|
||||||
// Calculate rotation based on position along the arc
|
const itemRotation = ARC_PARAMS.baseRotationDeg - index * ARC_PARAMS.rotationStepDeg;
|
||||||
const calculateRotation = () => {
|
|
||||||
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemRotation = calculateRotation();
|
const Tag = isLabel ? 'div' : 'button';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none">
|
<div className="pointer-events-none">
|
||||||
<button
|
<Tag
|
||||||
type="button"
|
{...(Tag === 'button' ? { type: 'button' as const } : {})}
|
||||||
onClick={() => {
|
onClick={onClick ? () => onClick() : undefined}
|
||||||
onClick();
|
|
||||||
}}
|
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
|
'--from-offset-distance': fromOffset,
|
||||||
'--target-offset-distance': targetOffset,
|
'--target-offset-distance': targetOffset,
|
||||||
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
|
'--arc-duration': `${ARC_PARAMS.durationSeconds}s`,
|
||||||
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
|
'--arc-delay': `${(staggerIndex ?? 0) * ARC_PARAMS.staggerMs}ms`,
|
||||||
'--rotation-offset': `${itemRotation}deg`,
|
'--rotation-offset': `${itemRotation}deg`,
|
||||||
offsetPath: 'border-box',
|
offsetPath: 'border-box',
|
||||||
offsetDistance: targetOffset,
|
offsetDistance: fromOffset,
|
||||||
offsetAnchor: '0% 40%',
|
offsetAnchor: `0% ${ARC_PARAMS.offsetAnchorY}%`,
|
||||||
offsetRotate: `auto ${itemRotation}deg`,
|
offsetRotate: 'auto 90deg',
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
@@ -184,49 +289,61 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
|
|||||||
'top-0',
|
'top-0',
|
||||||
'left-0',
|
'left-0',
|
||||||
'w-40',
|
'w-40',
|
||||||
'opacity-0',
|
|
||||||
'pointer-events-auto',
|
'pointer-events-auto',
|
||||||
'flex',
|
'flex',
|
||||||
'items-center',
|
'items-center',
|
||||||
'gap-2',
|
'gap-2',
|
||||||
styles.arcMenuItem,
|
mirrored ? 'flex-row-reverse' : '',
|
||||||
|
closing ? styles.arcMenuItemExit : styles.arcMenuItem,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={tcls(
|
className="flex items-center gap-2"
|
||||||
'flex shrink-0 items-center justify-center gap-1',
|
style={flippedVertical ? { transform: 'scaleY(-1)' } : undefined}
|
||||||
'h-8 w-8 rounded-full border',
|
|
||||||
'truncate text-sm',
|
|
||||||
'cursor-pointer transition-colors',
|
|
||||||
'group-hover:-rotate-5 group-hover:scale-105',
|
|
||||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
|
||||||
'text-tint-1 dark:text-tint-12',
|
|
||||||
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
|
|
||||||
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
|
|
||||||
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Icon
|
{isLabel ? (
|
||||||
icon={icon as IconName}
|
<div className="flex items-center gap-2 rounded-lg bg-[rgba(79,139,255,0.1)] px-2 py-1 text-[#4F8BFF] backdrop-blur-sm">
|
||||||
iconStyle={IconStyle.Solid}
|
<Icon
|
||||||
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
|
icon={icon as IconName}
|
||||||
/>
|
iconStyle={IconStyle.Solid}
|
||||||
|
className="size-3.5 shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="whitespace-nowrap font-normal text-sm">{label}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={tcls(
|
||||||
|
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
|
||||||
|
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||||
|
'group-hover:scale-105',
|
||||||
|
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||||
|
'bg-[var(--toolbar-bg)] text-tint-7 hover:text-tint-1 dark:text-tint-12',
|
||||||
|
'group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={icon as IconName}
|
||||||
|
iconStyle={IconStyle.Solid}
|
||||||
|
className={tcls(
|
||||||
|
'size-3.5 shrink-0 group-hover:scale-110',
|
||||||
|
iconClassName
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={tcls(
|
||||||
|
'whitespace-nowrap rounded-lg border-[0.5px] border-neutral-5 border-solid bg-[var(--toolbar-bg)] px-3 py-1 font-normal text-neutral-1 text-sm transition-[background-color,transform] group-hover:scale-105 group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)] dark:border-neutral-8 dark:text-neutral-12',
|
||||||
|
closing ? styles.arcLabelFadeOut : styles.arcLabelFadeIn
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
</Tag>
|
||||||
className={tcls(
|
|
||||||
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
|
|
||||||
'group-hover:rotate-2 group-hover:scale-105',
|
|
||||||
'text-neutral-1 dark:text-neutral-12',
|
|
||||||
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
.arcMenuItem {
|
.arcMenuItem {
|
||||||
animation-name: hide-toolbar-arc-enter;
|
animation-name: hide-toolbar-arc-enter;
|
||||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
animation-fill-mode: forwards;
|
animation-fill-mode: both;
|
||||||
animation-duration: var(--arc-duration, 0.4s);
|
animation-duration: var(--arc-duration, 0.4s);
|
||||||
animation-delay: var(--arc-delay, 0s);
|
animation-delay: var(--arc-delay, 0s);
|
||||||
transform-origin: center left;
|
transform-origin: center left;
|
||||||
@@ -28,13 +28,101 @@
|
|||||||
|
|
||||||
@keyframes hide-toolbar-arc-enter {
|
@keyframes hide-toolbar-arc-enter {
|
||||||
from {
|
from {
|
||||||
offset-distance: var(--start-distance);
|
offset-distance: var(--from-offset-distance);
|
||||||
transform: scale(0.5);
|
offset-rotate: auto 90deg;
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
offset-distance: var(--target-offset-distance);
|
offset-distance: var(--target-offset-distance);
|
||||||
transform: scale(1);
|
offset-rotate: auto var(--rotation-offset);
|
||||||
opacity: 1;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hide-toolbar-arc-exit {
|
||||||
|
from {
|
||||||
|
offset-distance: var(--target-offset-distance);
|
||||||
|
offset-rotate: auto var(--rotation-offset);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
offset-distance: var(--from-offset-distance);
|
||||||
|
offset-rotate: auto 90deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcMenuItemExit {
|
||||||
|
animation-name: hide-toolbar-arc-exit;
|
||||||
|
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
animation-duration: var(--arc-duration, 0.3s);
|
||||||
|
animation-delay: var(--arc-delay, 0s);
|
||||||
|
transform-origin: center left;
|
||||||
|
offset-path: border-box;
|
||||||
|
offset-anchor: 0% 0%;
|
||||||
|
offset-rotate: auto var(--rotation-offset, 0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Arc label fade ── */
|
||||||
|
|
||||||
|
@keyframes arc-label-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes arc-label-fade-out {
|
||||||
|
from { opacity: 1; }
|
||||||
|
to { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcLabelFadeIn {
|
||||||
|
animation: arc-label-fade-in 0.3s ease-out 0.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arcLabelFadeOut {
|
||||||
|
animation: arc-label-fade-out 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Glass effect for the toolbar pill ── */
|
||||||
|
|
||||||
|
.glassLayer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glassLayer::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -50%;
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-backdrop-filter: blur(9px);
|
||||||
|
backdrop-filter: blur(9px);
|
||||||
|
filter: url(#glass-distortion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glassLayer::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
background: rgba(39, 39, 39, 0.55);
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 1px rgba(255, 255, 255, 0.08),
|
||||||
|
inset -1px -1px 1px rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .glassLayer::after {
|
||||||
|
background: rgba(30, 30, 30, 0.6);
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 2px 1px rgba(255, 255, 255, 0.05),
|
||||||
|
inset -1px -1px 1px rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.glassLayer::before {
|
||||||
|
filter: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,71 @@
|
|||||||
import {
|
import {
|
||||||
AnimatePresence,
|
AnimatePresence,
|
||||||
type MotionValue,
|
type MotionValue,
|
||||||
|
animate,
|
||||||
motion,
|
motion,
|
||||||
|
useMotionValue,
|
||||||
useReducedMotion,
|
useReducedMotion,
|
||||||
useSpring,
|
useSpring,
|
||||||
} from 'motion/react';
|
} from 'motion/react';
|
||||||
import React from 'react';
|
import React, { isValidElement } from 'react';
|
||||||
import { AnimatedLogo } from './AnimatedLogo';
|
import { AnimatedLogo } from './AnimatedLogo';
|
||||||
import { useToolbarControls } from './ToolbarControlsContext';
|
import { useToolbarControls } from './ToolbarControlsContext';
|
||||||
|
import {
|
||||||
|
getStoredPosition,
|
||||||
|
getVisibilityHintDismissed,
|
||||||
|
setStoredPosition,
|
||||||
|
setVisibilityHintDismissed,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
import { tcls } from '@/lib/tailwind';
|
import { tcls } from '@/lib/tailwind';
|
||||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||||
import { Tooltip } from '../primitives';
|
import { Tooltip } from '../primitives';
|
||||||
|
// import styles from './Toolbar.module.css';
|
||||||
import { getCopyVariants, toolbarEasings } from './transitions';
|
import { getCopyVariants, toolbarEasings } from './transitions';
|
||||||
import { useMagnificationEffect } from './useMagnificationEffect';
|
import { useMagnificationEffect } from './useMagnificationEffect';
|
||||||
|
|
||||||
|
const DEBUG = true;
|
||||||
|
|
||||||
const DURATION_LOGO_APPEARANCE = 2000;
|
const DURATION_LOGO_APPEARANCE = 2000;
|
||||||
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
|
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
|
||||||
|
|
||||||
|
const ToolbarDraggingContext = React.createContext(false);
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
label: React.ReactNode;
|
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
minified: boolean;
|
minified: boolean;
|
||||||
onMinifiedChange: (value: boolean) => void;
|
onMinifiedChange: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar(props: ToolbarProps) {
|
export function Toolbar(props: ToolbarProps) {
|
||||||
const { children, label, minified, onMinifiedChange } = props;
|
const { children, minified, onMinifiedChange } = props;
|
||||||
const controls = useToolbarControls();
|
const controls = useToolbarControls();
|
||||||
const [isReady, setIsReady] = React.useState(false);
|
const [isReady, setIsReady] = React.useState(false);
|
||||||
const autoExpandTriggeredRef = React.useRef(false);
|
const autoExpandTriggeredRef = React.useRef(false);
|
||||||
|
const constraintsRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const innerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const isDraggingRef = React.useRef(false);
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
const prevWidthRef = React.useRef<number | undefined>(undefined);
|
||||||
|
const [hintDismissed, setHintDismissed] = React.useState(() =>
|
||||||
|
typeof window !== 'undefined' ? getVisibilityHintDismissed() : false
|
||||||
|
);
|
||||||
|
|
||||||
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
|
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
|
||||||
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
|
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
|
||||||
|
|
||||||
|
// Restore saved drag position (synchronous read — no flash)
|
||||||
|
const storedPos = React.useMemo(
|
||||||
|
() => (typeof window !== 'undefined' ? getStoredPosition() : null),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const x = useMotionValue(storedPos?.x ?? 0);
|
||||||
|
const y = useMotionValue(storedPos?.y ?? 0);
|
||||||
|
|
||||||
|
const savePosition = React.useCallback(() => {
|
||||||
|
setStoredPosition({ x: x.get(), y: y.get() });
|
||||||
|
}, [x, y]);
|
||||||
|
|
||||||
// Wait for page to be ready, then show the toolbar
|
// Wait for page to be ready, then show the toolbar
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleLoad = () => {
|
const handleLoad = () => {
|
||||||
@@ -80,58 +112,432 @@ export function Toolbar(props: ToolbarProps) {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!minified) {
|
if (!minified) {
|
||||||
// Any manual expansion should stop the logo animation so the icon stays in its
|
// Any manual expansion should stop the logo animation so the icon stays in its
|
||||||
// “settled” state once the toolbar is open.
|
// "settled" state once the toolbar is open.
|
||||||
setShouldAnimateLogo(false);
|
setShouldAnimateLogo(false);
|
||||||
}
|
}
|
||||||
}, [minified]);
|
}, [minified]);
|
||||||
|
|
||||||
|
// Detect pinning state based on actual viewport position
|
||||||
|
const debugRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const debugLeftEdgeRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const debugRightEdgeRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const debugArrowRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const lastDecisionRef = React.useRef<'left' | 'right' | 'center'>('center');
|
||||||
|
|
||||||
|
const computePinState = React.useCallback(
|
||||||
|
(overrideWidth?: number) => {
|
||||||
|
if (!innerRef.current) return 'center' as const;
|
||||||
|
const rect = innerRef.current.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const edgeThreshold = 80; // px from viewport edge
|
||||||
|
|
||||||
|
// When overrideWidth is provided (e.g. prevWidth), reconstruct where the
|
||||||
|
// edges WERE before the resize. The center stays the same because x hasn't
|
||||||
|
// been compensated yet and flexbox centering is width-independent.
|
||||||
|
let left = rect.left;
|
||||||
|
let right = rect.right;
|
||||||
|
if (overrideWidth !== undefined) {
|
||||||
|
const center = (rect.left + rect.right) / 2;
|
||||||
|
left = center - overrideWidth / 2;
|
||||||
|
right = center + overrideWidth / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pin =
|
||||||
|
right > viewportWidth - edgeThreshold
|
||||||
|
? ('right' as const)
|
||||||
|
: left < edgeThreshold
|
||||||
|
? ('left' as const)
|
||||||
|
: ('center' as const);
|
||||||
|
|
||||||
|
// Update debug overlays directly via DOM to avoid React re-render lag
|
||||||
|
if (DEBUG) {
|
||||||
|
// Text overlay
|
||||||
|
if (debugRef.current) {
|
||||||
|
const el = debugRef.current;
|
||||||
|
el.textContent = '';
|
||||||
|
const decisionColor = lastDecisionRef.current === 'center' ? '#facc15' : '#4ade80';
|
||||||
|
const liveColor = pin === 'center' ? '#facc15' : '#4ade80';
|
||||||
|
const preInfo =
|
||||||
|
overrideWidth !== undefined
|
||||||
|
? ` | pre: L${Math.round(left)} R${Math.round(right)}`
|
||||||
|
: '';
|
||||||
|
el.innerHTML = `decision: <span style="color:${decisionColor}">${lastDecisionRef.current}</span> | live: <span style="color:${liveColor}">${pin}</span> | x: ${Math.round(x.get())} y: ${Math.round(y.get())} | rect: L${Math.round(rect.left)} R${Math.round(rect.right)} (vw: ${viewportWidth})${preInfo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge anchor indicators on the toolbar pill (track live state)
|
||||||
|
if (debugLeftEdgeRef.current) {
|
||||||
|
debugLeftEdgeRef.current.style.backgroundColor =
|
||||||
|
pin === 'left' ? '#4ade80' : '#ffffff20';
|
||||||
|
debugLeftEdgeRef.current.style.boxShadow =
|
||||||
|
pin === 'left' ? '0 0 8px #4ade80' : 'none';
|
||||||
|
}
|
||||||
|
if (debugRightEdgeRef.current) {
|
||||||
|
debugRightEdgeRef.current.style.backgroundColor =
|
||||||
|
pin === 'right' ? '#4ade80' : '#ffffff20';
|
||||||
|
debugRightEdgeRef.current.style.boxShadow =
|
||||||
|
pin === 'right' ? '0 0 8px #4ade80' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow showing expansion direction (tracks live state so it
|
||||||
|
// updates as you drag, showing what WOULD happen on click)
|
||||||
|
if (debugArrowRef.current) {
|
||||||
|
debugArrowRef.current.textContent =
|
||||||
|
pin === 'left'
|
||||||
|
? '→'
|
||||||
|
: pin === 'right'
|
||||||
|
? '←'
|
||||||
|
: '↔';
|
||||||
|
debugArrowRef.current.style.color =
|
||||||
|
pin === 'center' ? '#facc15' : '#4ade80';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pin;
|
||||||
|
},
|
||||||
|
[x, y]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update debug display reactively when x/y change (direct DOM updates, no re-renders)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!DEBUG) return;
|
||||||
|
const unsubX = x.on('change', () => computePinState());
|
||||||
|
const unsubY = y.on('change', () => computePinState());
|
||||||
|
// Initial computation
|
||||||
|
computePinState();
|
||||||
|
return () => {
|
||||||
|
unsubX();
|
||||||
|
unsubY();
|
||||||
|
};
|
||||||
|
}, [x, y, computePinState]);
|
||||||
|
|
||||||
|
// Compensate drag x position when the toolbar width changes (expand/collapse) so the
|
||||||
|
// pinned edge stays anchored. Uses viewport-aware edge detection instead of a fixed
|
||||||
|
// x-threshold so it works correctly at any viewport width.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!innerRef.current) return;
|
||||||
|
|
||||||
|
const newWidth = innerRef.current.offsetWidth;
|
||||||
|
const prevWidth = prevWidthRef.current;
|
||||||
|
|
||||||
|
if (prevWidth !== undefined && prevWidth !== newWidth) {
|
||||||
|
const delta = newWidth - prevWidth;
|
||||||
|
const currentX = x.get();
|
||||||
|
const currentY = y.get();
|
||||||
|
const expanding = newWidth > prevWidth;
|
||||||
|
|
||||||
|
// Grab the raw post-resize rect for logging
|
||||||
|
const rawRect = innerRef.current.getBoundingClientRect();
|
||||||
|
const center = (rawRect.left + rawRect.right) / 2;
|
||||||
|
const preResizeLeft = center - prevWidth / 2;
|
||||||
|
const preResizeRight = center + prevWidth / 2;
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const edgeThreshold = 80;
|
||||||
|
|
||||||
|
// Use prevWidth to reconstruct where the edges WERE before the resize.
|
||||||
|
const pin = computePinState(prevWidth);
|
||||||
|
lastDecisionRef.current = pin;
|
||||||
|
// Refresh debug overlay so "decision" label updates immediately
|
||||||
|
computePinState(prevWidth);
|
||||||
|
|
||||||
|
let compensation = 0;
|
||||||
|
if (pin === 'right') {
|
||||||
|
compensation = -delta / 2;
|
||||||
|
} else if (pin === 'left') {
|
||||||
|
compensation = delta / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console -- temporary debug logging
|
||||||
|
console.warn(
|
||||||
|
`[Toolbar Pin] ${expanding ? 'EXPAND' : 'COLLAPSE'}\n` +
|
||||||
|
` Action: ${expanding ? 'minified → expanded' : 'expanded → minified'}\n` +
|
||||||
|
` Width: ${prevWidth}px → ${newWidth}px (delta: ${delta}px)\n` +
|
||||||
|
` Motion: x=${Math.round(currentX)}, y=${Math.round(currentY)}\n` +
|
||||||
|
` Viewport: ${viewportWidth}px, edgeThreshold: ${edgeThreshold}px\n` +
|
||||||
|
` Post-resize rect (raw): L=${Math.round(rawRect.left)} R=${Math.round(rawRect.right)}\n` +
|
||||||
|
` Pre-resize rect (reconstructed): L=${Math.round(preResizeLeft)} R=${Math.round(preResizeRight)}\n` +
|
||||||
|
` Center: ${Math.round(center)}\n` +
|
||||||
|
` Pin check: left=${Math.round(preResizeLeft)} < ${edgeThreshold}? ${preResizeLeft < edgeThreshold} | right=${Math.round(preResizeRight)} > ${viewportWidth - edgeThreshold}? ${preResizeRight > viewportWidth - edgeThreshold}\n` +
|
||||||
|
` → Decision: ${pin}\n` +
|
||||||
|
` → Compensation: ${compensation}px (x: ${Math.round(currentX)} → ${Math.round(currentX + compensation)})`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compensation !== 0) {
|
||||||
|
animate(x, currentX + compensation, {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 200,
|
||||||
|
damping: 30,
|
||||||
|
mass: 1,
|
||||||
|
}).then(savePosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevWidthRef.current = newWidth;
|
||||||
|
}, [minified, x, savePosition, computePinState]);
|
||||||
|
|
||||||
// Don't render anything until page is ready
|
// Don't render anything until page is ready
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip label={label}>
|
<ToolbarDraggingContext.Provider value={isDragging}>
|
||||||
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
|
{/* Hidden SVG filter for glass distortion */}
|
||||||
<AnimatePresence mode="wait">
|
<svg aria-hidden="true" className="pointer-events-none fixed size-0">
|
||||||
<motion.div
|
<defs>
|
||||||
onClick={() => {
|
<filter id="glass-distortion">
|
||||||
if (minified) {
|
<feTurbulence
|
||||||
setShouldAnimateLogo(false);
|
type="fractalNoise"
|
||||||
onMinifiedChange(false);
|
baseFrequency="0.016 0.012"
|
||||||
}
|
numOctaves={3}
|
||||||
}}
|
seed={39}
|
||||||
layout
|
result="noise"
|
||||||
transition={toolbarEasings.spring}
|
/>
|
||||||
className={tcls(
|
<feGaussianBlur in="noise" stdDeviation={6} result="softNoise" />
|
||||||
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
|
<feDisplacementMap
|
||||||
'flex',
|
in="SourceGraphic"
|
||||||
'items-center',
|
in2="softNoise"
|
||||||
'justify-center',
|
scale={160}
|
||||||
'min-h-11',
|
xChannelSelector="R"
|
||||||
'min-w-12',
|
yChannelSelector="G"
|
||||||
'h-12',
|
/>
|
||||||
'py-2',
|
</filter>
|
||||||
'backdrop-blur-sm',
|
</defs>
|
||||||
'origin-center',
|
</svg>
|
||||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
<div
|
||||||
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
|
ref={constraintsRef}
|
||||||
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
|
className="pointer-events-none fixed inset-2 z-40 flex items-end justify-center"
|
||||||
)}
|
>
|
||||||
style={{
|
<motion.div
|
||||||
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
drag
|
||||||
}}
|
dragConstraints={constraintsRef}
|
||||||
>
|
dragElastic={0.04}
|
||||||
{/* Logo with stroke segments animation in blue-tints */}
|
dragTransition={{
|
||||||
<motion.div layout>
|
power: 0.2,
|
||||||
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
|
timeConstant: 200,
|
||||||
|
bounceStiffness: 800,
|
||||||
|
bounceDamping: 60,
|
||||||
|
}}
|
||||||
|
style={{ x, y }}
|
||||||
|
onDragStart={() => {
|
||||||
|
isDraggingRef.current = true;
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isDraggingRef.current = false;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDragTransitionEnd={savePosition}
|
||||||
|
className="pointer-events-auto relative w-auto max-w-xl cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
{/* Visibility peek label — rendered as a sibling before the pill so it paints behind it */}
|
||||||
|
{!hintDismissed && (
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
y: !minified && !isDragging ? 0 : 10,
|
||||||
|
opacity: !minified && !isDragging ? 1 : 0,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 25,
|
||||||
|
}}
|
||||||
|
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3"
|
||||||
|
style={{ paddingBlock: '2px' }}
|
||||||
|
>
|
||||||
|
<span className="whitespace-nowrap text-[11px] text-neutral-9">
|
||||||
|
Only visible to your GitBook organization
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pointer-events-auto cursor-pointer rounded border border-tint-5 bg-tint-2 px-1 py-px text-[10px] text-tint-12 transition-colors hover:scale-102 hover:bg-tint-3 dark:border-tint-11/50 dark:bg-white dark:text-tint-1 dark:hover:bg-tint-11/20"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setVisibilityHintDismissed();
|
||||||
|
setHintDismissed(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!minified ? children : null}
|
<AnimatePresence mode="wait">
|
||||||
</motion.div>
|
<motion.div
|
||||||
</AnimatePresence>
|
ref={innerRef}
|
||||||
</motion.div>
|
onClick={() => {
|
||||||
</Tooltip>
|
if (isDraggingRef.current) return;
|
||||||
|
if (minified) {
|
||||||
|
setShouldAnimateLogo(false);
|
||||||
|
onMinifiedChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
layout
|
||||||
|
transition={toolbarEasings.spring}
|
||||||
|
className={tcls(
|
||||||
|
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
|
||||||
|
'relative',
|
||||||
|
'flex',
|
||||||
|
'items-center',
|
||||||
|
'justify-center',
|
||||||
|
'min-h-11',
|
||||||
|
'min-w-12',
|
||||||
|
'h-12',
|
||||||
|
'py-2',
|
||||||
|
'origin-center',
|
||||||
|
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||||
|
'bg-[var(--toolbar-bg)]'
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--toolbar-bg': '#1f1d1b',
|
||||||
|
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||||
|
zIndex: 1, // Ensure pill stacks above the peek label sibling
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Glass effect layer (disabled for now) */}
|
||||||
|
{/* <div className={styles.glassLayer} /> */}
|
||||||
|
|
||||||
|
{/* Debug: anchor edge indicators */}
|
||||||
|
{DEBUG && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={debugLeftEdgeRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: -1,
|
||||||
|
top: '15%',
|
||||||
|
bottom: '15%',
|
||||||
|
width: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: '#ffffff20',
|
||||||
|
transition: 'background-color 0.2s, box-shadow 0.2s',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={debugRightEdgeRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: -1,
|
||||||
|
top: '15%',
|
||||||
|
bottom: '15%',
|
||||||
|
width: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: '#ffffff20',
|
||||||
|
transition: 'background-color 0.2s, box-shadow 0.2s',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
ref={debugArrowRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -20,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logo — double-click to minimize without opening the arc menu */}
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (isDraggingRef.current || minified) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
onMinifiedChange(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{!minified ? children : null}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Debug overlay for pinning state — updated via DOM ref to avoid re-render lag */}
|
||||||
|
{DEBUG && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={debugRef}
|
||||||
|
className="pointer-events-none whitespace-nowrap rounded bg-black/80 px-2 py-1 font-mono text-[10px] text-white"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
zIndex: 9999,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Viewport edge threshold zones */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 80,
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||||
|
borderRight: '1px dashed rgba(74, 222, 128, 0.3)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 39,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 80,
|
||||||
|
right: 4,
|
||||||
|
fontSize: 9,
|
||||||
|
color: 'rgba(74, 222, 128, 0.5)',
|
||||||
|
writingMode: 'vertical-rl',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
pin-left zone (80px)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
width: 80,
|
||||||
|
height: '100vh',
|
||||||
|
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||||
|
borderLeft: '1px dashed rgba(74, 222, 128, 0.3)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 39,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 80,
|
||||||
|
left: 4,
|
||||||
|
fontSize: 9,
|
||||||
|
color: 'rgba(74, 222, 128, 0.5)',
|
||||||
|
writingMode: 'vertical-rl',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
pin-right zone (80px)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ToolbarDraggingContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,63 +603,53 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
|
|||||||
children,
|
children,
|
||||||
} = props;
|
} = props;
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
|
const isDragging = React.useContext(ToolbarDraggingContext);
|
||||||
|
|
||||||
|
const anchor = (
|
||||||
|
<motion.a
|
||||||
|
href={href}
|
||||||
|
onClick={onClick}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={
|
||||||
|
reduceMotion
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
scale: motionValues?.scale,
|
||||||
|
x: motionValues?.x,
|
||||||
|
transformOrigin: 'bottom center',
|
||||||
|
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||||
|
...style,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 400,
|
||||||
|
damping: 30,
|
||||||
|
}}
|
||||||
|
className={tcls(
|
||||||
|
'toolbar-button',
|
||||||
|
className,
|
||||||
|
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
|
||||||
|
'text-tint-7 hover:text-tint-1',
|
||||||
|
'dark:text-tint-12',
|
||||||
|
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||||
|
'bg-[var(--toolbar-bg)]',
|
||||||
|
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={icon}
|
||||||
|
iconStyle={IconStyle.Solid}
|
||||||
|
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
|
||||||
|
/>
|
||||||
|
</motion.a>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
|
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
|
||||||
{children ? children : null}
|
{children ? children : null}
|
||||||
<Tooltip label={title}>
|
{title && !isDragging ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
|
||||||
<motion.a
|
|
||||||
href={href}
|
|
||||||
onClick={onClick}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={
|
|
||||||
reduceMotion
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
scale: motionValues?.scale,
|
|
||||||
x: motionValues?.x,
|
|
||||||
transformOrigin: 'bottom center',
|
|
||||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
|
||||||
...style,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 400,
|
|
||||||
damping: 30,
|
|
||||||
}}
|
|
||||||
className={tcls(
|
|
||||||
'toolbar-button',
|
|
||||||
className,
|
|
||||||
'flex',
|
|
||||||
'relative',
|
|
||||||
'items-center',
|
|
||||||
'justify-center',
|
|
||||||
'gap-1',
|
|
||||||
'text-sm',
|
|
||||||
'rounded-full',
|
|
||||||
'truncate',
|
|
||||||
'text-tint-1',
|
|
||||||
'dark:text-tint-12',
|
|
||||||
'cursor-pointer',
|
|
||||||
'transition-colors',
|
|
||||||
'size-8',
|
|
||||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
|
||||||
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
|
|
||||||
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={icon}
|
|
||||||
iconStyle={IconStyle.Solid}
|
|
||||||
className={tcls(
|
|
||||||
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
|
|
||||||
iconClassName
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</motion.a>
|
|
||||||
</Tooltip>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -291,6 +687,10 @@ function ToolbarButtonWrapper(props: {
|
|||||||
x: springX,
|
x: springX,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isValidElement<{ motionValues: typeof motionValues }>(child)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return React.cloneElement(child, {
|
return React.cloneElement(child, {
|
||||||
motionValues,
|
motionValues,
|
||||||
});
|
});
|
||||||
@@ -335,7 +735,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<motion.span
|
<motion.span
|
||||||
{...getCopyVariants(1)}
|
{...getCopyVariants(1)}
|
||||||
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
|
className="inline-flex items-center gap-1 text-neutral-1/80 text-xxs dark:text-neutral-12/80"
|
||||||
>
|
>
|
||||||
{props.subtitle}
|
{props.subtitle}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useLanguage } from '@/intl/client';
|
||||||
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
|
|
||||||
|
type DateFormat = 'relative' | 'weekday' | 'full';
|
||||||
|
|
||||||
|
const DATE_FORMATS: DateFormat[] = ['relative', 'weekday', 'full'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toolbar-specific date display that cycles between relative, weekday, and full formats on click.
|
||||||
|
*/
|
||||||
|
export function ToolbarDate(props: { value: string }) {
|
||||||
|
const { value } = props;
|
||||||
|
const language = useLanguage();
|
||||||
|
const [now, setNow] = React.useState<number>(Date.now());
|
||||||
|
const [formatIndex, setFormatIndex] = React.useState(0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = setInterval(
|
||||||
|
() => {
|
||||||
|
setNow(Date.now());
|
||||||
|
},
|
||||||
|
30 * 60 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
const format = DATE_FORMATS[formatIndex] as DateFormat;
|
||||||
|
|
||||||
|
const formatted = React.useMemo(() => {
|
||||||
|
return formatDateValue(format, language.locale, now, date);
|
||||||
|
}, [format, language.locale, now, date]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
{/* Vertical dots indicating cycleable formats */}
|
||||||
|
<div className="flex flex-col items-center gap-[1px]">
|
||||||
|
{DATE_FORMATS.map((fmt, i) => (
|
||||||
|
<motion.span
|
||||||
|
key={fmt}
|
||||||
|
animate={{
|
||||||
|
opacity: i === formatIndex ? 1 : 0.3,
|
||||||
|
scale: i === formatIndex ? 1 : 0.75,
|
||||||
|
}}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||||
|
className="block size-[3px] rounded-full bg-current"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<time
|
||||||
|
data-visual-test="transparent"
|
||||||
|
suppressHydrationWarning={true}
|
||||||
|
dateTime={value}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="relative inline-flex min-w-24 cursor-pointer overflow-hidden font-semibold transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
|
<motion.span
|
||||||
|
key={formatIndex}
|
||||||
|
initial={{ y: '-100%', opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: '100%', opacity: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||||
|
>
|
||||||
|
{formatted}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateValue(format: DateFormat, locale: string, now: number, date: Date): string {
|
||||||
|
switch (format) {
|
||||||
|
case 'relative':
|
||||||
|
return formatRelative(locale, now - date.getTime());
|
||||||
|
case 'weekday':
|
||||||
|
return date.toLocaleDateString(locale, {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
case 'full':
|
||||||
|
return date.toLocaleString(locale, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(locale: string, diff: number) {
|
||||||
|
if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat === 'undefined') {
|
||||||
|
const days = Math.floor(diff / 24 / 60 / 60 / 1000);
|
||||||
|
return `${days} days ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(locale, { style: 'long' });
|
||||||
|
|
||||||
|
if (diff < 60 * 60 * 1000) {
|
||||||
|
const minutes = Math.floor(diff / 60 / 1000);
|
||||||
|
return rtf.format(-minutes, 'minute');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff < 24 * 60 * 60 * 1000) {
|
||||||
|
const hours = Math.floor(diff / 60 / 60 / 1000);
|
||||||
|
return rtf.format(-hours, 'hour');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff < 30 * 24 * 60 * 60 * 1000) {
|
||||||
|
const days = Math.floor(diff / 24 / 60 / 60 / 1000);
|
||||||
|
return rtf.format(-days, 'day');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff < 365 * 24 * 60 * 60 * 1000) {
|
||||||
|
const months = Math.floor(diff / 30 / 24 / 60 / 60 / 1000);
|
||||||
|
return rtf.format(-months, 'month');
|
||||||
|
}
|
||||||
|
|
||||||
|
const years = Math.floor(diff / 365 / 24 / 60 / 60 / 1000);
|
||||||
|
return rtf.format(-years, 'year');
|
||||||
|
}
|
||||||
@@ -136,7 +136,7 @@ const calculateSpacing = (
|
|||||||
|
|
||||||
export function useMagnificationEffect(props: {
|
export function useMagnificationEffect(props: {
|
||||||
childrenCount: number;
|
childrenCount: number;
|
||||||
containerRef: React.RefObject<HTMLElement>;
|
containerRef: React.RefObject<HTMLElement | null>;
|
||||||
config?: MagnificationConfig;
|
config?: MagnificationConfig;
|
||||||
}) {
|
}) {
|
||||||
const { childrenCount, containerRef, config } = props;
|
const { childrenCount, containerRef, config } = props;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
const STORAGE_KEY = 'gitbook_toolbar_closed';
|
const STORAGE_KEY = 'gitbook_toolbar_closed';
|
||||||
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
|
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
|
||||||
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
|
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
|
||||||
|
const SESSION_POSITION_KEY = 'gitbook_toolbar_position';
|
||||||
|
const VISIBILITY_HINT_DISMISSED_KEY = 'gitbook_toolbar_hint_dismissed';
|
||||||
|
|
||||||
type SessionHideReason = 'session' | 'persistent';
|
type SessionHideReason = 'session' | 'persistent';
|
||||||
|
|
||||||
@@ -57,6 +59,35 @@ export const setStoredMinified = (value: boolean) => {
|
|||||||
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
|
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the last drag position from session storage. Returns `null` when no position has been
|
||||||
|
* stored, meaning the toolbar should use its default center-bottom placement.
|
||||||
|
*/
|
||||||
|
export const getStoredPosition = (): { x: number; y: number } | null => {
|
||||||
|
return getSessionStorageItem<{ x: number; y: number } | null>(SESSION_POSITION_KEY, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the current drag position for the ongoing session.
|
||||||
|
*/
|
||||||
|
export const setStoredPosition = (position: { x: number; y: number }) => {
|
||||||
|
setSessionStorageItem(SESSION_POSITION_KEY, position);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the user has dismissed the "only you can see this" hint.
|
||||||
|
*/
|
||||||
|
export const getVisibilityHintDismissed = (): boolean => {
|
||||||
|
return getLocalStorageItem(VISIBILITY_HINT_DISMISSED_KEY, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist that the user dismissed the visibility hint.
|
||||||
|
*/
|
||||||
|
export const setVisibilityHintDismissed = () => {
|
||||||
|
setLocalStorageItem(VISIBILITY_HINT_DISMISSED_KEY, true);
|
||||||
|
};
|
||||||
|
|
||||||
interface UseToolbarVisibilityOptions {
|
interface UseToolbarVisibilityOptions {
|
||||||
onPersistentClose?: () => void;
|
onPersistentClose?: () => void;
|
||||||
onSessionClose?: () => void;
|
onSessionClose?: () => void;
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ export function AnnouncementBanner(props: {
|
|||||||
icon="close"
|
icon="close"
|
||||||
label={tString(language, 'close')}
|
label={tString(language, 'close')}
|
||||||
variant="blank"
|
variant="blank"
|
||||||
size="default"
|
size="small"
|
||||||
onClick={dismissAnnouncement}
|
onClick={dismissAnnouncement}
|
||||||
className={`absolute top-0 right-4 mt-2 mr-2 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
|
className={`absolute top-0 right-4 mt-1.5 mr-1.5 circular-corners:rounded-lg rounded-sm straight-corners:rounded-none p-1.5 transition-all hover:ring-1 sm:right-6 md:right-8 ${style.close}`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
|||||||
aria-describedby={describedById}
|
aria-describedby={describedById}
|
||||||
className={tcls(
|
className={tcls(
|
||||||
'fixed',
|
'fixed',
|
||||||
'z-10',
|
'z-50',
|
||||||
'bg-tint-base',
|
'bg-tint-base',
|
||||||
'rounded-sm',
|
'rounded-sm',
|
||||||
'straight-corners:rounded-none',
|
'straight-corners:rounded-none',
|
||||||
@@ -52,19 +52,20 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
|||||||
'depth-flat:shadow-none',
|
'depth-flat:shadow-none',
|
||||||
'p-4',
|
'p-4',
|
||||||
'pr-8',
|
'pr-8',
|
||||||
'bottom-4',
|
'bottom-[max(env(safe-area-inset-bottom),1rem)]',
|
||||||
'right-4',
|
'right-[max(env(safe-area-inset-right),1rem)]',
|
||||||
'left-16',
|
'left-[max(env(safe-area-inset-left),4rem)]',
|
||||||
'max-w-md',
|
'max-w-md',
|
||||||
'text-balance',
|
'text-balance',
|
||||||
'sm:left-auto',
|
'sm:left-auto',
|
||||||
'lg:chat-open:mr-80',
|
'lg:chat-open:mr-80',
|
||||||
'xl:chat-open:mr-100',
|
'xl:chat-open:mr-100',
|
||||||
'transition-all',
|
'transition-all',
|
||||||
'duration-300'
|
'duration-300',
|
||||||
|
'text-sm'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p id={describedById} className={tcls('text-sm')}>
|
<p id={describedById}>
|
||||||
{t(
|
{t(
|
||||||
language,
|
language,
|
||||||
'cookies_prompt',
|
'cookies_prompt',
|
||||||
@@ -76,12 +77,13 @@ export function CookiesToast(props: { privacyPolicy?: string }) {
|
|||||||
<Button
|
<Button
|
||||||
iconOnly
|
iconOnly
|
||||||
icon="close"
|
icon="close"
|
||||||
|
size="small"
|
||||||
label={tString(language, 'close')}
|
label={tString(language, 'close')}
|
||||||
variant="blank"
|
variant="blank"
|
||||||
onClick={() => setShow(false)}
|
onClick={() => setShow(false)}
|
||||||
className={tcls('absolute', 'top-2', 'right-2', 'hover:bg-tint-hover')}
|
className={tcls('absolute', 'top-2', 'right-2', 'hover:bg-tint-hover')}
|
||||||
/>
|
/>
|
||||||
<div className={tcls('mt-3', 'flex', 'flex-row', 'gap-2')}>
|
<div className="mt-3 flex flex-row gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function Annotation(props: InlineProps<DocumentInlineAnnotation>) {
|
|||||||
ancestorBlocks={[]}
|
ancestorBlocks={[]}
|
||||||
context={context}
|
context={context}
|
||||||
nodes={fragment.nodes}
|
nodes={fragment.nodes}
|
||||||
style={['space-y-4']}
|
style={['contents']}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,84 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as Popover from '@radix-ui/react-popover';
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
import { tString, useLanguage } from '@/intl/client';
|
import { Tooltip } from '@/components/primitives';
|
||||||
import { tcls } from '@/lib/tailwind';
|
|
||||||
|
|
||||||
export function AnnotationPopover(props: { children: React.ReactNode; body: React.ReactNode }) {
|
export function AnnotationPopover(props: { children: React.ReactNode; body: React.ReactNode }) {
|
||||||
const { children, body } = props;
|
const { children, body } = props;
|
||||||
const language = useLanguage();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Root>
|
<Tooltip
|
||||||
<Popover.Trigger asChild>
|
label={body}
|
||||||
<button
|
contentProps={{
|
||||||
data-testid="annotation-button"
|
role: 'definition',
|
||||||
aria-label={tString(language, 'annotation_button_label')}
|
}}
|
||||||
className={tcls(
|
className="bg-tint-base px-4 py-3 text-sm text-tint-strong shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1"
|
||||||
'decoration-dotted',
|
arrow={true}
|
||||||
'decoration-1',
|
arrowProps={{ className: 'fill-tint-1' }}
|
||||||
'underline',
|
>
|
||||||
'underline-offset-2'
|
<dfn
|
||||||
)}
|
data-testid="annotation-button"
|
||||||
>
|
className="cursor-help underline decoration-1 decoration-dotted underline-offset-2"
|
||||||
{children}
|
// biome-ignore lint/a11y/noNoninteractiveTabindex: we want to be able to focus the definition to open the tooltip
|
||||||
</button>
|
tabIndex={0}
|
||||||
</Popover.Trigger>
|
>
|
||||||
<Popover.Portal>
|
{children}
|
||||||
<Popover.Content
|
</dfn>
|
||||||
className={tcls(
|
</Tooltip>
|
||||||
'text-sm',
|
|
||||||
'max-w-[280px]',
|
|
||||||
'bg-tint',
|
|
||||||
'ring-1',
|
|
||||||
'ring-tint',
|
|
||||||
'rounded-sm',
|
|
||||||
'shadow-1xs',
|
|
||||||
'shadow-tint-12/1',
|
|
||||||
'dark:shadow-tint-1/2',
|
|
||||||
'p-3',
|
|
||||||
'[&_p]:leading-snug',
|
|
||||||
'-outline-offset-2',
|
|
||||||
'outline-2',
|
|
||||||
'outline-primary/8',
|
|
||||||
'z-20'
|
|
||||||
)}
|
|
||||||
sideOffset={4}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
<Popover.Arrow asChild>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 8 5"
|
|
||||||
className={tcls(
|
|
||||||
'relative',
|
|
||||||
'z-2',
|
|
||||||
'fill-tint-3', // Same as bg-tint
|
|
||||||
'stroke-tint-7', // Same as ring-tint
|
|
||||||
'[paint-order:stroke_fill]'
|
|
||||||
)}
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<g clip-path="url(#clipAnnotation)">
|
|
||||||
<path
|
|
||||||
d="M0 0L4 4L8 0"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
stroke="inherit"
|
|
||||||
fill="inherit"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clipAnnotation">
|
|
||||||
<rect width="8" height="5" fill="white" />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</Popover.Arrow>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Portal>
|
|
||||||
</Popover.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user