mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
0139b91b5d
* chore: Replace lodash with es-toolkit Migrate all direct lodash imports to es-toolkit/compat for a smaller, faster, lodash-compatible utility library. Transitive lodash usage from other packages remains unchanged. * fix: Restore isPlainObject semantics in CanCan policy The lodash migration aliased `isObject` to `lodash/isPlainObject` and the codemod incorrectly mapped the local name to es-toolkit's `isObject`, which also returns true for arrays and functions. This caused condition objects in policy definitions to be skipped, breaking authorization checks across the codebase. * fix: Restore unicode-aware length counting in validators es-toolkit/compat's size() returns string.length, while lodash's _.size() counts unicode code points. Switch to [...value].length to preserve the previous behavior so multi-byte characters like emoji count as one.
225 lines
5.3 KiB
TypeScript
225 lines
5.3 KiB
TypeScript
import { throttle } from "es-toolkit/compat";
|
|
import { observer } from "mobx-react";
|
|
import { MenuIcon } from "outline-icons";
|
|
import { transparentize } from "polished";
|
|
import * as React from "react";
|
|
import { mergeRefs } from "react-merge-refs";
|
|
import styled from "styled-components";
|
|
import breakpoint from "styled-components-breakpoint";
|
|
import useMeasure from "react-use-measure";
|
|
import { depths, s } from "@shared/styles";
|
|
import { supportsPassiveListener } from "@shared/utils/browser";
|
|
import Button from "~/components/Button";
|
|
import Fade from "~/components/Fade";
|
|
import Flex from "~/components/Flex";
|
|
import useEventListener from "~/hooks/useEventListener";
|
|
import useMobile from "~/hooks/useMobile";
|
|
import useStores from "~/hooks/useStores";
|
|
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
|
|
import Desktop from "~/utils/Desktop";
|
|
import { TooltipProvider } from "./TooltipContext";
|
|
|
|
export const HEADER_HEIGHT = 64;
|
|
|
|
type Props = {
|
|
left?: React.ReactNode;
|
|
title: React.ReactNode;
|
|
actions?:
|
|
| ((props: { isCompact: boolean }) => React.ReactNode)
|
|
| React.ReactNode;
|
|
hasSidebar?: boolean;
|
|
className?: string;
|
|
};
|
|
|
|
function Header(
|
|
{ left, title, actions, hasSidebar, className }: Props,
|
|
ref: React.RefObject<HTMLDivElement> | null
|
|
) {
|
|
const { ui } = useStores();
|
|
const isMobile = useMobile();
|
|
const hasMobileSidebar = hasSidebar && isMobile;
|
|
const [internalMeasureRef, size] = useMeasure();
|
|
const [breadcrumbsMeasureRef, breadcrumbsSize] = useMeasure();
|
|
const passThrough = !actions && !left && !title;
|
|
|
|
const [isScrolled, setScrolled] = React.useState(false);
|
|
const handleScroll = React.useMemo(
|
|
() => throttle(() => setScrolled(window.scrollY > 75), 50),
|
|
[]
|
|
);
|
|
|
|
useEventListener(
|
|
"scroll",
|
|
handleScroll,
|
|
window,
|
|
supportsPassiveListener ? { passive: true } : { capture: false }
|
|
);
|
|
|
|
const handleClickTitle = React.useCallback(() => {
|
|
window.scrollTo({
|
|
top: 0,
|
|
behavior: "smooth",
|
|
});
|
|
}, []);
|
|
|
|
const setBreadcrumbRef = React.useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
if (node?.firstElementChild) {
|
|
breadcrumbsMeasureRef(node.firstElementChild as HTMLDivElement);
|
|
}
|
|
},
|
|
[breadcrumbsMeasureRef]
|
|
);
|
|
|
|
const breadcrumbMakesCompact = breadcrumbsSize.width > size.width / 3;
|
|
const isCompact = size.width < 1000 || breadcrumbMakesCompact;
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<Wrapper
|
|
ref={mergeRefs([ref, internalMeasureRef])}
|
|
align="center"
|
|
shrink={false}
|
|
className={className}
|
|
$passThrough={passThrough}
|
|
$insetTitleAdjust={ui.sidebarIsClosed && Desktop.hasInsetTitlebar()}
|
|
>
|
|
{left || hasMobileSidebar ? (
|
|
<Breadcrumbs ref={setBreadcrumbRef}>
|
|
{hasMobileSidebar && (
|
|
<MobileMenuButton
|
|
haptic="light"
|
|
onClick={ui.toggleMobileSidebar}
|
|
icon={<MenuIcon />}
|
|
neutral
|
|
/>
|
|
)}
|
|
{left}
|
|
</Breadcrumbs>
|
|
) : null}
|
|
|
|
{isScrolled && !isCompact ? (
|
|
<Title onClick={handleClickTitle}>
|
|
<Fade>{title}</Fade>
|
|
</Title>
|
|
) : (
|
|
<div />
|
|
)}
|
|
<Actions align="center" justify="flex-end">
|
|
{typeof actions === "function" ? actions({ isCompact }) : actions}
|
|
</Actions>
|
|
</Wrapper>
|
|
</TooltipProvider>
|
|
);
|
|
}
|
|
|
|
const Breadcrumbs = styled("div")`
|
|
flex-grow: 1;
|
|
flex-basis: 0;
|
|
min-width: 0;
|
|
align-items: center;
|
|
padding-inline: 0 8px;
|
|
display: flex;
|
|
|
|
${breakpoint("tablet")`
|
|
min-width: auto;
|
|
`};
|
|
`;
|
|
|
|
const Actions = styled(Flex)`
|
|
flex-grow: 1;
|
|
flex-basis: 0;
|
|
min-width: auto;
|
|
padding-inline: 8px 0;
|
|
gap: 12px;
|
|
margin-inline-start: 8px;
|
|
|
|
${breakpoint("tablet")`
|
|
position: unset;
|
|
`};
|
|
`;
|
|
|
|
type WrapperProps = {
|
|
$passThrough?: boolean;
|
|
$insetTitleAdjust?: boolean;
|
|
};
|
|
|
|
const Wrapper = styled(Flex)<WrapperProps>`
|
|
top: 0;
|
|
z-index: ${depths.header};
|
|
position: sticky;
|
|
background: ${s("background")};
|
|
|
|
${(props) =>
|
|
props.$passThrough
|
|
? `
|
|
background: transparent;
|
|
pointer-events: none;
|
|
`
|
|
: `
|
|
background: ${transparentize(0.2, props.theme.background)};
|
|
backdrop-filter: blur(20px);
|
|
`};
|
|
|
|
padding: 12px;
|
|
transform: translate3d(0, 0, 0);
|
|
min-height: ${HEADER_HEIGHT}px;
|
|
justify-content: flex-start;
|
|
${draggableOnDesktop()}
|
|
|
|
button,
|
|
[role="button"] {
|
|
${fadeOnDesktopBackgrounded()}
|
|
}
|
|
|
|
@supports (backdrop-filter: blur(20px)) {
|
|
backdrop-filter: blur(20px);
|
|
background: ${(props) => transparentize(0.2, props.theme.background)};
|
|
}
|
|
|
|
@media print {
|
|
display: none;
|
|
}
|
|
|
|
${breakpoint("tablet")`
|
|
padding: 16px;
|
|
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
|
|
`};
|
|
`;
|
|
|
|
const Title = styled("div")`
|
|
display: none;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
cursor: var(--pointer);
|
|
min-width: 0;
|
|
|
|
${breakpoint("tablet")`
|
|
padding-left: 0;
|
|
display: block;
|
|
`};
|
|
|
|
svg {
|
|
vertical-align: bottom;
|
|
}
|
|
|
|
@media (display-mode: standalone) {
|
|
overflow: hidden;
|
|
flex-grow: 0 !important;
|
|
}
|
|
`;
|
|
|
|
const MobileMenuButton = styled(Button)`
|
|
margin-right: 8px;
|
|
pointer-events: auto;
|
|
|
|
@media print {
|
|
display: none;
|
|
}
|
|
`;
|
|
|
|
export default observer(React.forwardRef(Header));
|