mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-14 14:45:17 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5d1bbfafe | |||
| dac5e9d444 |
@@ -4,7 +4,6 @@ import { MotionConfig } from 'motion/react';
|
||||
import { useCheckForContentUpdate } from '../AutoRefreshContent';
|
||||
import { useVisitorSession } from '../Insights';
|
||||
import { useCurrentPagePath } from '../hooks';
|
||||
import { DateRelative } from '../primitives';
|
||||
import { HideToolbarButton } from './HideToolbarButton';
|
||||
import { IframeWrapper } from './IframeWrapper';
|
||||
import { RefreshContentButton } from './RefreshContentButton';
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
type ToolbarControlsContextValue,
|
||||
ToolbarControlsProvider,
|
||||
} from './ToolbarControlsContext';
|
||||
import { ToolbarDate } from './ToolbarDate';
|
||||
import type { AdminToolbarClientProps, AdminToolbarContext } from './types';
|
||||
import { useToolbarVisibility } from './utils';
|
||||
|
||||
@@ -132,7 +132,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle
|
||||
prefix={`Change #${changeRequest.number}:`}
|
||||
@@ -141,7 +141,7 @@ function ChangeRequestToolbar(props: ToolbarViewProps) {
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
<DateRelative value={changeRequest.updatedAt} /> by {author}
|
||||
<ToolbarDate value={changeRequest.updatedAt} /> by {author}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -207,13 +207,13 @@ function RevisionToolbar(props: ToolbarViewProps) {
|
||||
const gitProvider = isGitHub ? 'GitHub' : 'GitLab';
|
||||
|
||||
return (
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange} label="Site preview">
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle prefix="Site version" suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Created <DateRelative value={revision.createdAt} />
|
||||
Created <ToolbarDate value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -280,20 +280,10 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Toolbar
|
||||
minified={minified}
|
||||
onMinifiedChange={onMinifiedChange}
|
||||
label="Only visible to your GitBook organization"
|
||||
>
|
||||
<Toolbar minified={minified} onMinifiedChange={onMinifiedChange}>
|
||||
<ToolbarBody>
|
||||
<ToolbarTitle suffix={context.site.title} />
|
||||
<ToolbarSubtitle
|
||||
subtitle={
|
||||
<>
|
||||
Updated <DateRelative value={revision.createdAt} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ToolbarSubtitle subtitle={<ToolbarDate value={revision.createdAt} />} />
|
||||
</ToolbarBody>
|
||||
<ToolbarSeparator />
|
||||
<ToolbarActions>
|
||||
@@ -305,13 +295,13 @@ function AuthenticatedUserToolbar(props: ToolbarViewProps) {
|
||||
|
||||
{/* Open site in GitBook */}
|
||||
<ToolbarButton
|
||||
title="Open site in GitBook"
|
||||
title="View site configuration"
|
||||
href={getToolbarHref({
|
||||
href: site.urls.app,
|
||||
siteId: site.id,
|
||||
buttonId: 'site',
|
||||
})}
|
||||
icon="gears"
|
||||
icon="folder-gear"
|
||||
/>
|
||||
|
||||
{/* Customize in GitBook */}
|
||||
@@ -361,13 +351,13 @@ function EditPageButton(props: {
|
||||
|
||||
return (
|
||||
<ToolbarButton
|
||||
title="Edit in GitBook"
|
||||
title="Edit this page"
|
||||
href={getToolbarHref({
|
||||
href: `${href}${pagePath.startsWith('/') ? pagePath.slice(1) : pagePath}`,
|
||||
siteId,
|
||||
buttonId: 'edit',
|
||||
})}
|
||||
icon="pencil"
|
||||
icon="pen-to-square"
|
||||
motionValues={motionValues}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -8,10 +8,20 @@ import { ToolbarButton, type ToolbarButtonProps } from './Toolbar';
|
||||
import styles from './Toolbar.module.css';
|
||||
import { useToolbarControls } from './ToolbarControlsContext';
|
||||
|
||||
const ARC_DURATION_SECONDS = 0.4;
|
||||
const ARC_STAGGER_MS = 80;
|
||||
const BASE_ROTATION_DEG = 95;
|
||||
const ROTATION_STEP_DEG = 18;
|
||||
const ARC_PARAMS = {
|
||||
arcWidth: 505,
|
||||
arcHeight: 400,
|
||||
arcRadius: 34,
|
||||
startDistance: -240,
|
||||
spreadDistance: 45,
|
||||
fromDistance: -286,
|
||||
fromSpread: 0,
|
||||
durationSeconds: 0.6,
|
||||
staggerMs: 80,
|
||||
baseRotationDeg: 95,
|
||||
rotationStepDeg: 18,
|
||||
offsetAnchorY: 40,
|
||||
} as const;
|
||||
|
||||
interface HideToolbarButtonProps {
|
||||
motionValues?: ToolbarButtonProps['motionValues'];
|
||||
@@ -23,30 +33,75 @@ interface HideToolbarButtonProps {
|
||||
export function HideToolbarButton(props: HideToolbarButtonProps) {
|
||||
const { motionValues } = props;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [closing, setClosing] = React.useState(false);
|
||||
const [shouldMirror, setShouldMirror] = React.useState(false);
|
||||
const [shouldFlipVertical, setShouldFlipVertical] = React.useState(false);
|
||||
const controls = useToolbarControls();
|
||||
const closingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
if (!open || closing) return;
|
||||
setClosing(true);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (closingTimeoutRef.current) {
|
||||
clearTimeout(closingTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Wait for the exit animation to complete before unmounting
|
||||
const totalDuration = ARC_PARAMS.durationSeconds * 1000 + 3 * ARC_PARAMS.staggerMs;
|
||||
closingTimeoutRef.current = setTimeout(() => {
|
||||
setOpen(false);
|
||||
setClosing(false);
|
||||
closingTimeoutRef.current = null;
|
||||
}, totalDuration);
|
||||
}, [open, closing]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (closingTimeoutRef.current) {
|
||||
clearTimeout(closingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleClickOutsideArcMenu = (event: Event) => {
|
||||
// Don't close the arc if we are clicking on the button itself
|
||||
if (buttonRef.current?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
close();
|
||||
};
|
||||
// @ts-expect-error wrong type for ref
|
||||
useOnClickOutside(ref, handleClickOutsideArcMenu);
|
||||
|
||||
// Close arc menu on scroll
|
||||
// Measure button position to decide arc direction when opening
|
||||
React.useEffect(() => {
|
||||
if (open && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const distanceFromRight = window.innerWidth - rect.right;
|
||||
setShouldMirror(distanceFromRight < 250);
|
||||
setShouldFlipVertical(rect.top < 250);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Close arc menu on scroll or resize
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleScroll = () => setOpen(false);
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
const handleClose = () => close();
|
||||
window.addEventListener('scroll', handleClose, { passive: true });
|
||||
window.addEventListener('resize', handleClose, { passive: true });
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [open]);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleClose);
|
||||
window.removeEventListener('resize', handleClose);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
const items = [
|
||||
controls?.minimize
|
||||
@@ -84,36 +139,80 @@ export function HideToolbarButton(props: HideToolbarButtonProps) {
|
||||
return (
|
||||
<ToolbarButton
|
||||
ref={buttonRef}
|
||||
title={open ? 'Hide options' : 'Hide toolbar'}
|
||||
title={open ? undefined : 'Hide toolbar'}
|
||||
className={
|
||||
open || closing
|
||||
? 'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
if (open || closing) {
|
||||
close();
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
motionValues={motionValues}
|
||||
icon="eye-slash"
|
||||
icon="gear"
|
||||
>
|
||||
{/* Expanding arc menu */}
|
||||
{open && (
|
||||
{(open || closing) && (
|
||||
<motion.div
|
||||
className={tcls('pointer-events-none absolute inset-0', styles.arcMenu)}
|
||||
style={sharedMotionStyle as React.CSSProperties | undefined}
|
||||
style={
|
||||
{
|
||||
...sharedMotionStyle,
|
||||
'--arc-width': `${ARC_PARAMS.arcWidth}px`,
|
||||
'--arc-height': `${ARC_PARAMS.arcHeight}px`,
|
||||
'--arc-radius': `${ARC_PARAMS.arcRadius}%`,
|
||||
'--start-distance': `${ARC_PARAMS.startDistance}px`,
|
||||
'--spread-distance': `${ARC_PARAMS.spreadDistance}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'pointer-events-none absolute left-0 overflow-visible',
|
||||
'pointer-events-none absolute overflow-visible',
|
||||
shouldMirror ? '' : 'left-0',
|
||||
styles.arcMenuPath
|
||||
)}
|
||||
style={
|
||||
{
|
||||
...(shouldMirror
|
||||
? {
|
||||
'--arc-mirror-offset': '-345px',
|
||||
'--spread-distance': '37px',
|
||||
right: 'var(--arc-mirror-offset)',
|
||||
}
|
||||
: undefined),
|
||||
...(shouldFlipVertical
|
||||
? {
|
||||
bottom: 'auto',
|
||||
top: 'calc(var(--arc-height) / -2)',
|
||||
transform: 'scaleY(-1)',
|
||||
}
|
||||
: undefined),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
ref={ref}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<ArcToolbarButton
|
||||
index={index}
|
||||
staggerIndex={items.length - 1 - index}
|
||||
key={item.icon}
|
||||
staggerIndex={closing ? index : items.length - 1 - index}
|
||||
key={item.id}
|
||||
mirrored={shouldMirror}
|
||||
flippedVertical={shouldFlipVertical}
|
||||
closing={closing}
|
||||
{...item}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onClick?.();
|
||||
}}
|
||||
onClick={
|
||||
item.isLabel
|
||||
? undefined
|
||||
: () => {
|
||||
close();
|
||||
item.onClick?.();
|
||||
}
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -127,13 +226,17 @@ type ArcMenuItem = {
|
||||
id: string;
|
||||
icon: IconName;
|
||||
label: string;
|
||||
description: string;
|
||||
description?: string;
|
||||
onClick?: () => void;
|
||||
isLabel?: boolean;
|
||||
};
|
||||
|
||||
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick'> & {
|
||||
type ArcToolbarButtonProps = Pick<ArcMenuItem, 'label' | 'icon' | 'onClick' | 'isLabel'> & {
|
||||
index: number;
|
||||
staggerIndex?: number;
|
||||
mirrored?: boolean;
|
||||
flippedVertical?: boolean;
|
||||
closing?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
@@ -143,40 +246,41 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
|
||||
const {
|
||||
index,
|
||||
staggerIndex = index,
|
||||
mirrored = false,
|
||||
flippedVertical = false,
|
||||
closing = false,
|
||||
label,
|
||||
disabled,
|
||||
className,
|
||||
onClick = () => {},
|
||||
onClick,
|
||||
icon,
|
||||
iconClassName,
|
||||
isLabel = false,
|
||||
} = props;
|
||||
|
||||
const targetOffset = `calc(var(--start-distance) + ${index} * var(--spread-distance))`;
|
||||
const fromOffset = `calc(${ARC_PARAMS.fromDistance}px + ${index} * ${ARC_PARAMS.fromSpread}px)`;
|
||||
|
||||
// Calculate rotation based on position along the arc
|
||||
const calculateRotation = () => {
|
||||
return BASE_ROTATION_DEG - index * ROTATION_STEP_DEG;
|
||||
};
|
||||
const itemRotation = ARC_PARAMS.baseRotationDeg - index * ARC_PARAMS.rotationStepDeg;
|
||||
|
||||
const itemRotation = calculateRotation();
|
||||
const Tag = isLabel ? 'div' : 'button';
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
<Tag
|
||||
{...(Tag === 'button' ? { type: 'button' as const } : {})}
|
||||
onClick={onClick ? () => onClick() : undefined}
|
||||
style={
|
||||
{
|
||||
'--from-offset-distance': fromOffset,
|
||||
'--target-offset-distance': targetOffset,
|
||||
'--arc-duration': `${ARC_DURATION_SECONDS}s`,
|
||||
'--arc-delay': `${(staggerIndex ?? 0) * ARC_STAGGER_MS}ms`,
|
||||
'--arc-duration': `${ARC_PARAMS.durationSeconds}s`,
|
||||
'--arc-delay': `${(staggerIndex ?? 0) * ARC_PARAMS.staggerMs}ms`,
|
||||
'--rotation-offset': `${itemRotation}deg`,
|
||||
offsetPath: 'border-box',
|
||||
offsetDistance: targetOffset,
|
||||
offsetAnchor: '0% 40%',
|
||||
offsetRotate: `auto ${itemRotation}deg`,
|
||||
offsetDistance: fromOffset,
|
||||
offsetAnchor: `0% ${ARC_PARAMS.offsetAnchorY}%`,
|
||||
offsetRotate: 'auto 90deg',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={tcls(
|
||||
@@ -185,49 +289,61 @@ export function ArcToolbarButton(props: ArcToolbarButtonProps) {
|
||||
'top-0',
|
||||
'left-0',
|
||||
'w-40',
|
||||
'opacity-0',
|
||||
'pointer-events-auto',
|
||||
'flex',
|
||||
'items-center',
|
||||
'gap-2',
|
||||
styles.arcMenuItem,
|
||||
mirrored ? 'flex-row-reverse' : '',
|
||||
closing ? styles.arcMenuItemExit : styles.arcMenuItem,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex shrink-0 items-center justify-center gap-1',
|
||||
'h-8 w-8 rounded-full border',
|
||||
'truncate text-sm',
|
||||
'cursor-pointer transition-colors',
|
||||
'group-hover:-rotate-5 group-hover:scale-105',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'text-tint-1 dark:text-tint-12',
|
||||
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]',
|
||||
'dark:[background:linear-gradient(110deg,rgba(255,255,255,1)_0%,rgba(240,246,248,1)_100%)]',
|
||||
'border border-solid dark:border-[rgba(256,_256,_256,_0.06)]'
|
||||
)}
|
||||
style={{
|
||||
background: 'linear-gradient(rgb(51, 53, 57), rgb(50, 52, 56))',
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
style={flippedVertical ? { transform: 'scaleY(-1)' } : undefined}
|
||||
>
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls('size-4 shrink-0 group-hover:scale-110', iconClassName)}
|
||||
/>
|
||||
{isLabel ? (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-[rgba(79,139,255,0.1)] px-2 py-1 text-[#4F8BFF] backdrop-blur-sm">
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className="size-3.5 shrink-0"
|
||||
/>
|
||||
<span className="whitespace-nowrap font-normal text-sm">{label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={tcls(
|
||||
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'group-hover:scale-105',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'bg-[var(--toolbar-bg)] text-tint-7 hover:text-tint-1 dark:text-tint-12',
|
||||
'group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon as IconName}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'size-3.5 shrink-0 group-hover:scale-110',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={tcls(
|
||||
'whitespace-nowrap rounded-lg border-[0.5px] border-neutral-5 border-solid bg-[var(--toolbar-bg)] px-3 py-1 font-normal text-neutral-1 text-sm transition-[background-color,transform] group-hover:scale-105 group-hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)] dark:border-neutral-8 dark:text-neutral-12',
|
||||
closing ? styles.arcLabelFadeOut : styles.arcLabelFadeIn
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={tcls(
|
||||
'whitespace-nowrap rounded-lg px-3 py-1 font-normal text-sm transition-transform',
|
||||
'group-hover:rotate-2 group-hover:scale-105',
|
||||
'text-neutral-1 dark:text-neutral-12',
|
||||
'bg-[linear-gradient(110deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</Tag>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
.arcMenuItem {
|
||||
animation-name: hide-toolbar-arc-enter;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation-fill-mode: forwards;
|
||||
animation-fill-mode: both;
|
||||
animation-duration: var(--arc-duration, 0.4s);
|
||||
animation-delay: var(--arc-delay, 0s);
|
||||
transform-origin: center left;
|
||||
@@ -28,13 +28,101 @@
|
||||
|
||||
@keyframes hide-toolbar-arc-enter {
|
||||
from {
|
||||
offset-distance: var(--start-distance);
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
offset-distance: var(--from-offset-distance);
|
||||
offset-rotate: auto 90deg;
|
||||
}
|
||||
to {
|
||||
offset-distance: var(--target-offset-distance);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
offset-rotate: auto var(--rotation-offset);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hide-toolbar-arc-exit {
|
||||
from {
|
||||
offset-distance: var(--target-offset-distance);
|
||||
offset-rotate: auto var(--rotation-offset);
|
||||
}
|
||||
to {
|
||||
offset-distance: var(--from-offset-distance);
|
||||
offset-rotate: auto 90deg;
|
||||
}
|
||||
}
|
||||
|
||||
.arcMenuItemExit {
|
||||
animation-name: hide-toolbar-arc-exit;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation-fill-mode: both;
|
||||
animation-duration: var(--arc-duration, 0.3s);
|
||||
animation-delay: var(--arc-delay, 0s);
|
||||
transform-origin: center left;
|
||||
offset-path: border-box;
|
||||
offset-anchor: 0% 0%;
|
||||
offset-rotate: auto var(--rotation-offset, 0deg);
|
||||
}
|
||||
|
||||
/* ── Arc label fade ── */
|
||||
|
||||
@keyframes arc-label-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes arc-label-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.arcLabelFadeIn {
|
||||
animation: arc-label-fade-in 0.3s ease-out 0.2s both;
|
||||
}
|
||||
|
||||
.arcLabelFadeOut {
|
||||
animation: arc-label-fade-out 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ── Glass effect for the toolbar pill ── */
|
||||
|
||||
.glassLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.glassLayer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -50%;
|
||||
pointer-events: none;
|
||||
-webkit-backdrop-filter: blur(9px);
|
||||
backdrop-filter: blur(9px);
|
||||
filter: url(#glass-distortion);
|
||||
}
|
||||
|
||||
.glassLayer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: rgba(39, 39, 39, 0.55);
|
||||
box-shadow:
|
||||
inset 2px 2px 1px rgba(255, 255, 255, 0.08),
|
||||
inset -1px -1px 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
:global(.dark) .glassLayer::after {
|
||||
background: rgba(30, 30, 30, 0.6);
|
||||
box-shadow:
|
||||
inset 2px 2px 1px rgba(255, 255, 255, 0.05),
|
||||
inset -1px -1px 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.glassLayer::before {
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,39 +2,71 @@
|
||||
import {
|
||||
AnimatePresence,
|
||||
type MotionValue,
|
||||
animate,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useSpring,
|
||||
} from 'motion/react';
|
||||
import React, { isValidElement } from 'react';
|
||||
import { AnimatedLogo } from './AnimatedLogo';
|
||||
import { useToolbarControls } from './ToolbarControlsContext';
|
||||
import {
|
||||
getStoredPosition,
|
||||
getVisibilityHintDismissed,
|
||||
setStoredPosition,
|
||||
setVisibilityHintDismissed,
|
||||
} from './utils';
|
||||
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
|
||||
import { Tooltip } from '../primitives';
|
||||
// import styles from './Toolbar.module.css';
|
||||
import { getCopyVariants, toolbarEasings } from './transitions';
|
||||
import { useMagnificationEffect } from './useMagnificationEffect';
|
||||
|
||||
const DEBUG = true;
|
||||
|
||||
const DURATION_LOGO_APPEARANCE = 2000;
|
||||
const DELAY_BETWEEN_LOGO_AND_CONTENT = 100;
|
||||
|
||||
const ToolbarDraggingContext = React.createContext(false);
|
||||
|
||||
interface ToolbarProps {
|
||||
label: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
minified: boolean;
|
||||
onMinifiedChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function Toolbar(props: ToolbarProps) {
|
||||
const { children, label, minified, onMinifiedChange } = props;
|
||||
const { children, minified, onMinifiedChange } = props;
|
||||
const controls = useToolbarControls();
|
||||
const [isReady, setIsReady] = React.useState(false);
|
||||
const autoExpandTriggeredRef = React.useRef(false);
|
||||
const constraintsRef = React.useRef<HTMLDivElement>(null);
|
||||
const innerRef = React.useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const prevWidthRef = React.useRef<number | undefined>(undefined);
|
||||
const [hintDismissed, setHintDismissed] = React.useState(() =>
|
||||
typeof window !== 'undefined' ? getVisibilityHintDismissed() : false
|
||||
);
|
||||
|
||||
const shouldAutoExpand = Boolean(controls?.shouldAutoExpand);
|
||||
const [shouldAnimateLogo, setShouldAnimateLogo] = React.useState(shouldAutoExpand);
|
||||
|
||||
// Restore saved drag position (synchronous read — no flash)
|
||||
const storedPos = React.useMemo(
|
||||
() => (typeof window !== 'undefined' ? getStoredPosition() : null),
|
||||
[]
|
||||
);
|
||||
const x = useMotionValue(storedPos?.x ?? 0);
|
||||
const y = useMotionValue(storedPos?.y ?? 0);
|
||||
|
||||
const savePosition = React.useCallback(() => {
|
||||
setStoredPosition({ x: x.get(), y: y.get() });
|
||||
}, [x, y]);
|
||||
|
||||
// Wait for page to be ready, then show the toolbar
|
||||
React.useEffect(() => {
|
||||
const handleLoad = () => {
|
||||
@@ -80,58 +112,432 @@ export function Toolbar(props: ToolbarProps) {
|
||||
React.useEffect(() => {
|
||||
if (!minified) {
|
||||
// Any manual expansion should stop the logo animation so the icon stays in its
|
||||
// “settled” state once the toolbar is open.
|
||||
// "settled" state once the toolbar is open.
|
||||
setShouldAnimateLogo(false);
|
||||
}
|
||||
}, [minified]);
|
||||
|
||||
// Detect pinning state based on actual viewport position
|
||||
const debugRef = React.useRef<HTMLDivElement>(null);
|
||||
const debugLeftEdgeRef = React.useRef<HTMLDivElement>(null);
|
||||
const debugRightEdgeRef = React.useRef<HTMLDivElement>(null);
|
||||
const debugArrowRef = React.useRef<HTMLDivElement>(null);
|
||||
const lastDecisionRef = React.useRef<'left' | 'right' | 'center'>('center');
|
||||
|
||||
const computePinState = React.useCallback(
|
||||
(overrideWidth?: number) => {
|
||||
if (!innerRef.current) return 'center' as const;
|
||||
const rect = innerRef.current.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const edgeThreshold = 80; // px from viewport edge
|
||||
|
||||
// When overrideWidth is provided (e.g. prevWidth), reconstruct where the
|
||||
// edges WERE before the resize. The center stays the same because x hasn't
|
||||
// been compensated yet and flexbox centering is width-independent.
|
||||
let left = rect.left;
|
||||
let right = rect.right;
|
||||
if (overrideWidth !== undefined) {
|
||||
const center = (rect.left + rect.right) / 2;
|
||||
left = center - overrideWidth / 2;
|
||||
right = center + overrideWidth / 2;
|
||||
}
|
||||
|
||||
const pin =
|
||||
right > viewportWidth - edgeThreshold
|
||||
? ('right' as const)
|
||||
: left < edgeThreshold
|
||||
? ('left' as const)
|
||||
: ('center' as const);
|
||||
|
||||
// Update debug overlays directly via DOM to avoid React re-render lag
|
||||
if (DEBUG) {
|
||||
// Text overlay
|
||||
if (debugRef.current) {
|
||||
const el = debugRef.current;
|
||||
el.textContent = '';
|
||||
const decisionColor = lastDecisionRef.current === 'center' ? '#facc15' : '#4ade80';
|
||||
const liveColor = pin === 'center' ? '#facc15' : '#4ade80';
|
||||
const preInfo =
|
||||
overrideWidth !== undefined
|
||||
? ` | pre: L${Math.round(left)} R${Math.round(right)}`
|
||||
: '';
|
||||
el.innerHTML = `decision: <span style="color:${decisionColor}">${lastDecisionRef.current}</span> | live: <span style="color:${liveColor}">${pin}</span> | x: ${Math.round(x.get())} y: ${Math.round(y.get())} | rect: L${Math.round(rect.left)} R${Math.round(rect.right)} (vw: ${viewportWidth})${preInfo}`;
|
||||
}
|
||||
|
||||
// Edge anchor indicators on the toolbar pill (track live state)
|
||||
if (debugLeftEdgeRef.current) {
|
||||
debugLeftEdgeRef.current.style.backgroundColor =
|
||||
pin === 'left' ? '#4ade80' : '#ffffff20';
|
||||
debugLeftEdgeRef.current.style.boxShadow =
|
||||
pin === 'left' ? '0 0 8px #4ade80' : 'none';
|
||||
}
|
||||
if (debugRightEdgeRef.current) {
|
||||
debugRightEdgeRef.current.style.backgroundColor =
|
||||
pin === 'right' ? '#4ade80' : '#ffffff20';
|
||||
debugRightEdgeRef.current.style.boxShadow =
|
||||
pin === 'right' ? '0 0 8px #4ade80' : 'none';
|
||||
}
|
||||
|
||||
// Arrow showing expansion direction (tracks live state so it
|
||||
// updates as you drag, showing what WOULD happen on click)
|
||||
if (debugArrowRef.current) {
|
||||
debugArrowRef.current.textContent =
|
||||
pin === 'left'
|
||||
? '→'
|
||||
: pin === 'right'
|
||||
? '←'
|
||||
: '↔';
|
||||
debugArrowRef.current.style.color =
|
||||
pin === 'center' ? '#facc15' : '#4ade80';
|
||||
}
|
||||
}
|
||||
|
||||
return pin;
|
||||
},
|
||||
[x, y]
|
||||
);
|
||||
|
||||
// Update debug display reactively when x/y change (direct DOM updates, no re-renders)
|
||||
React.useEffect(() => {
|
||||
if (!DEBUG) return;
|
||||
const unsubX = x.on('change', () => computePinState());
|
||||
const unsubY = y.on('change', () => computePinState());
|
||||
// Initial computation
|
||||
computePinState();
|
||||
return () => {
|
||||
unsubX();
|
||||
unsubY();
|
||||
};
|
||||
}, [x, y, computePinState]);
|
||||
|
||||
// Compensate drag x position when the toolbar width changes (expand/collapse) so the
|
||||
// pinned edge stays anchored. Uses viewport-aware edge detection instead of a fixed
|
||||
// x-threshold so it works correctly at any viewport width.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!innerRef.current) return;
|
||||
|
||||
const newWidth = innerRef.current.offsetWidth;
|
||||
const prevWidth = prevWidthRef.current;
|
||||
|
||||
if (prevWidth !== undefined && prevWidth !== newWidth) {
|
||||
const delta = newWidth - prevWidth;
|
||||
const currentX = x.get();
|
||||
const currentY = y.get();
|
||||
const expanding = newWidth > prevWidth;
|
||||
|
||||
// Grab the raw post-resize rect for logging
|
||||
const rawRect = innerRef.current.getBoundingClientRect();
|
||||
const center = (rawRect.left + rawRect.right) / 2;
|
||||
const preResizeLeft = center - prevWidth / 2;
|
||||
const preResizeRight = center + prevWidth / 2;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const edgeThreshold = 80;
|
||||
|
||||
// Use prevWidth to reconstruct where the edges WERE before the resize.
|
||||
const pin = computePinState(prevWidth);
|
||||
lastDecisionRef.current = pin;
|
||||
// Refresh debug overlay so "decision" label updates immediately
|
||||
computePinState(prevWidth);
|
||||
|
||||
let compensation = 0;
|
||||
if (pin === 'right') {
|
||||
compensation = -delta / 2;
|
||||
} else if (pin === 'left') {
|
||||
compensation = delta / 2;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console -- temporary debug logging
|
||||
console.warn(
|
||||
`[Toolbar Pin] ${expanding ? 'EXPAND' : 'COLLAPSE'}\n` +
|
||||
` Action: ${expanding ? 'minified → expanded' : 'expanded → minified'}\n` +
|
||||
` Width: ${prevWidth}px → ${newWidth}px (delta: ${delta}px)\n` +
|
||||
` Motion: x=${Math.round(currentX)}, y=${Math.round(currentY)}\n` +
|
||||
` Viewport: ${viewportWidth}px, edgeThreshold: ${edgeThreshold}px\n` +
|
||||
` Post-resize rect (raw): L=${Math.round(rawRect.left)} R=${Math.round(rawRect.right)}\n` +
|
||||
` Pre-resize rect (reconstructed): L=${Math.round(preResizeLeft)} R=${Math.round(preResizeRight)}\n` +
|
||||
` Center: ${Math.round(center)}\n` +
|
||||
` Pin check: left=${Math.round(preResizeLeft)} < ${edgeThreshold}? ${preResizeLeft < edgeThreshold} | right=${Math.round(preResizeRight)} > ${viewportWidth - edgeThreshold}? ${preResizeRight > viewportWidth - edgeThreshold}\n` +
|
||||
` → Decision: ${pin}\n` +
|
||||
` → Compensation: ${compensation}px (x: ${Math.round(currentX)} → ${Math.round(currentX + compensation)})`
|
||||
);
|
||||
|
||||
if (compensation !== 0) {
|
||||
animate(x, currentX + compensation, {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 30,
|
||||
mass: 1,
|
||||
}).then(savePosition);
|
||||
}
|
||||
}
|
||||
|
||||
prevWidthRef.current = newWidth;
|
||||
}, [minified, x, savePosition, computePinState]);
|
||||
|
||||
// Don't render anything until page is ready
|
||||
if (!isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<motion.div className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
onClick={() => {
|
||||
if (minified) {
|
||||
setShouldAnimateLogo(false);
|
||||
onMinifiedChange(false);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'backdrop-blur-sm',
|
||||
'origin-center',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'bg-[linear-gradient(45deg,rgba(39,39,39,0.8)_100%,rgba(39,39,39,0.4)_80%)]',
|
||||
'dark:bg-[linear-gradient(45deg,rgba(39,39,39,0.5)_100%,rgba(39,39,39,0.3)_80%)]'
|
||||
)}
|
||||
style={{
|
||||
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||
}}
|
||||
>
|
||||
{/* Logo with stroke segments animation in blue-tints */}
|
||||
<motion.div layout>
|
||||
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
|
||||
<ToolbarDraggingContext.Provider value={isDragging}>
|
||||
{/* Hidden SVG filter for glass distortion */}
|
||||
<svg aria-hidden="true" className="pointer-events-none fixed size-0">
|
||||
<defs>
|
||||
<filter id="glass-distortion">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.016 0.012"
|
||||
numOctaves={3}
|
||||
seed={39}
|
||||
result="noise"
|
||||
/>
|
||||
<feGaussianBlur in="noise" stdDeviation={6} result="softNoise" />
|
||||
<feDisplacementMap
|
||||
in="SourceGraphic"
|
||||
in2="softNoise"
|
||||
scale={160}
|
||||
xChannelSelector="R"
|
||||
yChannelSelector="G"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<div
|
||||
ref={constraintsRef}
|
||||
className="pointer-events-none fixed inset-2 z-40 flex items-end justify-center"
|
||||
>
|
||||
<motion.div
|
||||
drag
|
||||
dragConstraints={constraintsRef}
|
||||
dragElastic={0.04}
|
||||
dragTransition={{
|
||||
power: 0.2,
|
||||
timeConstant: 200,
|
||||
bounceStiffness: 800,
|
||||
bounceDamping: 60,
|
||||
}}
|
||||
style={{ x, y }}
|
||||
onDragStart={() => {
|
||||
isDraggingRef.current = true;
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setIsDragging(false);
|
||||
requestAnimationFrame(() => {
|
||||
isDraggingRef.current = false;
|
||||
});
|
||||
}}
|
||||
onDragTransitionEnd={savePosition}
|
||||
className="pointer-events-auto relative w-auto max-w-xl cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
{/* Visibility peek label — rendered as a sibling before the pill so it paints behind it */}
|
||||
{!hintDismissed && (
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
y: !minified && !isDragging ? 0 : 10,
|
||||
opacity: !minified && !isDragging ? 1 : 0,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
}}
|
||||
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 flex items-center gap-1.5 rounded-t-xl border border-[#eaeaea] border-b-0 border-solid bg-white px-3"
|
||||
style={{ paddingBlock: '2px' }}
|
||||
>
|
||||
<span className="whitespace-nowrap text-[11px] text-neutral-9">
|
||||
Only visible to your GitBook organization
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto cursor-pointer rounded border border-tint-5 bg-tint-2 px-1 py-px text-[10px] text-tint-12 transition-colors hover:scale-102 hover:bg-tint-3 dark:border-tint-11/50 dark:bg-white dark:text-tint-1 dark:hover:bg-tint-11/20"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setVisibilityHintDismissed();
|
||||
setHintDismissed(true);
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!minified ? children : null}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
ref={innerRef}
|
||||
onClick={() => {
|
||||
if (isDraggingRef.current) return;
|
||||
if (minified) {
|
||||
setShouldAnimateLogo(false);
|
||||
onMinifiedChange(false);
|
||||
}
|
||||
}}
|
||||
layout
|
||||
transition={toolbarEasings.spring}
|
||||
className={tcls(
|
||||
minified ? 'cursor-pointer' : 'pr-2 pl-3.5',
|
||||
'relative',
|
||||
'flex',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'min-h-11',
|
||||
'min-w-12',
|
||||
'h-12',
|
||||
'py-2',
|
||||
'origin-center',
|
||||
'border-[0.5px] border-neutral-5 border-solid dark:border-neutral-8',
|
||||
'bg-[var(--toolbar-bg)]'
|
||||
)}
|
||||
style={
|
||||
{
|
||||
'--toolbar-bg': '#1f1d1b',
|
||||
borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions
|
||||
zIndex: 1, // Ensure pill stacks above the peek label sibling
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* Glass effect layer (disabled for now) */}
|
||||
{/* <div className={styles.glassLayer} /> */}
|
||||
|
||||
{/* Debug: anchor edge indicators */}
|
||||
{DEBUG && (
|
||||
<>
|
||||
<div
|
||||
ref={debugLeftEdgeRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -1,
|
||||
top: '15%',
|
||||
bottom: '15%',
|
||||
width: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ffffff20',
|
||||
transition: 'background-color 0.2s, box-shadow 0.2s',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={debugRightEdgeRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -1,
|
||||
top: '15%',
|
||||
bottom: '15%',
|
||||
width: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#ffffff20',
|
||||
transition: 'background-color 0.2s, box-shadow 0.2s',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={debugArrowRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Logo — double-click to minimize without opening the arc menu */}
|
||||
<motion.div
|
||||
layout
|
||||
onDoubleClick={(e) => {
|
||||
if (isDraggingRef.current || minified) return;
|
||||
e.stopPropagation();
|
||||
onMinifiedChange(true);
|
||||
}}
|
||||
>
|
||||
<AnimatedLogo shouldAnimate={shouldAnimateLogo} />
|
||||
</motion.div>
|
||||
|
||||
{!minified ? children : null}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{/* Debug overlay for pinning state — updated via DOM ref to avoid re-render lag */}
|
||||
{DEBUG && (
|
||||
<>
|
||||
<div
|
||||
ref={debugRef}
|
||||
className="pointer-events-none whitespace-nowrap rounded bg-black/80 px-2 py-1 font-mono text-[10px] text-white"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
{/* Viewport edge threshold zones */}
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 80,
|
||||
height: '100vh',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
borderRight: '1px dashed rgba(74, 222, 128, 0.3)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 39,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 80,
|
||||
right: 4,
|
||||
fontSize: 9,
|
||||
color: 'rgba(74, 222, 128, 0.5)',
|
||||
writingMode: 'vertical-rl',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
pin-left zone (80px)
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 80,
|
||||
height: '100vh',
|
||||
backgroundColor: 'rgba(74, 222, 128, 0.06)',
|
||||
borderLeft: '1px dashed rgba(74, 222, 128, 0.3)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 39,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 80,
|
||||
left: 4,
|
||||
fontSize: 9,
|
||||
color: 'rgba(74, 222, 128, 0.5)',
|
||||
writingMode: 'vertical-rl',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
pin-right zone (80px)
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ToolbarDraggingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,63 +603,53 @@ export const ToolbarButton = React.forwardRef<HTMLDivElement, ToolbarButtonProps
|
||||
children,
|
||||
} = props;
|
||||
const reduceMotion = useReducedMotion();
|
||||
const isDragging = React.useContext(ToolbarDraggingContext);
|
||||
|
||||
const anchor = (
|
||||
<motion.a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: motionValues?.scale,
|
||||
x: motionValues?.x,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||
...style,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={tcls(
|
||||
'toolbar-button',
|
||||
className,
|
||||
'relative flex size-8 cursor-pointer items-center justify-center gap-1 truncate rounded-full text-sm transition-colors',
|
||||
'text-tint-7 hover:text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'bg-[var(--toolbar-bg)]',
|
||||
'hover:bg-[color-mix(in_srgb,var(--toolbar-bg)_90%,white)]'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls('size-3.5 shrink-0 group-hover:scale-110', iconClassName)}
|
||||
/>
|
||||
</motion.a>
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div variants={toolbarEasings.staggeringChild} className="relative" ref={ref}>
|
||||
{children ? children : null}
|
||||
<Tooltip label={title}>
|
||||
<motion.a
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={
|
||||
reduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: motionValues?.scale,
|
||||
x: motionValues?.x,
|
||||
transformOrigin: 'bottom center',
|
||||
zIndex: motionValues?.scale ? 10 : 'auto',
|
||||
...style,
|
||||
}
|
||||
}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={tcls(
|
||||
'toolbar-button',
|
||||
className,
|
||||
'flex',
|
||||
'relative',
|
||||
'items-center',
|
||||
'justify-center',
|
||||
'gap-1',
|
||||
'text-sm',
|
||||
'rounded-full',
|
||||
'truncate',
|
||||
'text-tint-1',
|
||||
'dark:text-tint-12',
|
||||
'cursor-pointer',
|
||||
'transition-colors',
|
||||
'size-8',
|
||||
disabled ? 'cursor-not-allowed opacity-50' : '',
|
||||
'border border-[rgba(256,_256,_256,_0.06)] border-solid',
|
||||
'bg-[linear-gradient(45deg,rgba(51,53,57,1)_0%,rgba(50,52,56,1)_100%)]'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
iconStyle={IconStyle.Solid}
|
||||
className={tcls(
|
||||
'size-4 shrink-0 group-hover:scale-110 group-hover:text-tint-3',
|
||||
iconClassName
|
||||
)}
|
||||
/>
|
||||
</motion.a>
|
||||
</Tooltip>
|
||||
{title && !isDragging ? <Tooltip label={title}>{anchor}</Tooltip> : anchor}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
@@ -339,7 +735,7 @@ export function ToolbarSubtitle(props: { subtitle: React.ReactNode }) {
|
||||
return (
|
||||
<motion.span
|
||||
{...getCopyVariants(1)}
|
||||
className="text-neutral-1/80 text-xxs dark:text-neutral-12/80"
|
||||
className="inline-flex items-center gap-1 text-neutral-1/80 text-xxs dark:text-neutral-12/80"
|
||||
>
|
||||
{props.subtitle}
|
||||
</motion.span>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useLanguage } from '@/intl/client';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
type DateFormat = 'relative' | 'weekday' | 'full';
|
||||
|
||||
const DATE_FORMATS: DateFormat[] = ['relative', 'weekday', 'full'];
|
||||
|
||||
/**
|
||||
* Toolbar-specific date display that cycles between relative, weekday, and full formats on click.
|
||||
*/
|
||||
export function ToolbarDate(props: { value: string }) {
|
||||
const { value } = props;
|
||||
const language = useLanguage();
|
||||
const [now, setNow] = React.useState<number>(Date.now());
|
||||
const [formatIndex, setFormatIndex] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
setNow(Date.now());
|
||||
},
|
||||
30 * 60 * 1000
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const date = new Date(value);
|
||||
const format = DATE_FORMATS[formatIndex] as DateFormat;
|
||||
|
||||
const formatted = React.useMemo(() => {
|
||||
return formatDateValue(format, language.locale, now, date);
|
||||
}, [format, language.locale, now, date]);
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
{/* Vertical dots indicating cycleable formats */}
|
||||
<div className="flex flex-col items-center gap-[1px]">
|
||||
{DATE_FORMATS.map((fmt, i) => (
|
||||
<motion.span
|
||||
key={fmt}
|
||||
animate={{
|
||||
opacity: i === formatIndex ? 1 : 0.3,
|
||||
scale: i === formatIndex ? 1 : 0.75,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||
className="block size-[3px] rounded-full bg-current"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<time
|
||||
data-visual-test="transparent"
|
||||
suppressHydrationWarning={true}
|
||||
dateTime={value}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setFormatIndex((i) => (i + 1) % DATE_FORMATS.length);
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex min-w-24 cursor-pointer overflow-hidden font-semibold transition-colors hover:text-white"
|
||||
>
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
<motion.span
|
||||
key={formatIndex}
|
||||
initial={{ y: '-100%', opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: '100%', opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
|
||||
>
|
||||
{formatted}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateValue(format: DateFormat, locale: string, now: number, date: Date): string {
|
||||
switch (format) {
|
||||
case 'relative':
|
||||
return formatRelative(locale, now - date.getTime());
|
||||
case 'weekday':
|
||||
return date.toLocaleDateString(locale, {
|
||||
weekday: 'long',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
case 'full':
|
||||
return date.toLocaleString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(locale: string, diff: number) {
|
||||
if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat === 'undefined') {
|
||||
const days = Math.floor(diff / 24 / 60 / 60 / 1000);
|
||||
return `${days} days ago`;
|
||||
}
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(locale, { style: 'long' });
|
||||
|
||||
if (diff < 60 * 60 * 1000) {
|
||||
const minutes = Math.floor(diff / 60 / 1000);
|
||||
return rtf.format(-minutes, 'minute');
|
||||
}
|
||||
|
||||
if (diff < 24 * 60 * 60 * 1000) {
|
||||
const hours = Math.floor(diff / 60 / 60 / 1000);
|
||||
return rtf.format(-hours, 'hour');
|
||||
}
|
||||
|
||||
if (diff < 30 * 24 * 60 * 60 * 1000) {
|
||||
const days = Math.floor(diff / 24 / 60 / 60 / 1000);
|
||||
return rtf.format(-days, 'day');
|
||||
}
|
||||
|
||||
if (diff < 365 * 24 * 60 * 60 * 1000) {
|
||||
const months = Math.floor(diff / 30 / 24 / 60 / 60 / 1000);
|
||||
return rtf.format(-months, 'month');
|
||||
}
|
||||
|
||||
const years = Math.floor(diff / 365 / 24 / 60 / 60 / 1000);
|
||||
return rtf.format(-years, 'year');
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
const STORAGE_KEY = 'gitbook_toolbar_closed';
|
||||
const SESSION_STORAGE_KEY = 'gitbook_toolbar_session_closed';
|
||||
const SESSION_MINIFIED_KEY = 'gitbook_toolbar_minified';
|
||||
const SESSION_POSITION_KEY = 'gitbook_toolbar_position';
|
||||
const VISIBILITY_HINT_DISMISSED_KEY = 'gitbook_toolbar_hint_dismissed';
|
||||
|
||||
type SessionHideReason = 'session' | 'persistent';
|
||||
|
||||
@@ -57,6 +59,35 @@ export const setStoredMinified = (value: boolean) => {
|
||||
setSessionStorageItem(SESSION_MINIFIED_KEY, value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve the last drag position from session storage. Returns `null` when no position has been
|
||||
* stored, meaning the toolbar should use its default center-bottom placement.
|
||||
*/
|
||||
export const getStoredPosition = (): { x: number; y: number } | null => {
|
||||
return getSessionStorageItem<{ x: number; y: number } | null>(SESSION_POSITION_KEY, null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist the current drag position for the ongoing session.
|
||||
*/
|
||||
export const setStoredPosition = (position: { x: number; y: number }) => {
|
||||
setSessionStorageItem(SESSION_POSITION_KEY, position);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether the user has dismissed the "only you can see this" hint.
|
||||
*/
|
||||
export const getVisibilityHintDismissed = (): boolean => {
|
||||
return getLocalStorageItem(VISIBILITY_HINT_DISMISSED_KEY, false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist that the user dismissed the visibility hint.
|
||||
*/
|
||||
export const setVisibilityHintDismissed = () => {
|
||||
setLocalStorageItem(VISIBILITY_HINT_DISMISSED_KEY, true);
|
||||
};
|
||||
|
||||
interface UseToolbarVisibilityOptions {
|
||||
onPersistentClose?: () => void;
|
||||
onSessionClose?: () => void;
|
||||
|
||||
@@ -65,6 +65,8 @@ export function PageBody(props: {
|
||||
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
|
||||
).length > 0;
|
||||
|
||||
const pageHasToc = page.layout.tableOfContents && hasVisibleTOCItems;
|
||||
|
||||
return (
|
||||
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
|
||||
<main
|
||||
@@ -76,12 +78,10 @@ export function PageBody(props: {
|
||||
'@container',
|
||||
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
|
||||
siteWidthWide ? 'site-width-wide' : 'site-width-default',
|
||||
page.layout.tableOfContents && hasVisibleTOCItems
|
||||
? 'page-has-toc'
|
||||
: 'page-no-toc'
|
||||
pageHasToc ? 'page-has-toc' : 'page-no-toc'
|
||||
)}
|
||||
>
|
||||
<PreservePageLayout siteWidthWide={siteWidthWide} />
|
||||
<PreservePageLayout siteWidthWide={siteWidthWide} pageHasToc={pageHasToc} />
|
||||
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||
) : null}
|
||||
|
||||
@@ -11,9 +11,10 @@ import * as React from 'react';
|
||||
* 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
|
||||
*
|
||||
* This component ensures that the layout is preserved while transitioning between the 2 page states (in step 2).
|
||||
* It also preserves the page TOC state (page-has-toc/page-no-toc) to prevent logo sizing issues during navigation.
|
||||
*/
|
||||
export function PreservePageLayout(props: { siteWidthWide: boolean }) {
|
||||
const { siteWidthWide } = props;
|
||||
export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc: boolean }) {
|
||||
const { siteWidthWide, pageHasToc } = props;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// We use the header as it's an element preserved between page transitions
|
||||
@@ -28,7 +29,15 @@ export function PreservePageLayout(props: { siteWidthWide: boolean }) {
|
||||
} else {
|
||||
header.classList.remove('site-width-wide');
|
||||
}
|
||||
}, [siteWidthWide]);
|
||||
|
||||
if (pageHasToc) {
|
||||
header.classList.add('page-has-toc');
|
||||
header.classList.remove('page-no-toc');
|
||||
} else {
|
||||
header.classList.add('page-no-toc');
|
||||
header.classList.remove('page-has-toc');
|
||||
}
|
||||
}, [siteWidthWide, pageHasToc]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user