diff --git a/bun.lock b/bun.lock index cc547167e..a515522a6 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,7 @@ }, "packages/expr": { "name": "@gitbook/expr", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "acorn": "^8.14.0", "acorn-loose": "8.4.0", @@ -95,7 +95,7 @@ }, "packages/gitbook": { "name": "gitbook", - "version": "0.17.1", + "version": "0.18.0", "dependencies": { "@gitbook/api": "catalog:", "@gitbook/browser-types": "workspace:*", @@ -142,6 +142,7 @@ "memoizee": "^0.4.17", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", + "motion": "^12.23.12", "next": "15.4.0", "next-themes": "^0.2.1", "nuqs": "^2.2.3", @@ -197,7 +198,7 @@ }, "packages/icons": { "name": "@gitbook/icons", - "version": "0.3.0", + "version": "0.3.1", "bin": { "gitbook-icons": "./bin/gitbook-icons.js", }, @@ -233,7 +234,7 @@ }, "packages/react-contentkit": { "name": "@gitbook/react-contentkit", - "version": "0.7.4", + "version": "0.7.5", "dependencies": { "@gitbook/api": "catalog:", "@gitbook/icons": "workspace:*", @@ -265,7 +266,7 @@ }, "packages/react-openapi": { "name": "@gitbook/react-openapi", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@gitbook/expr": "workspace:*", "@gitbook/openapi-parser": "workspace:*", @@ -2432,6 +2433,12 @@ "mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="], + "motion": ["motion@12.23.12", "", { "dependencies": { "framer-motion": "^12.23.12", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng=="], + + "motion-dom": ["motion-dom@12.23.12", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw=="], + + "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -4352,6 +4359,10 @@ "minizlib/minipass": ["minipass@2.9.0", "", { "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg=="], + "motion/framer-motion": ["framer-motion@12.23.12", "", { "dependencies": { "motion-dom": "^12.23.12", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg=="], + + "motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 00500400a..56ccd22cf 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -46,6 +46,7 @@ "memoizee": "^0.4.17", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", + "motion": "^12.23.12", "next": "15.4.0", "next-themes": "^0.2.1", "nuqs": "^2.2.3", diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx index 5faac2a68..1388e9929 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx @@ -1,143 +1,103 @@ import type { GitBookSiteContext } from '@/lib/context'; -import { Icon } from '@gitbook/icons'; -import React from 'react'; +import { AdminToolbarClient } from './AdminToolbarClient'; -import { tcls } from '@/lib/tailwind'; -import { DateRelative } from '../primitives'; -import { IframeWrapper } from './IframeWrapper'; -import { RefreshChangeRequestButton } from './RefreshChangeRequestButton'; -import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar'; - -interface AdminToolbarProps { +export interface AdminToolbarProps { context: GitBookSiteContext; } -function ToolbarLayout(props: { children: React.ReactNode }) { - return ( -
- {props.children} -
- ); +export type AdminToolbarContext = { + space: { + id: string; + revision: string; + }; + changeRequest: MinimalChangeRequest | null; + revision: MinimalRevision; + revisionId: string; + site: { + title: string; + urls: { + published: string | undefined; + }; + }; +}; + +export interface AdminToolbarClientProps { + context: AdminToolbarContext; } /** - * Toolbar with information for the content admin when previewing a revision or change-request. + * Server component that determines what type of toolbar to show and passes data to client component */ export async function AdminToolbar(props: AdminToolbarProps) { const { context } = props; - if (context.changeRequest) { - return ( - - - - ); + if (context.changeRequest || context.revisionId !== context.space.revision) { + // Create a minimal context with only the fields needed for AdminToolbar + const minimalContext: AdminToolbarContext = { + space: { + id: context.space.id, + revision: context.space.revision, + }, + changeRequest: context.changeRequest + ? { + id: context.changeRequest.id, + number: context.changeRequest.number, + subject: context.changeRequest.subject, + revision: context.changeRequest.revision, + updatedAt: context.changeRequest.updatedAt, + createdBy: { + displayName: context.changeRequest.createdBy.displayName, + }, + urls: { + app: context.changeRequest.urls.app, + }, + } + : null, + revision: { + createdAt: context.revision.createdAt, + urls: { + app: context.revision.urls.app, + }, + git: context.revision.git + ? { + url: context.revision.git.url, + } + : null, + }, + revisionId: context.revisionId, + site: { + title: context.site.title, + urls: { + published: context.site.urls.published, + }, + }, + }; + + return ; } - - if (context.revisionId !== context.space.revision) { - return ( - - - - ); - } - - return null; -} - -async function ChangeRequestToolbar(props: { context: GitBookSiteContext }) { - const { context } = props; - const { space, changeRequest } = context; - - if (!changeRequest) { - return null; - } - - return ( - - - - - - -

- #{changeRequest.number}: {changeRequest.subject ?? 'No subject'} -

-

- Change request updated -

-
- - - - - - -
-
- ); -} - -async function RevisionToolbar(props: { context: GitBookSiteContext }) { - const { context } = props; - const { revision } = context; - - return ( - - - - - - -

- Revision created -

- {revision.git ? ( -

- {revision.git.message} -

- ) : null} -
- - - - - {revision.git?.url ? ( - - - - ) : null} - -
-
- ); } diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx new file mode 100644 index 000000000..dae1f788a --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -0,0 +1,209 @@ +'use client'; +import { Icon } from '@gitbook/icons'; +import { MotionConfig } from 'motion/react'; +import * as motion from 'motion/react-client'; +import { DateRelative } from '../primitives'; +import type { AdminToolbarClientProps } from './AdminToolbar'; +import { IframeWrapper } from './IframeWrapper'; +import { RefreshChangeRequestButton } from './RefreshChangeRequestButton'; +import { + Toolbar, + ToolbarBody, + ToolbarButton, + ToolbarButtonGroup, + ToolbarSeparator, +} from './Toolbar'; +import { getCopyVariants } from './transitions'; + +export function AdminToolbarClient(props: AdminToolbarClientProps) { + const { context } = props; + + if (context.changeRequest) { + return ( + + + + + + ); + } + + if (context.revisionId !== context.space.revision) { + return ( + + + + + + ); + } + + return null; +} + +function ChangeRequestToolbar(props: AdminToolbarClientProps) { + const { context } = props; + const { space, changeRequest, site } = context; + + if (!changeRequest) { + return null; + } + + const crLabel = changeRequest.subject || 'Untitled'; + const author = changeRequest.createdBy.displayName; + + return ( + + + + + by {author} + + } + /> + + + + + + {/* Refresh to retrieve latest changes */} + + {/* Comment in app */} + + + {/* Open production site */} + + + {/* Open CR in GitBook */} + + + + ); +} + +function RevisionToolbar(props: AdminToolbarClientProps) { + const { context } = props; + const { revision, site } = context; + + if (!revision) { + return null; + } + + const gitURL = revision.git?.url; + + return ( + + + + + Created + + } + /> + + + + {/* Open commit in Git client */} + + Setup GitSync to edit using Git{' '} + + + ) + } + href={gitURL} + disabled={!gitURL} + icon={gitURL ? (gitURL.includes('github.com') ? 'github' : 'gitlab') : 'github'} + /> + + + + + ); +} + +function ToolbarTitle(props: { prefix: string; suffix: string }) { + return ( +
+ + +
+ ); +} + +function ToolbarTitlePrefix(props: { title: string }) { + return ( + + {props.title} + + ); +} + +function ToolbarTitleSuffix(props: { title: string }) { + return ( + + {props.title} + + ); +} + +function ToolbarSubtitle(props: { subtitle: React.ReactNode }) { + return ( + + {props.subtitle} + + ); +} diff --git a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css new file mode 100644 index 000000000..52cb508c7 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css @@ -0,0 +1,180 @@ +.svgLogo { + --logo-fill: var(--color-neutral-100); + --seg-A-color: #2782c4; + --seg-B-color: #43b7f2; + --seg-C-color: #8be2ff; + + --T: 2s; + shape-rendering: geometricPrecision; + vector-effect: non-scaling-stroke; +} + +:global(html.dark) .svgLogo { + --logo-fill: var(--color-neutral-800); + --seg-A-color: #19537d; + --seg-B-color: #2782c4; + --seg-C-color: #43b7f2; + --trace-color: #46474c; +} + +/* Base segment animation */ +.seg { + opacity: 0; + animation: segFade var(--T) ease both 0.06s, segAMove var(--T) linear, + segALen var(--T) linear; + animation-delay: 0.06s; + animation-fill-mode: forwards; +} + +.segA { + animation-name: segFade, segAMove, segALen; +} + +.segB { + animation-name: segFade, segBMove, segBLen; +} + +.segC { + animation-name: segFade, segCMove, segCLen; +} + +.trace { + animation: traceFill var(--T) ease both 0.06s; + animation-fill-mode: forwards; +} + +@keyframes traceFill { + 0% { + fill: transparent; + stroke:var(--trace-color); + } + 95% { + fill: transparent; + stroke:var(--trace-color); + } + 100% { + fill: var(--logo-fill); + stroke:none; + } +} + + +/* Segment A animation */ +@keyframes segAMove { + 0% { + stroke-dashoffset: 0; + stroke: var(--seg-A-color); + } + 50% { + stroke-dashoffset: -0.5; + stroke: var(--seg-A-color); + } + 95% { + stroke-dashoffset: -1; + } + 100% { + stroke-dashoffset: 0; + stroke: none; + } +} + +@keyframes segALen { + 0% { + stroke-dasharray: 0.18 0.82; + } + 95% { + stroke-dasharray: 0.22 0.78; + } + 100% { + stroke-dasharray: 1; + } +} + +/* Segment B animation */ +@keyframes segBMove { + 0% { + stroke-dashoffset: -0.18; + stroke: var(--seg-B-color); + } + 50% { + stroke-dashoffset: -0.72; + stroke: var(--seg-B-color); + } + 95% { + stroke-dashoffset: -1.18; + stroke: var(--seg-B-color); + } + 100% { + stroke-dashoffset: 0; + stroke: none; + } +} + +@keyframes segBLen { + 0%, + 50%, + 95% { + stroke-dasharray: 0.2 0.8; + } + 100% { + stroke-dasharray: 1; + } +} + +/* Segment C animation */ +@keyframes segCMove { + 0% { + stroke-dashoffset: -0.38; + stroke: var(--seg-C-color); + } + 50% { + stroke-dashoffset: -0.92; + stroke: var(--seg-C-color); + } + 95% { + stroke-dashoffset: -1.38; + stroke: var(--seg-C-color); + } + 100% { + stroke-dashoffset: 0; + stroke: none; + } +} + +@keyframes segCLen { + 0% { + stroke-dasharray: 0.22 0.78; + } + 95% { + stroke-dasharray: 0.18 0.82; + } + 100% { + stroke-dasharray: 1; + } +} + +/* Segment fade animation - stays visible at end */ +@keyframes segFade { + 0% { + opacity: 0; + } + 10% { + opacity: 1; + } + 100% { + opacity: 1; + } +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .seg { + animation: none; + opacity: 0; + } + .trace { + animation: none; + fill: var(--logo-fill); + stroke:none; + } +} \ No newline at end of file diff --git a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx new file mode 100644 index 000000000..f7a7c2790 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx @@ -0,0 +1,62 @@ +import { tcls } from '@/lib/tailwind'; +import type React from 'react'; +import styles from './AnimatedLogo.module.css'; + +export const AnimatedLogo: React.FC = () => { + return ( + + ); +}; + +const dPath = + 'M-5.07306 1.64898C-1.92626 3.46518 -0.352865 4.37328 1.37504 4.37477C3.10303 4.37628 4.67794 3.47098 7.82794 1.66027C7.82794 1.66027 27.9071 -9.88183 27.9071 -9.88183C28.8136 -10.4029 29.3724 -11.3687 29.3724 -12.4143C29.3724 -13.4598 28.8136 -14.4257 27.9071 -14.9467C27.9071 -14.9467 7.82063 -26.4931 7.82063 -26.4931C4.67414 -28.3018 3.10083 -29.2062 1.37453 -29.2055C-0.351665 -29.2048 -1.92427 -28.2992 -5.06947 -26.488C-5.06947 -26.488 -22.3372 -16.5441 -22.3372 -16.5441C-22.4651 -16.4704 -22.5291 -16.4336 -22.5888 -16.3986C-28.4872 -12.9457 -32.1315 -6.64173 -32.1802 0.192975C-32.1807 0.262075 -32.1807 0.335975 -32.1807 0.483575C-32.1807 0.631075 -32.1807 0.704875 -32.1802 0.773875C-32.1316 7.60087 -28.4955 13.899 -22.6075 17.3547C-22.548 17.3897 -22.4841 17.4266 -22.3564 17.5003C-22.3564 17.5003 -11.5399 23.7454 -11.5399 23.7454C-5.23716 27.3844 -2.08587 29.2039 1.37484 29.2051C4.83554 29.2062 7.98813 27.3889 14.2932 23.7541C14.2932 23.7541 25.7115 17.1718 25.7115 17.1718C28.8686 15.3518 30.4471 14.4418 31.3139 12.9416C32.1807 11.4414 32.1807 9.61938 32.1807 5.97517C32.1807 5.97517 32.1807 -1.06463 32.1807 -1.06463C32.1807 -2.07553 31.6332 -3.00723 30.75 -3.49913C29.8953 -3.97513 28.8536 -3.96802 28.0054 -3.48053C28.0054 -3.48053 4.59224 9.97808 4.59224 9.97808C3.02144 10.8811 2.23593 11.3326 1.37404 11.3329C0.512135 11.3331 -0.273565 10.8821 -1.84506 9.98007C-1.84506 9.98007 -17.6916 0.883775 -17.6916 0.883775C-18.4854 0.428075 -18.8823 0.200275 -19.2011 0.159175C-19.9279 0.065375 -20.6267 0.472475 -20.9036 1.15108C-21.025 1.44858 -21.0225 1.90628 -21.0176 2.82148C-21.014 3.49528 -21.0122 3.83218 -20.9492 4.14208C-20.8082 4.83607 -20.4431 5.46448 -19.91 5.93068C-19.672 6.13888 -19.3802 6.30727 -18.7966 6.64407C-18.7966 6.64407 -1.85397 16.4227 -1.85397 16.4227C-0.278465 17.332 0.509335 17.7867 1.37434 17.7869C2.23934 17.7872 3.02734 17.3329 4.60333 16.4245C4.60333 16.4245 25.3699 4.45377 25.3699 4.45377C25.9083 4.14348 26.1774 3.98828 26.3792 4.10488C26.5811 4.22148 26.5811 4.53218 26.5811 5.15357C26.5811 5.15357 26.5811 8.34667 26.5811 8.34667C26.5811 9.25768 26.5811 9.71317 26.3643 10.0883C26.1476 10.4634 25.753 10.6908 24.9637 11.1458C24.9637 11.1458 7.83524 21.0193 7.83524 21.0193C4.68194 22.837 3.10533 23.7458 1.37463 23.7451C-0.356065 23.7443 -1.93186 22.834 -5.08347 21.0134C-5.08347 21.0134 -21.1086 11.7563 -21.1086 11.7563C-21.1595 11.7269 -21.1849 11.7122 -21.2087 11.6984C-24.5687 9.73487 -26.642 6.14287 -26.6614 2.25128C-26.6616 2.22378 -26.6616 2.19438 -26.6616 2.13567C-26.6616 2.13567 -26.6616 -0.795425 -26.6616 -0.795425C-26.6616 -2.94372 -25.5174 -4.92953 -23.6587 -6.00682C-22.0163 -6.95883 -19.9905 -6.96072 -18.3463 -6.01183C-18.3463 -6.01183 -5.07306 1.64898 -5.07306 1.64898Z'; diff --git a/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx b/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx index d141140a6..9939f1938 100644 --- a/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx +++ b/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx @@ -1,27 +1,28 @@ 'use client'; -import { Icon } from '@gitbook/icons'; import React from 'react'; import { useCheckForContentUpdate } from '@/components/AutoRefreshContent'; import { tcls } from '@/lib/tailwind'; -import { ToolbarButton } from './Toolbar'; +import { ToolbarButton, type ToolbarButtonProps } from './Toolbar'; // We don't show the button if the content has been updated 30s ago or less. -const minInterval = 1000 * 30; // 5 minutes +const minInterval = 1000 * 30; /** * Button to refresh the page if the content has been updated. */ export function RefreshChangeRequestButton(props: { + className?: string; spaceId: string; changeRequestId: string; revisionId: string; updatedAt: number; + motionValues?: ToolbarButtonProps['motionValues']; }) { - const { updatedAt } = props; + const { updatedAt, className, motionValues } = props; - const [visible, setVisible] = React.useState(false); + const [coolingDown, setCoolingDown] = React.useState(false); const [loading, setLoading] = React.useState(false); const checkForUpdates = useCheckForContentUpdate(props); @@ -31,40 +32,42 @@ export function RefreshChangeRequestButton(props: { await checkForUpdates(); } finally { setLoading(false); - setVisible(false); + setCoolingDown(true); } }, [checkForUpdates]); // Show the button if the content has been updated more than 30s ago. React.useEffect(() => { if (updatedAt < Date.now() - minInterval) { - setVisible(true); + setCoolingDown(false); } }, [updatedAt]); // 30sec after being hidden, we show the button again React.useEffect(() => { - if (!visible) { + if (!coolingDown) { const timeout = setTimeout(() => { - setVisible(true); + setCoolingDown(false); }, minInterval); return () => clearTimeout(timeout); } - }, [visible]); - - if (!visible) { - return null; - } + }, [coolingDown]); return ( { + if (coolingDown) { + return; + } event.preventDefault(); refresh(); }} - > - - + className={tcls(className, 'overflow-visible')} + disabled={loading || coolingDown} + motionValues={motionValues} + icon="rotate" + iconClassName={loading ? 'animate-spin' : undefined} + /> ); } diff --git a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx index 4298d8db6..7a180e838 100644 --- a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx @@ -1,65 +1,245 @@ 'use client'; - -import type * as React from 'react'; +import { AnimatePresence, type MotionValue, motion, useReducedMotion } from 'motion/react'; +import React from 'react'; +import { AnimatedLogo } from './AnimatedLogo'; import { tcls } from '@/lib/tailwind'; +import { Icon, type IconName } from '@gitbook/icons'; +import { Tooltip } from '../primitives'; +import { minifyButtonAnimation, toolbarEasings } from './transitions'; +import { useMagnificationEffect } from './useMagnificationEffect'; + +const DURATION_LOGO_APPEARANCE = 2000; +const DELAY_BETWEEN_LOGO_AND_CONTENT = 100; export function Toolbar(props: { children: React.ReactNode }) { - const { children } = props; + const [minified, setMinified] = React.useState(true); + const [showToolbarControls, setShowToolbarControls] = React.useState(false); + const [isReady, setIsReady] = React.useState(false); + + // Wait for page to be ready, then show the toolbar + React.useEffect(() => { + const handleLoad = () => { + setIsReady(true); + }; + + if (document.readyState === 'complete') { + handleLoad(); + } else { + window.addEventListener('load', handleLoad); + return () => window.removeEventListener('load', handleLoad); + } + }, []); + + // After toolbar appears, wait then show the full content + React.useEffect(() => { + if (isReady) { + const expandAfterTimeout = setTimeout(() => { + setMinified(false); + }, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT); + + return () => clearTimeout(expandAfterTimeout); + } + }, [isReady]); + + // Don't render anything until page is ready + if (!isReady) { + return null; + } return ( -
setShowToolbarControls(true)} + onMouseLeave={() => setShowToolbarControls(false)} + className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4" > - {children} -
+ + { + if (minified) { + setMinified((prev) => !prev); + } + }} + layout + transition={toolbarEasings.spring} + className={tcls( + minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5', + 'flex', + 'items-center', + 'justify-center', + 'min-h-11', + 'min-w-12', + 'h-12', + 'py-2', + 'border-tint-1/3', + 'backdrop-blur-sm', + 'origin-center', + 'bg-[linear-gradient(110deg,rgba(20,23,28,0.90)_0%,rgba(20,23,28,0.80)_100%)]', + 'dark:bg-[linear-gradient(110deg,rgba(256,256,256,0.90)_0%,rgba(256,256,256,0.80)_100%)]' + )} + initial={{ + scale: 1, + opacity: 1, + }} + animate={{ + scale: 1, + opacity: 1, + boxShadow: minified + ? '0 4px 40px 8px rgba(0, 0, 0, .2), 0 0 0 .5px rgba(0, 0, 0, .4), inset 0 .5px 0 0 hsla(0, 0%, 100%, .15)' + : '0 4px 40px 8px rgba(0, 0, 0, .4), 0 0 0 .5px rgba(0, 0, 0, .8), inset 0 .5px 0 0 hsla(0, 0%, 100%, .3)', + }} + style={{ + borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions + }} + > + {/* Logo with stroke segments animation in blue-tints */} + + + + + {!minified ? props.children : null} + + {!minified && showToolbarControls && } + + + ); } export function ToolbarBody(props: { children: React.ReactNode }) { - return
{props.children}
; + return
{props.children}
; } -export function ToolbarButtonGroups(props: { children: React.ReactNode }) { - return
{props.children}
; -} +export function ToolbarButtonGroup(props: { children: React.ReactNode }) { + const containerRef = React.useRef(null); + const { buttonMotionValues } = useMagnificationEffect(containerRef); -export function ToolbarButton(props: React.HTMLProps) { - const { children, ...rest } = props; return ( - - {children} - + {React.Children.toArray(props.children).map((child, index) => { + const motionValues = buttonMotionValues[index]; + const childEl = child as React.ReactElement; + const key = + childEl.key || + childEl.props.icon || + childEl.props.href || + `toolbar-button-${index}`; + return React.cloneElement(childEl, { + motionValues, + key, + }); + })} + + ); +} + +export interface ToolbarButtonProps extends Omit, 'title'> { + motionValues?: { + scale: MotionValue; + x: MotionValue; + }; + icon?: IconName; + iconClassName?: string; + title?: React.ReactNode; +} + +export function ToolbarButton(props: ToolbarButtonProps) { + const { title, disabled, motionValues, className, style, href, onClick, icon, iconClassName } = + props; + const reduceMotion = useReducedMotion(); + + return ( + + + + {icon && } + + + + ); +} + +export function ToolbarSeparator() { + return
; +} + +function MinifyButton(props: { setMinified: (minified: boolean) => void }) { + return ( + + { + e.stopPropagation(); + props.setMinified(true); + }} + className={tcls( + '-top-2 -right-4 absolute flex size-4 cursor-pointer items-center justify-center rounded-full border', + 'border-neutral-500 bg-neutral-700 hover:border-neutral-400 hover:bg-neutral-600', + 'dark:border-neutral-400 dark:bg-neutral-200 dark:hover:border-neutral-200 dark:hover:bg-neutral-100' + )} + > + + + ); } diff --git a/packages/gitbook/src/components/AdminToolbar/index.ts b/packages/gitbook/src/components/AdminToolbar/index.ts index dde514750..703ad78e1 100644 --- a/packages/gitbook/src/components/AdminToolbar/index.ts +++ b/packages/gitbook/src/components/AdminToolbar/index.ts @@ -1,2 +1,5 @@ export * from './AdminToolbar'; +export * from './AdminToolbarClient'; export * from './IframeWrapper'; +export * from './Toolbar'; +export * from './transitions'; diff --git a/packages/gitbook/src/components/AdminToolbar/transitions.ts b/packages/gitbook/src/components/AdminToolbar/transitions.ts new file mode 100644 index 000000000..bfd0bcb34 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/transitions.ts @@ -0,0 +1,73 @@ +import type { Transition } from 'motion/react'; + +// Slight bounce but minimal overshoot esp. at the minimized state. +const spring = { + type: 'spring' as const, + stiffness: 130, + damping: 19, + mass: 1, +}; + +const parent = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + delayChildren: 0.4, + staggerChildren: 0.1, + }, + }, +}; + +const staggeringChild = { + hidden: { + opacity: 0, + scale: 0.7, + }, + show: { + opacity: 1, + scale: 1, + transition: { + duration: 0.3, + type: 'spring', + } as Transition, + }, +}; + +export const minifyButtonAnimation = { + initial: { + scale: 0.5, + opacity: 0, + }, + animate: { + scale: 1, + opacity: 1, + }, + exit: { + scale: 0.5, + opacity: 0, + }, +}; + +export const getCopyVariants = (position: number) => { + return { + initial: { + opacity: 0, + x: -10, + }, + animate: { + opacity: 1, + x: 0, + }, + transition: { + duration: 0.1, + delay: position / 10 + 0.3, + } as Transition, + }; +}; + +export const toolbarEasings = { + spring, + parent, + staggeringChild, +}; diff --git a/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts b/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts new file mode 100644 index 000000000..d6a3a00d0 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts @@ -0,0 +1,216 @@ +import { type MotionValue, motionValue } from 'framer-motion'; +import React from 'react'; + +interface ButtonMotionValues { + scale: MotionValue; + x: MotionValue; +} + +interface MagnificationConfig { + /** Size of each button in pixels - used for spacing calculations */ + buttonSize?: number; + /** Maximum scale factor for buttons when mouse is directly over them */ + maxScale?: number; + /** Distance in pixels from mouse where buttons start scaling */ + influenceRadius?: number; + /** Multiplier for spacing between buttons - higher values create more space */ + spacingMultiplier?: number; + /** Controls scaling curve - higher values make scaling more dramatic near mouse */ + scaleExponent?: number; + /** Padding around container for mouse detection in pixels */ + padding?: number; +} + +const defaultConfig: Required = { + buttonSize: 32, // Standard button size for 32px (size-8) buttons + maxScale: 1.3, // 30% scale increase - noticeable but not overwhelming + influenceRadius: 80, // ~2.5 button widths of influence + spacingMultiplier: 1.3, // Creates proportional spacing to scale increase + scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling + padding: 10, // Small buffer zone around container edges +}; + +// Helper functions for cleaner code +const createMotionValues = (count: number): ButtonMotionValues[] => + Array.from({ length: count }, () => ({ + scale: motionValue(1), + x: motionValue(0), + })); + +const resetMotionValues = (motionValues: ButtonMotionValues[]) => { + motionValues.forEach(({ scale, x }) => { + scale.set(1); + x.set(0); + }); +}; + +const captureButtonPositions = (buttons: HTMLElement[]) => { + // Reset transforms to get original positions + buttons.forEach((button) => (button.style.transform = '')); + return buttons.map((button) => { + const rect = button.getBoundingClientRect(); + return { left: rect.left, width: rect.width }; + }); +}; + +const calculateScale = ( + mouseX: number, + buttonCenterX: number, + containerRect: DOMRect, + config: Required +) => { + const distance = Math.abs(mouseX - buttonCenterX); + + if (distance > config.influenceRadius) return 1; + + // Edge influence to reduce scaling near container edges + const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX); + const edgeInfluence = Math.min(distanceFromEdge / 20, 1); + + // Exponential scaling curve + const progress = distance / config.influenceRadius; + const exponentialProgress = (1 - progress) ** config.scaleExponent; + const baseScale = 1 + (config.maxScale - 1) * exponentialProgress; + + return 1 + (baseScale - 1) * edgeInfluence; +}; + +const calculateSpacing = ( + buttonIndex: number, + buttonEffects: Array<{ scale: number; translateX: number }>, + positions: Array<{ left: number; width: number }>, + config: Required +) => { + const currentPos = positions[buttonIndex]; + if (!currentPos) return 0; + + const buttonCenterX = currentPos.left + currentPos.width / 2; + let leftPush = 0; + let rightPush = 0; + + buttonEffects.forEach((otherEffect, otherIndex) => { + if (otherIndex === buttonIndex) return; + + const otherPos = positions[otherIndex]; + if (!otherPos) return; + + const otherCenterX = otherPos.left + otherPos.width / 2; + const distanceBetween = Math.abs(buttonCenterX - otherCenterX); + + // Only apply influence if close enough + if (distanceBetween < config.influenceRadius * 1.5) { + const influenceStrength = Math.max( + 0, + 1 - distanceBetween / (config.influenceRadius * 1.5) + ); + const extraSpace = (otherEffect.scale - 1) * config.buttonSize; + const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength; + + if (otherCenterX < buttonCenterX) { + rightPush += pushAmount; + } else { + leftPush += pushAmount; + } + } + }); + + return rightPush - leftPush; +}; + +export function useMagnificationEffect( + containerRef: React.RefObject, + config: MagnificationConfig = {} +) { + const [buttonMotionValues, setButtonMotionValues] = React.useState([]); + const originalPositionsRef = React.useRef>([]); + + const finalConfig = { ...defaultConfig, ...config }; + + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const buttons = Array.from(container.querySelectorAll('.toolbar-button')) as HTMLElement[]; + + // Initialize motion values if button count changed + if (buttonMotionValues.length !== buttons.length) { + setButtonMotionValues(createMotionValues(buttons.length)); + } + + const handleMouseMove = (event: MouseEvent) => { + const buttons = Array.from( + container.querySelectorAll('.toolbar-button') + ) as HTMLElement[]; + + // Capture positions if needed + if (originalPositionsRef.current.length !== buttons.length) { + originalPositionsRef.current = captureButtonPositions(buttons); + } + + const containerRect = container.getBoundingClientRect(); + const { clientX: mouseX, clientY: mouseY } = event; + + // Check if mouse is in range + const isInRange = + mouseX >= containerRect.left - finalConfig.padding && + mouseX <= containerRect.right + finalConfig.padding && + mouseY >= containerRect.top - finalConfig.padding && + mouseY <= containerRect.bottom + finalConfig.padding; + + if (!isInRange) { + resetMotionValues(buttonMotionValues); + return; + } + + // Calculate scales for all buttons + const buttonEffects = buttons.map((_, index) => { + const pos = originalPositionsRef.current[index]; + if (!pos) return { scale: 1, translateX: 0 }; + + const buttonCenterX = pos.left + pos.width / 2; + const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig); + return { scale, translateX: 0 }; + }); + + // Calculate spacing for all buttons + buttonEffects.forEach((effect, index) => { + effect.translateX = calculateSpacing( + index, + buttonEffects, + originalPositionsRef.current, + finalConfig + ); + }); + + // Update motion values + buttonEffects.forEach((effect, index) => { + const motionValue = buttonMotionValues[index]; + if (motionValue) { + motionValue.scale.set(effect.scale); + motionValue.x.set(effect.translateX); + } + }); + }; + + const handleMouseLeave = () => resetMotionValues(buttonMotionValues); + + container.addEventListener('mousemove', handleMouseMove); + container.addEventListener('mouseleave', handleMouseLeave); + + return () => { + container.removeEventListener('mousemove', handleMouseMove); + container.removeEventListener('mouseleave', handleMouseLeave); + }; + }, [ + finalConfig.buttonSize, + finalConfig.maxScale, + finalConfig.influenceRadius, + finalConfig.spacingMultiplier, + finalConfig.scaleExponent, + finalConfig.padding, + containerRef, + buttonMotionValues, + ]); + + return { buttonMotionValues }; +} diff --git a/packages/gitbook/src/components/primitives/DateRelative.tsx b/packages/gitbook/src/components/primitives/DateRelative.tsx index 3d8fc23a1..796dafac0 100644 --- a/packages/gitbook/src/components/primitives/DateRelative.tsx +++ b/packages/gitbook/src/components/primitives/DateRelative.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useLanguage } from '@/intl/client'; +import { Tooltip } from './Tooltip'; /** * Display a date as a relative time. @@ -28,14 +29,11 @@ export function DateRelative(props: { value: string }) { const date = new Date(value); const diff = now - date.getTime(); return ( - + + + ); } diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts index 5ad889049..87d42fdea 100644 --- a/packages/gitbook/tailwind.config.ts +++ b/packages/gitbook/tailwind.config.ts @@ -81,6 +81,9 @@ const config: Config = { ], var: ['var(--font-family)'], }, + fontSize: { + xxs: ['0.625rem', { lineHeight: '0.75rem' }], + }, colors: { // Dynamic colors matching the customization settings