mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-01 06:28:38 +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 { 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}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -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,30 +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
|
// @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
|
||||||
@@ -84,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>
|
||||||
@@ -127,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;
|
||||||
@@ -143,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(
|
||||||
@@ -185,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, { isValidElement } 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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -339,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');
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export function PageBody(props: {
|
|||||||
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
|
(page) => page.type !== 'document' || (page.type === 'document' && !page.hidden)
|
||||||
).length > 0;
|
).length > 0;
|
||||||
|
|
||||||
|
const pageHasToc = page.layout.tableOfContents && hasVisibleTOCItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
|
<CurrentPageProvider page={{ spaceId: context.space.id, pageId: page.id }}>
|
||||||
<main
|
<main
|
||||||
@@ -76,12 +78,10 @@ export function PageBody(props: {
|
|||||||
'@container',
|
'@container',
|
||||||
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
|
pageWidthWide ? 'page-width-wide 3xl:px-8' : 'page-width-default',
|
||||||
siteWidthWide ? 'site-width-wide' : 'site-width-default',
|
siteWidthWide ? 'site-width-wide' : 'site-width-default',
|
||||||
page.layout.tableOfContents && hasVisibleTOCItems
|
pageHasToc ? 'page-has-toc' : 'page-no-toc'
|
||||||
? 'page-has-toc'
|
|
||||||
: 'page-no-toc'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<PreservePageLayout siteWidthWide={siteWidthWide} />
|
<PreservePageLayout siteWidthWide={siteWidthWide} pageHasToc={pageHasToc} />
|
||||||
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
|
||||||
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
<PageCover as="hero" page={page} cover={page.cover} context={context} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import * as React from 'react';
|
|||||||
* 3. Page 2 with full width block: `body:has(.site-width-wide)` is true
|
* 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).
|
* 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 }) {
|
export function PreservePageLayout(props: { siteWidthWide: boolean; pageHasToc: boolean }) {
|
||||||
const { siteWidthWide } = props;
|
const { siteWidthWide, pageHasToc } = props;
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
// We use the header as it's an element preserved between page transitions
|
// We use the header as it's an element preserved between page transitions
|
||||||
@@ -28,7 +29,15 @@ export function PreservePageLayout(props: { siteWidthWide: boolean }) {
|
|||||||
} else {
|
} else {
|
||||||
header.classList.remove('site-width-wide');
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user