first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
import { useCallback, useEffect, useRef } from 'react'
import { callFnsInSequence } from '../../utils/functions'
import { MergeAndOverride } from '../../../../types/utils'
type AutoExpandingTextAreaProps = MergeAndOverride<
React.ComponentProps<'textarea'>,
{
onResize?: () => void
onAutoFocus?: (textarea: HTMLTextAreaElement) => void
}
>
function AutoExpandingTextArea({
onChange,
onResize,
autoFocus,
onAutoFocus,
...rest
}: AutoExpandingTextAreaProps) {
const ref = useRef<HTMLTextAreaElement>(null)
const previousHeightRef = useRef<number | null>(null)
const previousMeasurementRef = useRef<{
heightAdjustment: number
value: string
} | null>(null)
const resetHeight = useCallback(() => {
const el = ref.current
if (!el) {
return
}
const { value } = el
const previousMeasurement = previousMeasurementRef.current
// Do nothing if the textarea value hasn't changed since the last reset
if (previousMeasurement !== null && value === previousMeasurement.value) {
return
}
let heightAdjustment
if (previousMeasurement === null) {
const computedStyle = window.getComputedStyle(el)
heightAdjustment =
computedStyle.boxSizing === 'border-box'
? Math.ceil(
parseFloat(computedStyle.borderTopWidth) +
parseFloat(computedStyle.borderBottomWidth)
)
: -Math.floor(
parseFloat(computedStyle.paddingTop) +
parseFloat(computedStyle.paddingBottom)
)
} else {
heightAdjustment = previousMeasurement.heightAdjustment
}
const curHeight = el.clientHeight
const fitHeight = el.scrollHeight
// Clear height if text area is empty
if (value === '') {
el.style.removeProperty('height')
}
// Otherwise, expand to fit text
else if (fitHeight > curHeight) {
el.style.height = fitHeight + heightAdjustment + 'px'
}
previousMeasurementRef.current = { heightAdjustment, value }
}, [])
useEffect(() => {
if (!ref.current || !onResize || !('ResizeObserver' in window)) {
return
}
const resizeObserver = new ResizeObserver(() => {
if (!ref.current) {
return
}
const newHeight = ref.current.offsetHeight
// Ignore the resize when the height of the element is less than or equal to 0
if (newHeight <= 0) {
return
}
const heightChanged = newHeight !== previousHeightRef.current
previousHeightRef.current = newHeight
if (heightChanged) {
// Prevent errors like "ResizeObserver loop completed with undelivered
// notifications" that occur if onResize triggers another repaint. The
// cost of this is that onResize lags one frame behind, but it's
// unlikely to matter.
// Wrap onResize to prevent extra parameters being passed
window.requestAnimationFrame(() => onResize())
}
})
resizeObserver.observe(ref.current)
return () => {
resizeObserver.disconnect()
}
}, [onResize])
// Maintain a copy onAutoFocus in a ref for use in the autofocus effect
// below so that the effect doesn't run when onAutoFocus changes
const onAutoFocusRef = useRef(onAutoFocus)
useEffect(() => {
onAutoFocusRef.current = onAutoFocus
}, [onAutoFocus])
// Implement autofocus manually so that the cursor is placed at the end of
// the textarea content
useEffect(() => {
const el = ref.current
if (!el) {
return
}
resetHeight()
if (autoFocus) {
const cursorPos = el.value.length
const timer = window.setTimeout(() => {
el.focus()
el.setSelectionRange(cursorPos, cursorPos)
if (onAutoFocusRef.current) {
onAutoFocusRef.current(el)
}
}, 100)
return () => {
window.clearTimeout(timer)
}
}
}, [autoFocus, resetHeight])
// Reset height when the value changes via the `value` prop. If the textarea
// is controlled, this means resetHeight is called twice per keypress, but
// this is mitigated by a check on whether the value has actually changed in
// resetHeight()
useEffect(() => {
resetHeight()
}, [rest.value, resetHeight])
return (
<textarea
onChange={callFnsInSequence(onChange, resetHeight)}
{...rest}
ref={ref}
/>
)
}
export default AutoExpandingTextArea

View File

@@ -0,0 +1,34 @@
import type { FC } from 'react'
import MaterialIcon from '@/shared/components/material-icon'
import OLBadge from '@/features/ui/components/ol/ol-badge'
const BetaBadgeIcon: FC<{
phase?: string
}> = ({ phase = 'beta' }) => {
const badgeClass = chooseBadgeClass(phase)
if (badgeClass === 'info-badge') {
return <MaterialIcon type="info" className="align-middle info-badge" />
} else if (badgeClass === 'alpha-badge') {
return (
<OLBadge bg="primary" className="alpha-badge">
α
</OLBadge>
)
} else {
return <OLBadge bg="warning">β</OLBadge>
}
}
function chooseBadgeClass(phase?: string) {
switch (phase) {
case 'release':
return 'info-badge'
case 'alpha':
return 'alpha-badge'
case 'beta':
default:
return 'beta-badge'
}
}
export default BetaBadgeIcon

View File

@@ -0,0 +1,64 @@
import type { FC, MouseEventHandler, ReactNode } from 'react'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import BetaBadgeIcon from '@/shared/components/beta-badge-icon'
type TooltipProps = {
id: string
text: ReactNode
className?: string
placement?: NonNullable<
React.ComponentProps<typeof OLTooltip>['overlayProps']
>['placement']
}
type LinkProps = {
href?: string
ref?: React.Ref<HTMLAnchorElement>
className?: string
onMouseDown?: MouseEventHandler<HTMLAnchorElement>
}
const defaultHref = '/beta/participate'
const BetaBadge: FC<{
tooltip?: TooltipProps
link?: LinkProps
description?: ReactNode
phase?: string
}> = ({
tooltip,
link = { href: defaultHref },
description,
phase = 'beta',
}) => {
const { href, ...linkProps } = link
const linkedBadge = (
<a
target="_blank"
rel="noopener noreferrer"
href={href || defaultHref}
{...linkProps}
>
<span className="visually-hidden">{description || tooltip?.text}</span>
<BetaBadgeIcon phase={phase} />
</a>
)
return tooltip ? (
<OLTooltip
id={tooltip.id}
description={tooltip.text}
tooltipProps={{ className: tooltip.className }}
overlayProps={{
placement: tooltip.placement || 'bottom',
delay: 100,
}}
>
{linkedBadge}
</OLTooltip>
) : (
linkedBadge
)
}
export default BetaBadge

View File

@@ -0,0 +1,75 @@
import { useRef, useEffect } from 'react'
import PolymorphicComponent, {
PolymorphicComponentProps,
} from '@/shared/components/polymorphic-component'
import { MergeAndOverride } from '../../../../types/utils'
// Performs a click event on elements that has been clicked,
// but when releasing the mouse button are no longer hovered
// by the cursor (which by default cancels the event).
type ClickableElementEnhancerOwnProps = {
onClick: () => void
onMouseDown?: (e: React.MouseEvent) => void
offset?: number
}
type ClickableElementEnhancerProps<E extends React.ElementType> =
MergeAndOverride<
PolymorphicComponentProps<E>,
ClickableElementEnhancerOwnProps
>
function ClickableElementEnhancer<E extends React.ElementType>({
onClick,
onMouseDown,
offset = 50, // the offset around the clicked element which should still trigger the click
...rest
}: ClickableElementEnhancerProps<E>) {
const isClickedRef = useRef(false)
const elRectRef = useRef<DOMRect>()
const restProps = rest as PolymorphicComponentProps<E>
const handleMouseDown = (e: React.MouseEvent) => {
isClickedRef.current = true
elRectRef.current = (e.target as HTMLElement).getBoundingClientRect()
onMouseDown?.(e)
}
useEffect(() => {
const handleMouseUp = (e: MouseEvent) => {
if (isClickedRef.current) {
isClickedRef.current = false
if (!elRectRef.current) {
return
}
const halfWidth = elRectRef.current.width / 2
const halfHeight = elRectRef.current.height / 2
const centerX = elRectRef.current.x + halfWidth
const centerY = elRectRef.current.y + halfHeight
const deltaX = Math.abs(e.clientX - centerX)
const deltaY = Math.abs(e.clientY - centerY)
// Check if the mouse has moved significantly from the element position
if (deltaX < halfWidth + offset && deltaY < halfHeight + offset) {
// If the mouse hasn't moved much, consider it a click
onClick()
}
}
}
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mouseup', handleMouseUp)
}
}, [onClick, offset])
return <PolymorphicComponent onMouseDown={handleMouseDown} {...restProps} />
}
export default ClickableElementEnhancer

View File

@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
type CloseProps = {
onDismiss: React.MouseEventHandler<HTMLButtonElement>
variant?: 'light' | 'dark'
}
function Close({ onDismiss, variant = 'light' }: CloseProps) {
const { t } = useTranslation()
return (
<button
type="button"
className={`close pull-right ${variant}`}
onClick={onDismiss}
>
<MaterialIcon
type="close"
className="align-text-bottom"
accessibilityLabel={t('close')}
/>
<span className="sr-only">{t('close')}</span>
</button>
)
}
export default Close

View File

@@ -0,0 +1,21 @@
import MaterialIcon from '@/shared/components/material-icon'
import { Dispatch, FC, SetStateAction } from 'react'
export const CollapsibleFileHeader: FC<{
name: string
count: number
collapsed: boolean
toggleCollapsed: Dispatch<SetStateAction<any>>
}> = ({ name, count, collapsed, toggleCollapsed }) => (
<button
type="button"
className="collapsible-file-header"
onClick={toggleCollapsed}
>
<MaterialIcon
type={collapsed ? 'keyboard_arrow_right' : 'keyboard_arrow_down'}
/>
{name}
<div className="collapsible-file-header-count">{count}</div>
</button>
)

View File

@@ -0,0 +1,57 @@
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLIconButton from '@/features/ui/components/ol/ol-icon-button'
export const CopyToClipboard = memo<{
content: string
tooltipId: string
kind?: 'text' | 'icon'
}>(({ content, tooltipId, kind = 'icon' }) => {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const handleClick = useCallback(() => {
navigator.clipboard.writeText(content).then(() => {
setCopied(true)
window.setTimeout(() => {
setCopied(false)
}, 1500)
})
}, [content])
if (!navigator.clipboard?.writeText) {
return null
}
return (
<OLTooltip
id={tooltipId}
description={copied ? `${t('copied')}!` : t('copy')}
overlayProps={{ delay: copied ? 1000 : 250 }}
>
{kind === 'text' ? (
<OLButton
onClick={handleClick}
size="sm"
variant="secondary"
className="copy-button"
>
{t('copy')}
</OLButton>
) : (
<OLIconButton
onClick={handleClick}
variant="link"
size="sm"
accessibilityLabel={t('copy')}
className="copy-button"
icon={copied ? 'check' : 'content_copy'}
/>
)}
</OLTooltip>
)
})
CopyToClipboard.displayName = 'CopyToClipboard'

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next'
type DefaultMessageProps = {
className?: string
style?: React.CSSProperties
}
export function DefaultMessage({ className, style }: DefaultMessageProps) {
const { t } = useTranslation()
return (
<>
<span style={style}>{`${t('generic_something_went_wrong')}. `}</span>
<span className={className}>{`${t('please_refresh')}`}</span>
</>
)
}

View File

@@ -0,0 +1,31 @@
import { FC } from 'react'
import Notification from '@/shared/components/notification'
import { Trans, useTranslation } from 'react-i18next'
import Bowser from 'bowser'
export const isDeprecatedBrowser = () => {
const parser = Bowser.getParser(window.navigator.userAgent)
return parser.satisfies({
safari: '~15',
})
}
export const DeprecatedBrowser: FC = () => {
const { t } = useTranslation()
return (
<Notification
type="warning"
title={t('support_for_your_browser_is_ending_soon')}
content={
<Trans
i18nKey="to_continue_using_upgrade_or_change_your_browser"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content,react/jsx-key
<a href="/learn/how-to/Which_browsers_does_Overleaf_support%3F" />,
]}
/>
}
/>
)
}

View File

@@ -0,0 +1,15 @@
import { FC, ReactNode } from 'react'
import { DefaultMessage } from './default-message'
import OLNotification from '@/features/ui/components/ol/ol-notification'
export const ErrorBoundaryFallback: FC<{ modal?: ReactNode }> = ({
children,
modal,
}) => {
return (
<div className="error-boundary-alert">
<OLNotification type="error" content={children || <DefaultMessage />} />
{modal}
</div>
)
}

View File

@@ -0,0 +1,25 @@
import BetaBadge from './beta-badge'
import { FC, ReactNode, useMemo } from 'react'
export const FeedbackBadge: FC<{
url: string
id: string
text?: ReactNode
}> = ({ url, id, text }) => {
const tooltip = useMemo(() => {
return {
id: `${id}-tooltip`,
text: text || <DefaultContent />,
}
}, [id, text])
return <BetaBadge tooltip={tooltip} phase="release" link={{ href: url }} />
}
const DefaultContent = () => (
<>
We are testing this new feature.
<br />
Click to give feedback
</>
)

View File

@@ -0,0 +1,8 @@
import { memo } from 'react'
import { formatTimeBasedOnYear } from '@/features/utils/format-date'
export const FormatTimeBasedOnYear = memo<{ date: string | number | Date }>(
function FormatTimeBasedOnYear({ date }) {
return <>{formatTimeBasedOnYear(date)}</>
}
)

View File

@@ -0,0 +1,31 @@
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from '../hooks/use-location'
import { DefaultMessage } from './default-message'
import MaterialIcon from './material-icon'
import OLButton from '@/features/ui/components/ol/ol-button'
export const GenericErrorBoundaryFallback: FC = ({ children }) => {
const { t } = useTranslation()
const { reload: handleClick } = useLocation()
return (
<div className="error-boundary-container">
<MaterialIcon
accessibilityLabel={`${t('generic_something_went_wrong')} ${t(
'please_refresh'
)}`}
type="warning"
size="2x"
/>
{children || (
<div className="error-message">
<DefaultMessage className="small" style={{ fontWeight: 'bold' }} />
</div>
)}
<OLButton variant="primary" onClick={handleClick}>
{t('refresh')}
</OLButton>
</div>
)
}

View File

@@ -0,0 +1,7 @@
// window.history-related functions in a separate module so they can be mocked/stubbed in tests
export const history = {
pushState(data: any, unused: string, url?: string | URL | null) {
window.history.pushState(data, unused, url)
},
}

View File

@@ -0,0 +1,10 @@
import { useTranslation } from 'react-i18next'
import Icon from './icon'
function IconChecked() {
const { t } = useTranslation()
return <Icon type="check" fw accessibilityLabel={t('selected')} />
}
export default IconChecked

View File

@@ -0,0 +1,44 @@
import classNames from 'classnames'
type IconOwnProps = {
type: string
spin?: boolean
fw?: boolean
modifier?: string
accessibilityLabel?: string
}
export type IconProps = IconOwnProps &
Omit<React.ComponentProps<'i'>, keyof IconOwnProps>
function Icon({
type,
spin,
fw,
modifier,
className = '',
accessibilityLabel,
...rest
}: IconProps) {
const iconClassName = classNames(
'fa',
`fa-${type}`,
{
'fa-spin': spin,
'fa-fw': fw,
[`fa-${modifier}`]: modifier,
},
className
)
return (
<>
<i className={iconClassName} aria-hidden="true" {...rest} />
{accessibilityLabel && (
<span className="visually-hidden">{accessibilityLabel}</span>
)}
</>
)
}
export default Icon

View File

@@ -0,0 +1,26 @@
import classNames from 'classnames'
import overleafLogo from '@/shared/svgs/overleaf.svg'
type InterstitialProps = {
className?: string
contentClassName?: string
children: React.ReactNode
showLogo: boolean
title?: string
}
export function Interstitial({
className,
contentClassName,
children,
showLogo,
title,
}: InterstitialProps) {
return (
<div className={classNames('interstitial', className)}>
{showLogo && <img className="logo" src={overleafLogo} alt="Overleaf" />}
{title && <h1 className="h3 interstitial-header">{title}</h1>}
<div className={classNames(contentClassName)}>{children}</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
import { ReactNode, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import OLBadge from '@/features/ui/components/ol/ol-badge'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import { postJSON } from '@/infrastructure/fetch-json'
import OLButton from '@/features/ui/components/ol/ol-button'
import getMeta from '@/utils/meta'
type IntegrationLinkingWidgetProps = {
logo: ReactNode
title: string
description: string
helpPath?: string
labsEnabled?: boolean
experimentName: string
setErrorMessage: (message: string) => void
optedIn: boolean
setOptedIn: (optedIn: boolean) => void
}
export function LabsExperimentWidget({
logo,
title,
description,
helpPath,
labsEnabled,
experimentName,
setErrorMessage,
optedIn,
setOptedIn,
}: IntegrationLinkingWidgetProps) {
const { t } = useTranslation()
const experimentsErrorMessage = t(
'we_are_unable_to_opt_you_into_this_experiment'
)
const allowedExperiments = getMeta('ol-allowedExperiments')
const disabled = !allowedExperiments.includes(experimentName) && !optedIn
const handleEnable = useCallback(async () => {
try {
const enablePath = `/labs/participate/experiments/${experimentName}/opt-in`
await postJSON(enablePath)
setOptedIn(true)
} catch (err) {
setErrorMessage(experimentsErrorMessage)
}
}, [experimentName, setErrorMessage, experimentsErrorMessage, setOptedIn])
const handleDisable = useCallback(async () => {
try {
const disablePath = `/labs/participate/experiments/${experimentName}/opt-out`
await postJSON(disablePath)
setOptedIn(false)
} catch (err) {
setErrorMessage(experimentsErrorMessage)
}
}, [experimentName, setErrorMessage, experimentsErrorMessage, setOptedIn])
return (
<div
className={`labs-experiment-widget-container ${disabled ? 'disabled-experiment' : ''}`}
>
<div className="experiment-logo-container">{logo}</div>
<div className="description-container">
<div className="title-row">
<h3 className="h4">{title}</h3>
{optedIn && <OLBadge bg="info">{t('enabled')}</OLBadge>}
</div>
<p className="small">
{description}{' '}
{helpPath && (
<a href={helpPath} target="_blank" rel="noreferrer">
{t('learn_more')}
</a>
)}
</p>
</div>
{disabled && (
<div className="disabled-explanation">{t('experiment_full')}</div>
)}
<div>
{labsEnabled && (
<ActionButton
optedIn={optedIn}
handleDisable={handleDisable}
handleEnable={handleEnable}
disabled={disabled}
/>
)}
</div>
</div>
)
}
type ActionButtonProps = {
optedIn?: boolean
disabled?: boolean
handleEnable: () => void
handleDisable: () => void
}
function ActionButton({
optedIn,
disabled,
handleEnable,
handleDisable,
}: ActionButtonProps) {
const { t } = useTranslation()
if (optedIn) {
return (
<OLButton variant="secondary" onClick={handleDisable}>
{t('turn_off')}
</OLButton>
)
} else if (disabled) {
const tooltipableButton = (
<div className="d-inline-block">
<OLButton variant="primary" disabled>
{t('turn_on')}
</OLButton>
</div>
)
return (
<OLTooltip
id="experiment-disabled"
description={t('this_experiment_isnt_accepting_new_participants')}
overlayProps={{ delay: 0 }}
>
{tooltipableButton}
</OLTooltip>
)
} else {
return (
<OLButton variant="primary" onClick={handleEnable}>
{t('turn_on')}
</OLButton>
)
}
}
export default LabsExperimentWidget

View File

@@ -0,0 +1,37 @@
type LoadingBrandedTypes = {
loadProgress: number // Percentage
label?: string
hasError?: boolean
}
export default function LoadingBranded({
loadProgress,
label,
hasError = false,
}: LoadingBrandedTypes) {
return (
<>
<div className="loading-screen-brand-container">
<div
className="loading-screen-brand"
style={{ height: `${loadProgress}%` }}
/>
</div>
{!hasError && (
<div className="h3 loading-screen-label" aria-live="polite">
{label}
<span className="loading-screen-ellip" aria-hidden="true">
.
</span>
<span className="loading-screen-ellip" aria-hidden="true">
.
</span>
<span className="loading-screen-ellip" aria-hidden="true">
.
</span>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import OLSpinner, {
OLSpinnerSize,
} from '@/features/ui/components/ol/ol-spinner'
import classNames from 'classnames'
function LoadingSpinner({
align,
delay = 0,
loadingText,
size = 'sm',
className,
}: {
align?: 'left' | 'center'
delay?: 0 | 500 // 500 is our standard delay
loadingText?: string
size?: OLSpinnerSize
className?: string
}) {
const { t } = useTranslation()
const [show, setShow] = useState(false)
useEffect(() => {
// Ensure that spinner is displayed immediately if delay is 0
if (delay === 0) {
setShow(true)
return
}
const timer = window.setTimeout(() => {
setShow(true)
}, delay)
return () => {
window.clearTimeout(timer)
}
}, [delay])
if (!show) {
return null
}
return (
<div
className={classNames(
'loading',
className,
align === 'left' ? 'align-items-start' : 'align-items-center'
)}
>
<OLSpinner size={size} />
&nbsp;
{loadingText || `${t('loading')}`}
</div>
)
}
export default LoadingSpinner
export function FullSizeLoadingSpinner({
delay = 0,
minHeight,
loadingText,
size = 'sm',
className,
}: {
delay?: 0 | 500
minHeight?: string
loadingText?: string
size?: OLSpinnerSize
className?: string
}) {
return (
<div
className={classNames('full-size-loading-spinner-container', className)}
style={{ minHeight }}
>
<LoadingSpinner size={size} loadingText={loadingText} delay={delay} />
</div>
)
}

View File

@@ -0,0 +1,27 @@
// window location-related functions in a separate module so they can be mocked/stubbed in tests
export const location = {
get href() {
// eslint-disable-next-line no-restricted-syntax
return window.location.href
},
assign(url) {
// eslint-disable-next-line no-restricted-syntax
window.location.assign(url)
},
replace(url) {
// eslint-disable-next-line no-restricted-syntax
window.location.replace(url)
},
reload() {
// eslint-disable-next-line no-restricted-syntax
window.location.reload()
},
setHash(hash) {
window.location.hash = hash
},
toString() {
// eslint-disable-next-line no-restricted-syntax
return window.location.toString()
},
}

View File

@@ -0,0 +1,51 @@
import classNames from 'classnames'
import React from 'react'
import unfilledIconTypes from '../../../fonts/material-symbols/unfilled-symbols.mjs'
export type AvailableUnfilledIcon = (typeof unfilledIconTypes)[number]
type BaseIconProps = React.ComponentProps<'i'> & {
accessibilityLabel?: string
modifier?: string
size?: '2x'
}
type FilledIconProps = BaseIconProps & {
type: string
unfilled?: false
}
type UnfilledIconProps = BaseIconProps & {
type: AvailableUnfilledIcon
unfilled: true
}
type IconProps = FilledIconProps | UnfilledIconProps
function MaterialIcon({
type,
className,
accessibilityLabel,
modifier,
size,
unfilled,
...rest
}: IconProps) {
const iconClassName = classNames('material-symbols', className, modifier, {
[`size-${size}`]: size,
unfilled,
})
return (
<>
<span className={iconClassName} aria-hidden="true" {...rest}>
{type}
</span>
{accessibilityLabel && (
<span className="visually-hidden">{accessibilityLabel}</span>
)}
</>
)
}
export default MaterialIcon

View File

@@ -0,0 +1,145 @@
import {
Dropdown,
DropdownMenu,
DropdownToggle,
} from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { FC, forwardRef, useCallback } from 'react'
import classNames from 'classnames'
import { useNestableDropdown } from '@/shared/hooks/use-nestable-dropdown'
import { NestableDropdownContextProvider } from '@/shared/context/nestable-dropdown-context'
import { AnchorProps } from 'react-bootstrap-5'
import MaterialIcon from '../material-icon'
import { DropdownMenuProps } from '@/features/ui/components/types/dropdown-menu-props'
type MenuBarDropdownProps = {
title: string
id: string
className?: string
align?: 'start' | 'end'
}
export const MenuBarDropdown: FC<MenuBarDropdownProps> = ({
title,
children,
id,
className,
align = 'start',
}) => {
const { menuId, selected, setSelected } = useNestableDropdown()
const onToggle = useCallback(
show => {
setSelected(show ? id : null)
},
[id, setSelected]
)
const onHover = useCallback(() => {
setSelected(prev => {
if (prev === null) {
return null
}
return id
})
}, [id, setSelected])
return (
<Dropdown
show={selected === id}
align={align}
onToggle={onToggle}
autoClose
>
<DropdownToggle
id={`${menuId}-${id}`}
variant="secondary"
className={classNames(className, 'menu-bar-toggle')}
onMouseEnter={onHover}
>
{title}
</DropdownToggle>
<NestableDropdownMenu renderOnMount id={`${menuId}-${id}`}>
{children}
</NestableDropdownMenu>
</Dropdown>
)
}
const NestableDropdownMenu: FC<DropdownMenuProps & { id: string }> = ({
children,
id,
...props
}) => {
return (
<DropdownMenu {...props}>
<NestableDropdownContextProvider id={id}>
{children}
</NestableDropdownContextProvider>
</DropdownMenu>
)
}
const NestedDropdownToggle: FC = forwardRef<HTMLAnchorElement, AnchorProps>(
function NestedDropdownToggle(
{ children, className, onMouseEnter, id },
ref
) {
return (
// eslint-disable-next-line jsx-a11y/anchor-is-valid
<a
id={id}
href="#"
ref={ref}
onMouseEnter={onMouseEnter}
onClick={onMouseEnter}
className={classNames(
className,
'nested-dropdown-toggle',
'dropdown-item'
)}
>
{children}
<MaterialIcon type="chevron_right" />
</a>
)
}
)
export const NestedMenuBarDropdown: FC<{ id: string; title: string }> = ({
children,
id,
title,
}) => {
const { menuId, selected, setSelected } = useNestableDropdown()
const select = useCallback(() => {
setSelected(id)
}, [id, setSelected])
const onToggle = useCallback(
show => {
setSelected(show ? id : null)
},
[setSelected, id]
)
const active = selected === id
return (
<Dropdown
align="start"
drop="end"
show={active}
autoClose
onToggle={onToggle}
>
<DropdownToggle
id={`${menuId}-${id}`}
onMouseEnter={select}
className={classNames({ 'nested-dropdown-toggle-shown': active })}
as={NestedDropdownToggle}
>
{title}
</DropdownToggle>
<NestableDropdownMenu renderOnMount id={`${menuId}-${id}`}>
{children}
</NestableDropdownMenu>
</Dropdown>
)
}

View File

@@ -0,0 +1,41 @@
import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import { useNestableDropdown } from '@/shared/hooks/use-nestable-dropdown'
import { MouseEventHandler, ReactNode } from 'react'
type MenuBarOptionProps = {
title: string
onClick?: MouseEventHandler
disabled?: boolean
trailingIcon?: ReactNode
href?: string
target?: string
rel?: string
}
export const MenuBarOption = ({
title,
onClick,
href,
disabled,
trailingIcon,
target,
rel,
}: MenuBarOptionProps) => {
const { setSelected } = useNestableDropdown()
return (
<DropdownListItem>
<DropdownItem
onMouseEnter={() => setSelected(null)}
onClick={onClick}
disabled={disabled}
trailingIcon={trailingIcon}
href={href}
rel={rel}
target={target}
>
{title}
</DropdownItem>
</DropdownListItem>
)
}

View File

@@ -0,0 +1,16 @@
import { NestableDropdownContextProvider } from '@/shared/context/nestable-dropdown-context'
import { FC, HTMLProps } from 'react'
export const MenuBar: FC<HTMLProps<HTMLDivElement> & { id: string }> = ({
children,
id,
...props
}) => {
return (
<div {...props}>
<NestableDropdownContextProvider id={id}>
{children}
</NestableDropdownContextProvider>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import Notification, {
NotificationProps,
} from '@/shared/components/notification'
import { useEffect } from 'react'
function elementIsInView(el: HTMLElement) {
const scroll = window.scrollY
const boundsTop = el.getBoundingClientRect().top + scroll
const viewport = {
top: scroll,
bottom: scroll + window.innerHeight,
}
const bounds = {
top: boundsTop,
bottom: boundsTop + el.clientHeight,
}
return (
(bounds.bottom >= viewport.top && bounds.bottom <= viewport.bottom) ||
(bounds.top <= viewport.bottom && bounds.top >= viewport.top)
)
}
function NotificationScrolledTo({ ...props }: NotificationProps) {
useEffect(() => {
if (props.id) {
const alert = document.getElementById(props.id)
if (alert && !elementIsInView(alert)) {
alert.scrollIntoView({ behavior: 'smooth' })
}
}
}, [props])
const notificationProps = { ...props }
if (!notificationProps.className) {
notificationProps.className = ''
}
notificationProps.className = `${notificationProps.className} notification-with-scroll-margin`
return (
<div className="notification-list">
<Notification {...notificationProps} />
</div>
)
}
export default NotificationScrolledTo

View File

@@ -0,0 +1,126 @@
// to be kept in sync with app/views/_mixins/notification.pug
import classNames from 'classnames'
import React, { ReactElement, useState } from 'react'
import { useTranslation } from 'react-i18next'
import MaterialIcon from './material-icon'
export type NotificationType =
| 'info'
| 'success'
| 'warning'
| 'error'
| 'offer'
export type NotificationProps = {
action?: React.ReactElement
ariaLive?: 'polite' | 'off' | 'assertive'
className?: string
content: React.ReactNode
customIcon?: React.ReactElement | null
disclaimer?: React.ReactElement | string
isDismissible?: boolean
isActionBelowContent?: boolean
onDismiss?: () => void
title?: string
type: NotificationType
id?: string
}
export function NotificationIcon({
notificationType,
customIcon,
}: {
notificationType: NotificationType
customIcon?: ReactElement
}) {
let icon = <MaterialIcon type="info" />
if (customIcon) {
icon = customIcon
} else if (notificationType === 'success') {
icon = <MaterialIcon type="check_circle" />
} else if (notificationType === 'warning') {
icon = <MaterialIcon type="warning" />
} else if (notificationType === 'error') {
icon = <MaterialIcon type="error" />
} else if (notificationType === 'offer') {
icon = <MaterialIcon type="campaign" />
}
return <div className="notification-icon">{icon}</div>
}
function Notification({
action,
ariaLive,
className = '',
content,
customIcon,
disclaimer,
isActionBelowContent,
isDismissible,
onDismiss,
title,
type,
id,
}: NotificationProps) {
type = type || 'info'
const { t } = useTranslation()
const [show, setShow] = useState(true)
const notificationClassName = classNames(
'notification',
`notification-type-${type}`,
isActionBelowContent ? 'notification-cta-below-content' : '',
className
)
const handleDismiss = () => {
setShow(false)
if (onDismiss) onDismiss()
}
// return null
if (!show) {
return null
}
return (
<div
className={notificationClassName}
aria-live={ariaLive || 'off'}
role="alert"
id={id}
>
{customIcon !== null && (
<NotificationIcon notificationType={type} customIcon={customIcon} />
)}
<div className="notification-content-and-cta">
<div className="notification-content">
{title && (
<p>
<b>{title}</b>
</p>
)}
{content}
</div>
{action && <div className="notification-cta">{action}</div>}
{disclaimer && (
<div className="notification-disclaimer">{disclaimer}</div>
)}
</div>
{isDismissible && (
<div className="notification-close-btn">
<button aria-label={t('close')} onClick={handleDismiss}>
<MaterialIcon type="close" />
</button>
</div>
)}
</div>
)
}
export default Notification

View File

@@ -0,0 +1,170 @@
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
function Pagination({ currentPage, totalPages, handlePageClick }) {
const { t } = useTranslation()
const maxOtherPageButtons = useMemo(() => {
let maxOtherPageButtons = 4 // does not include current page, prev/next buttons
if (totalPages < maxOtherPageButtons + 1) {
maxOtherPageButtons = totalPages - 1
}
return maxOtherPageButtons
}, [totalPages])
const pageButtons = useMemo(() => {
const result = []
let nextPage = currentPage + 1
let prevPage = currentPage - 1
function calcPages() {
if (nextPage && nextPage <= totalPages) {
result.push(nextPage)
nextPage++
} else {
nextPage = undefined
}
if (prevPage && prevPage > 0) {
result.push(prevPage)
prevPage--
} else {
prevPage = undefined
}
}
while (result.length < maxOtherPageButtons) {
calcPages()
}
result.push(currentPage) // wait until prev/next calculated to add current
result.sort((a, b) => a - b) // sort numerically
return result
}, [currentPage, totalPages, maxOtherPageButtons])
const morePrevPages = useMemo(() => {
return pageButtons[0] !== 1 && currentPage - maxOtherPageButtons / 2 > 1
}, [pageButtons, currentPage, maxOtherPageButtons])
const moreNextPages = useMemo(() => {
return pageButtons[pageButtons.length - 1] < totalPages
}, [pageButtons, totalPages])
return (
<nav role="navigation" aria-label={t('pagination_navigation')}>
<ul className="pagination">
{currentPage > 1 && (
<li>
<button
onClick={event => handlePageClick(event, currentPage - 1)}
aria-label={t('go_prev_page')}
>
«
</button>
</li>
)}
{morePrevPages && (
<li>
<span className="ellipses"></span>
</li>
)}
{pageButtons.map(page => (
<PaginationItem
key={`prev-page-${page}`}
page={page}
currentPage={currentPage}
handlePageClick={handlePageClick}
/>
))}
{moreNextPages && (
<li>
<span className="ellipses"></span>
</li>
)}
{currentPage < totalPages && (
<li>
<button
onClick={event => handlePageClick(event, currentPage + 1)}
aria-label={t('go_next_page')}
>
»
</button>
</li>
)}
</ul>
</nav>
)
}
function PaginationItem({ page, currentPage, handlePageClick }) {
const { t } = useTranslation()
const itemClassName = classNames({ active: currentPage === page })
const ariaCurrent = currentPage === page
const ariaLabel =
currentPage === page ? t('page_current', { page }) : t('go_page', { page })
return (
<li className={itemClassName}>
<button
aria-current={ariaCurrent}
onClick={event => handlePageClick(event, page)}
aria-label={ariaLabel}
>
{page}
</button>
</li>
)
}
function isPositiveNumber(value) {
return typeof value === 'number' && value > 0
}
function isCurrentPageWithinTotalPages(currentPage, totalPages) {
return currentPage <= totalPages
}
Pagination.propTypes = {
currentPage: function (props, propName, componentName) {
if (
!isPositiveNumber(props[propName]) ||
!isCurrentPageWithinTotalPages(props.currentPage, props.totalPages)
) {
return new Error(
'Invalid prop `' +
propName +
'` supplied to' +
' `' +
componentName +
'`. Validation failed.'
)
}
},
totalPages: function (props, propName, componentName) {
if (!isPositiveNumber(props[propName])) {
return new Error(
'Invalid prop `' +
propName +
'` supplied to' +
' `' +
componentName +
'`. Validation failed.'
)
}
},
handlePageClick: PropTypes.func.isRequired,
}
PaginationItem.propTypes = {
currentPage: PropTypes.number.isRequired,
page: PropTypes.number.isRequired,
handlePageClick: PropTypes.func.isRequired,
}
export default Pagination

View File

@@ -0,0 +1,38 @@
import { FC } from 'react'
import SplitTestBadge from '@/shared/components/split-test-badge'
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
export const PanelHeading: FC<{
title: string
splitTestName?: string
children?: React.ReactNode
handleClose(): void
}> = ({ title, splitTestName, children, handleClose }) => {
const { t } = useTranslation()
return (
<div className="panel-heading">
<div className="panel-heading-label">
<span>{title}</span>
{splitTestName && (
<SplitTestBadge
splitTestName={splitTestName}
displayOnVariants={['enabled']}
/>
)}
</div>
{children}
<button
type="button"
className="btn panel-heading-close-button"
aria-label={t('close')}
onClick={handleClose}
>
<MaterialIcon type="close" />
</button>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { MergeAndOverride } from '../../../../types/utils'
type PolymorphicComponentOwnProps<E extends React.ElementType> = {
as?: E
}
export type PolymorphicComponentProps<E extends React.ElementType> =
MergeAndOverride<React.ComponentProps<E>, PolymorphicComponentOwnProps<E>>
function PolymorphicComponent<E extends React.ElementType = 'div'>({
as,
...props
}: PolymorphicComponentProps<E>) {
const Component = as || 'div'
return <Component {...props} />
}
export default PolymorphicComponent

View File

@@ -0,0 +1,23 @@
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import Icon from './icon'
function Processing({ isProcessing }) {
const { t } = useTranslation()
if (isProcessing) {
return (
<div aria-live="polite">
{t('processing')}&nbsp;
<Icon type="refresh" fw spin />
</div>
)
} else {
return <></>
}
}
Processing.propTypes = {
isProcessing: PropTypes.bool.isRequired,
}
export default Processing

View File

@@ -0,0 +1,42 @@
import React from 'react'
type RadioChipProps<ValueType> = {
checked?: boolean
disabled?: boolean
name: string
onChange: (value: ValueType) => void
required?: boolean
label: React.ReactElement | string
value: ValueType
}
const RadioChip = <T extends string>({
checked,
disabled,
name,
onChange,
label,
required,
value,
}: RadioChipProps<T>) => {
const handleChange = () => {
onChange(value)
}
return (
<label className="radio-chip" data-disabled={disabled ? 'true' : undefined}>
<input
checked={checked}
disabled={disabled}
name={name}
onChange={handleChange}
type="radio"
required={required}
value={value}
/>
{label}
</label>
)
}
export default RadioChip

View File

@@ -0,0 +1,36 @@
import ReCAPTCHA from 'react-google-recaptcha'
import getMeta from '@/utils/meta'
import { ExposedSettings } from '../../../../types/exposed-settings'
interface ReCaptcha2Props
extends Pick<React.ComponentProps<typeof ReCAPTCHA>, 'onChange'> {
page: keyof ExposedSettings['recaptchaDisabled']
recaptchaRef: React.LegacyRef<ReCAPTCHA>
}
export function ReCaptcha2({
page: site,
onChange,
recaptchaRef,
}: ReCaptcha2Props) {
const { recaptchaSiteKey, recaptchaDisabled } = getMeta('ol-ExposedSettings')
if (!recaptchaSiteKey) {
return null
}
if (site && recaptchaDisabled[site]) {
return null
}
if (process.env.NODE_ENV === 'development' && window.Cypress) {
return null // Disable captcha for E2E tests in dev-env.
}
return (
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey={recaptchaSiteKey}
onChange={onChange}
badge="inline"
/>
)
}

View File

@@ -0,0 +1,32 @@
import { Trans } from 'react-i18next'
export default function RecaptchaConditions() {
// the component link children below will be overwritten by the translation string
return (
<div className="recaptcha-branding">
<Trans
i18nKey="recaptcha_conditions"
components={{
1: (
<a
rel="noopener noreferrer"
target="_blank"
href="https://policies.google.com/privacy"
>
Privacy Policy
</a>
),
2: (
<a
rel="noopener noreferrer"
target="_blank"
href="https://policies.google.com/terms"
>
Terms of Service
</a>
),
}}
/>
</div>
)
}

View File

@@ -0,0 +1,220 @@
/* eslint-disable jsx-a11y/label-has-for */
/* eslint-disable jsx-a11y/label-has-associated-control */
import {
useRef,
useEffect,
KeyboardEventHandler,
useCallback,
ReactNode,
useState,
} from 'react'
import classNames from 'classnames'
import { useSelect } from 'downshift'
import { useTranslation } from 'react-i18next'
import { Form, Spinner } from 'react-bootstrap-5'
import FormControl from '@/features/ui/components/bootstrap-5/form/form-control'
import MaterialIcon from '@/shared/components/material-icon'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
export type SelectProps<T> = {
// The items rendered as dropdown options.
items: T[]
// Stringifies an item of type T. The resulting string is rendered as a dropdown option.
itemToString: (item: T | null | undefined) => string
// Caption for the dropdown.
label?: ReactNode
// Attribute used to identify the component inside a Form. This name is used to
// retrieve FormData when the form is submitted. The value of the FormData entry
// is the string returned by `itemToString(selectedItem)`.
name?: string
// Hint text displayed in the initial render.
defaultText?: string
// Initial selected item, displayed in the initial render. When both `defaultText`
// and `defaultItem` are set the latter is ignored.
defaultItem?: T | null
// Stringifies an item. The resulting string is rendered as a subtitle in a dropdown option.
itemToSubtitle?: (item: T | null | undefined) => string
// Stringifies an item. The resulting string is rendered as a React `key` for each item.
itemToKey: (item: T) => string
// Callback invoked after the selected item is updated.
onSelectedItemChanged?: (item: T | null | undefined) => void
// Optionally directly control the selected item.
selected?: T | null
// When `true` item selection is disabled.
disabled?: boolean
// Determine which items should be disabled
itemToDisabled?: (item: T | null | undefined) => boolean
// When `true` displays an "Optional" subtext after the `label` caption.
optionalLabel?: boolean
// When `true` displays a spinner next to the `label` caption.
loading?: boolean
// Show a checkmark next to the selected item
selectedIcon?: boolean
// testId for the input element
dataTestId?: string
}
export const Select = <T,>({
items,
itemToString = item => (item === null ? '' : String(item)),
label,
name,
defaultText = 'Items',
defaultItem,
itemToSubtitle,
itemToKey,
onSelectedItemChanged,
selected,
disabled = false,
itemToDisabled,
optionalLabel = false,
loading = false,
selectedIcon = false,
dataTestId,
}: SelectProps<T>) => {
const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
defaultItem
)
const { t } = useTranslation()
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getItemProps,
highlightedIndex,
openMenu,
closeMenu,
} = useSelect({
items: items ?? [],
itemToString,
selectedItem: selected || defaultItem,
onSelectedItemChange: changes => {
if (onSelectedItemChanged) {
onSelectedItemChanged(changes.selectedItem)
}
setSelectedItem(changes.selectedItem)
},
})
useEffect(() => {
setSelectedItem(selected)
}, [selected])
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!name || !rootRef.current) return
const parentForm: HTMLFormElement | null | undefined =
rootRef.current?.closest('form')
if (!parentForm) return
function handleFormDataEvent(event: FormDataEvent) {
const data = event.formData
const key = name as string // can't be undefined due to early exit in the effect
if (selectedItem || defaultItem) {
data.append(key, itemToString(selectedItem || defaultItem))
}
}
parentForm.addEventListener('formdata', handleFormDataEvent)
return () => {
parentForm.removeEventListener('formdata', handleFormDataEvent)
}
}, [name, itemToString, selectedItem, defaultItem])
const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {
if (event.key === 'Escape' && isOpen) {
event.stopPropagation()
closeMenu()
}
}
const onKeyDown: KeyboardEventHandler<HTMLButtonElement> = useCallback(
event => {
if ((event.key === 'Enter' || event.key === ' ') && !isOpen) {
event.preventDefault()
;(event.nativeEvent as any).preventDownshiftDefault = true
openMenu()
}
},
[isOpen, openMenu]
)
let value: string | undefined
if (selectedItem || defaultItem) {
value = itemToString(selectedItem || defaultItem)
} else {
value = defaultText
}
return (
<div className="select-wrapper" ref={rootRef}>
{label ? (
<Form.Label {...getLabelProps()}>
{label}{' '}
{optionalLabel && (
<span className="fw-normal">({t('optional')})</span>
)}{' '}
{loading && (
<span data-testid="spinner">
<Spinner
animation="border"
aria-hidden="true"
as="span"
role="status"
size="sm"
/>
</span>
)}
</Form.Label>
) : null}
<FormControl
data-testid={dataTestId}
{...getToggleButtonProps({
disabled,
onKeyDown,
className: 'select-trigger',
})}
value={value}
readOnly
append={
<MaterialIcon
type={isOpen ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
className="align-text-bottom"
/>
}
/>
<ul
{...getMenuProps({ disabled, onKeyDown: handleMenuKeyDown })}
className={classNames('dropdown-menu w-100', { show: isOpen })}
>
{isOpen &&
items?.map((item, index) => {
const isDisabled = itemToDisabled && itemToDisabled(item)
return (
<li role="none" key={itemToKey(item)}>
<DropdownItem
as="button"
className={classNames({
'select-highlighted': highlightedIndex === index,
})}
active={selectedItem === item}
trailingIcon={
selectedIcon && selectedItem === item ? 'check' : undefined
}
description={
itemToSubtitle ? itemToSubtitle(item) : undefined
}
{...getItemProps({ item, index, disabled: isDisabled })}
>
{itemToString(item)}
</DropdownItem>
</li>
)
})}
</ul>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { useSplitTestContext } from '../context/split-test-context'
import BetaBadge from './beta-badge'
type TooltipProps = {
id?: string
className?: string
}
type SplitTestBadgeProps = {
splitTestName: string
displayOnVariants: string[]
tooltip?: TooltipProps
}
export default function SplitTestBadge({
splitTestName,
displayOnVariants,
tooltip = {},
}: SplitTestBadgeProps) {
const { splitTestVariants, splitTestInfo } = useSplitTestContext()
const testInfo = splitTestInfo[splitTestName]
if (!testInfo) {
return null
}
const variant = splitTestVariants[splitTestName]
if (!variant || !displayOnVariants.includes(variant)) {
return null
}
return (
<BetaBadge
tooltip={{
id: tooltip.id || `${splitTestName}-badge-tooltip`,
className: `split-test-badge-tooltip ${tooltip.className}`,
text: testInfo.badgeInfo?.tooltipText || (
<>
We are testing this new feature.
<br />
Click to give feedback
</>
),
}}
phase={testInfo.phase}
link={{
href: testInfo.badgeInfo?.url?.length
? testInfo.badgeInfo?.url
: undefined,
}}
/>
)
}

View File

@@ -0,0 +1,54 @@
import { MouseEventHandler, useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { startFreeTrial } from '@/main/account-upgrade'
import * as eventTracking from '../../infrastructure/event-tracking'
import OLButton from '@/features/ui/components/ol/ol-button'
type StartFreeTrialButtonProps = {
source: string
variant?: string
buttonProps?: React.ComponentProps<typeof OLButton>
children?: React.ReactNode
handleClick?: MouseEventHandler<typeof OLButton>
}
export default function StartFreeTrialButton({
buttonProps = {
variant: 'secondary',
},
children,
handleClick,
source,
variant,
}: StartFreeTrialButtonProps) {
const { t } = useTranslation()
useEffect(() => {
const eventSegmentation: { [key: string]: unknown } = {
'paywall-type': source,
}
if (variant) {
eventSegmentation.variant = variant
}
eventTracking.sendMB('paywall-prompt', eventSegmentation)
}, [source, variant])
const onClick = useCallback(
event => {
event.preventDefault()
if (handleClick) {
handleClick(event)
}
startFreeTrial(source, variant)
},
[handleClick, source, variant]
)
return (
<OLButton {...buttonProps} onClick={onClick}>
{children || t('start_free_trial')}
</OLButton>
)
}

View File

@@ -0,0 +1,27 @@
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
export function Stepper({ steps, active }: { steps: number; active: number }) {
const { t } = useTranslation()
return (
<div
className="stepper"
role="progressbar"
aria-label={t('progress_bar_percentage')}
aria-valuenow={active + 1}
aria-valuemax={steps}
tabIndex={0}
>
{Array.from({ length: steps }).map((_, i) => (
<div
key={i}
className={classNames({
step: true,
active: i === active,
completed: i < active,
})}
/>
))}
</div>
)
}

View File

@@ -0,0 +1,26 @@
import classNames from 'classnames'
type SwitchProps = {
onChange: () => void
checked: boolean
disabled?: boolean
}
function Switch({ onChange, checked, disabled = false }: SwitchProps) {
return (
<label className={classNames('switch-input', { disabled })}>
<input
className="invisible-input"
type="checkbox"
role="switch"
autoComplete="off"
onChange={onChange}
checked={checked}
disabled={disabled}
/>
<span className="switch" />
</label>
)
}
export default Switch

View File

@@ -0,0 +1,29 @@
import Close from './close'
import usePersistedState from '../hooks/use-persisted-state'
type SystemMessageProps = {
id: string
children: React.ReactNode
}
function SystemMessage({ id, children }: SystemMessageProps) {
const [hidden, setHidden] = usePersistedState(
`systemMessage.hide.${id}`,
false
)
if (hidden) {
return null
}
return (
<li className="system-message">
{id !== 'protected' ? (
<Close onDismiss={() => setHidden(true)} variant="dark" />
) : null}
{children}
</li>
)
}
export default SystemMessage

View File

@@ -0,0 +1,50 @@
import { useEffect } from 'react'
import SystemMessage from './system-message'
import TranslationMessage from './translation-message'
import useAsync from '../hooks/use-async'
import { getJSON } from '@/infrastructure/fetch-json'
import getMeta from '../../utils/meta'
import { SystemMessage as TSystemMessage } from '../../../../types/system-message'
import { debugConsole } from '@/utils/debugging'
const MESSAGE_POLL_INTERVAL = 15 * 60 * 1000
function SystemMessages() {
const { data: messages, runAsync } = useAsync<TSystemMessage[]>()
const suggestedLanguage = getMeta('ol-suggestedLanguage')
useEffect(() => {
const pollMessages = () => {
// Ignore polling if tab is hidden or browser is offline
if (document.hidden || !navigator.onLine) {
return
}
runAsync(getJSON('/system/messages')).catch(debugConsole.error)
}
pollMessages()
const interval = setInterval(pollMessages, MESSAGE_POLL_INTERVAL)
return () => {
clearInterval(interval)
}
}, [runAsync])
if (!messages?.length && !suggestedLanguage) {
return null
}
return (
<ul className="system-messages">
{messages?.map((message, idx) => (
<SystemMessage key={idx} id={message._id}>
{message.content}
</SystemMessage>
))}
{suggestedLanguage ? <TranslationMessage /> : null}
</ul>
)
}
export default SystemMessages

View File

@@ -0,0 +1,40 @@
import { Trans, useTranslation } from 'react-i18next'
import Close from './close'
import usePersistedState from '../hooks/use-persisted-state'
import getMeta from '../../utils/meta'
function TranslationMessage() {
const { t } = useTranslation()
const [hidden, setHidden] = usePersistedState('hide-i18n-notification', false)
const config = getMeta('ol-suggestedLanguage')!
const currentUrl = getMeta('ol-currentUrl')
if (hidden) {
return null
}
return (
<li className="system-message">
<Close onDismiss={() => setHidden(true)} />
<div className="text-center">
<a href={config.url + currentUrl}>
<Trans
i18nKey="click_here_to_view_sl_in_lng"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ lngName: config.lngName }}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
<img
className="ms-1"
src={config.imgUrl}
alt={t('country_flag', { country: config.lngName })}
aria-hidden
/>
</a>
</div>
</li>
)
}
export default TranslationMessage

View File

@@ -0,0 +1,48 @@
import MaterialIcon from '@/shared/components/material-icon'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
function Check() {
return <MaterialIcon type="check" />
}
function UpgradeBenefits() {
const { t } = useTranslation()
return (
<ul className="list-unstyled upgrade-benefits">
<li>
<Check />
&nbsp;
{t('unlimited_projects')}
</li>
<li>
<Check />
&nbsp;
{t('collabs_per_proj', { collabcount: 'Multiple' })}
</li>
<li>
<Check />
&nbsp;
{t('full_doc_history')}
</li>
<li>
<Check />
&nbsp;
{t('sync_to_dropbox')}
</li>
<li>
<Check />
&nbsp;
{t('sync_to_github')}
</li>
<li>
<Check />
&nbsp;
{t('compile_larger_projects')}
</li>
</ul>
)
}
export default memo(UpgradeBenefits)

View File

@@ -0,0 +1,177 @@
import OLBadge from '@/features/ui/components/ol/ol-badge'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLCloseButton from '@/features/ui/components/ol/ol-close-button'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLRow from '@/features/ui/components/ol/ol-row'
import MaterialIcon from '@/shared/components/material-icon'
import { PropsWithChildren } from 'react'
import { Container } from 'react-bootstrap-5'
import { Trans, useTranslation } from 'react-i18next'
type IconListItemProps = PropsWithChildren<{
icon: string
}>
function IconListItem({ icon, children }: IconListItemProps) {
return (
<li className="d-flex align-items-center">
<div className="flex-shrink-0">
<MaterialIcon type={icon} />
</div>
<div className="flex-grow-1 ms-2">{children}</div>
</li>
)
}
type PlansLinkProps = {
itmCampaign: string
onClick?: React.MouseEventHandler<HTMLAnchorElement>
}
function PlansLink({
children,
itmCampaign,
onClick,
}: PropsWithChildren<PlansLinkProps>) {
return (
<a
key="compare_plans_link"
href={`/user/subscription/choose-your-plan?itm-campaign=${itmCampaign}`}
target="_blank"
rel="noreferrer"
onClick={onClick}
>
{children}
<MaterialIcon type="open_in_new" />
</a>
)
}
type UpgradePromptProps = {
title: string
summary: string
onClose: () => void
planPricing: { student: string; standard: string }
itmCampaign: string
isStudent?: boolean
onClickInfoLink?: React.MouseEventHandler<HTMLAnchorElement>
onClickPaywall?: React.MouseEventHandler<HTMLAnchorElement>
}
export function UpgradePrompt({
title,
summary,
onClose,
planPricing,
itmCampaign,
isStudent = false,
onClickInfoLink,
onClickPaywall,
}: UpgradePromptProps) {
const { t } = useTranslation()
const planPrice = isStudent ? planPricing.student : planPricing.standard
const planCode = isStudent
? 'student_free_trial_7_days'
: 'collaborator_free_trial_7_days'
return (
<Container className="upgrade-prompt">
<OLRow className="justify-content-end">
<OLCloseButton onClick={() => onClose()} />
</OLRow>
<OLRow className="text-center">
<h2 className="my-0 upgrade-prompt-title">{title}</h2>
<p className="upgrade-prompt-summary">{summary}</p>
</OLRow>
<OLRow className="g-3">
<OLCol md={6} className="upgrade-prompt-card-container">
<div className="g-0 upgrade-prompt-card upgrade-prompt-card-premium">
<OLRow className="justify-content-between">
<OLCol>
<h3>{isStudent ? t('student') : t('standard')}</h3>
</OLCol>
<OLCol xs="auto">
<OLBadge className="badge-premium-gradient">
{t('recommended')}
</OLBadge>
</OLCol>
</OLRow>
<OLRow>
<p className="upgrade-prompt-price">
<span className="upgrade-prompt-price-number">{planPrice}</span>{' '}
{t('per_month')}
</p>
</OLRow>
<OLRow>
<ul className="upgrade-prompt-list">
<IconListItem icon="hourglass_top">
{t('12x_more_compile_time')}
</IconListItem>
<IconListItem icon="group_add">
{t('collabs_per_proj', { collabcount: isStudent ? 6 : 10 })}
</IconListItem>
<IconListItem icon="history">
{t('unlimited_document_history')}
</IconListItem>
</ul>
</OLRow>
<OLRow className="mt-auto">
<a
className="btn btn-premium"
href={`/user/subscription/new?planCode=${planCode}&itm-campaign=${itmCampaign}`}
onClick={onClickPaywall}
target="_blank"
rel="noreferrer"
>
{t('try_for_free')}
</a>
</OLRow>
</div>
</OLCol>
<OLCol md={6} className="upgrade-prompt-card-container">
<div className="g-0 upgrade-prompt-card upgrade-prompt-card-free">
<OLRow>
<h3>{t('free')}</h3>
</OLRow>
<OLRow>
<p className="upgrade-prompt-price">
{/* Invisible span here to hold the correct height to match a card with a price */}
<span className="upgrade-prompt-price-number invisible" />
{t('your_current_plan')}
</p>
</OLRow>
<OLRow>
<ul className="upgrade-prompt-list">
<IconListItem icon="hourglass_bottom">
{t('basic_compile_time')}
</IconListItem>
<IconListItem icon="person">
{t('collabs_per_proj_single', { collabcount: 1 })}
</IconListItem>
<IconListItem icon="history_off">
{t('limited_document_history')}
</IconListItem>
</ul>
</OLRow>
<OLRow className="mt-auto">
<OLButton variant="secondary" onClick={() => onClose()}>
{t('continue_with_free_plan')}
</OLButton>
</OLRow>
</div>
</OLCol>
</OLRow>
<OLRow className="text-center">
<p className="upgrade-prompt-all-plans">
{/* eslint-disable react/jsx-key */}
<Trans
i18nKey="compare_all_plans"
components={[
<PlansLink onClick={onClickInfoLink} itmCampaign={itmCampaign} />,
]}
/>
{/* eslint-disable react/jsx-key */}
</p>
</OLRow>
</Container>
)
}

View File

@@ -0,0 +1,503 @@
import { createContext, FC, useContext, useMemo } from 'react'
import { CompileContext, useLocalCompileContext } from './local-compile-context'
import useDetachStateWatcher from '../hooks/use-detach-state-watcher'
import useDetachAction from '../hooks/use-detach-action'
import useCompileTriggers from '../../features/pdf-preview/hooks/use-compile-triggers'
import { useLogEvents } from '@/features/pdf-preview/hooks/use-log-events'
export const DetachCompileContext = createContext<CompileContext | undefined>(
undefined
)
export const DetachCompileProvider: FC = ({ children }) => {
const localCompileContext = useLocalCompileContext()
if (!localCompileContext) {
throw new Error(
'DetachCompileProvider is only available inside LocalCompileProvider'
)
}
const {
animateCompileDropdownArrow: _animateCompileDropdownArrow,
autoCompile: _autoCompile,
clearingCache: _clearingCache,
clsiServerId: _clsiServerId,
codeCheckFailed: _codeCheckFailed,
compiling: _compiling,
deliveryLatencies: _deliveryLatencies,
draft: _draft,
editedSinceCompileStarted: _editedSinceCompileStarted,
error: _error,
fileList: _fileList,
hasChanges: _hasChanges,
hasShortCompileTimeout: _hasShortCompileTimeout,
highlights: _highlights,
isProjectOwner: _isProjectOwner,
lastCompileOptions: _lastCompileOptions,
logEntries: _logEntries,
logEntryAnnotations: _logEntryAnnotations,
pdfFile: _pdfFile,
pdfViewer: _pdfViewer,
position: _position,
rawLog: _rawLog,
setAnimateCompileDropdownArrow: _setAnimateCompileDropdownArrow,
setAutoCompile: _setAutoCompile,
setDraft: _setDraft,
setError: _setError,
setHasLintingError: _setHasLintingError,
setHighlights: _setHighlights,
setPosition: _setPosition,
setShowCompileTimeWarning: _setShowCompileTimeWarning,
setShowLogs: _setShowLogs,
toggleLogs: _toggleLogs,
setStopOnFirstError: _setStopOnFirstError,
setStopOnValidationError: _setStopOnValidationError,
showLogs: _showLogs,
showCompileTimeWarning: _showCompileTimeWarning,
stopOnFirstError: _stopOnFirstError,
stopOnValidationError: _stopOnValidationError,
stoppedOnFirstError: _stoppedOnFirstError,
uncompiled: _uncompiled,
validationIssues: _validationIssues,
firstRenderDone: _firstRenderDone,
cleanupCompileResult: _cleanupCompileResult,
recompileFromScratch: _recompileFromScratch,
setCompiling: _setCompiling,
startCompile: _startCompile,
stopCompile: _stopCompile,
setChangedAt: _setChangedAt,
clearCache: _clearCache,
syncToEntry: _syncToEntry,
recordAction: _recordAction,
} = localCompileContext
const [animateCompileDropdownArrow] = useDetachStateWatcher(
'animateCompileDropdownArrow',
_animateCompileDropdownArrow,
'detacher',
'detached'
)
const [autoCompile] = useDetachStateWatcher(
'autoCompile',
_autoCompile,
'detacher',
'detached'
)
const [clearingCache] = useDetachStateWatcher(
'clearingCache',
_clearingCache,
'detacher',
'detached'
)
const [clsiServerId] = useDetachStateWatcher(
'clsiServerId',
_clsiServerId,
'detacher',
'detached'
)
const [codeCheckFailed] = useDetachStateWatcher(
'codeCheckFailed',
_codeCheckFailed,
'detacher',
'detached'
)
const [compiling] = useDetachStateWatcher(
'compiling',
_compiling,
'detacher',
'detached'
)
const [deliveryLatencies] = useDetachStateWatcher(
'deliveryLatencies',
_deliveryLatencies,
'detacher',
'detached'
)
const [draft] = useDetachStateWatcher('draft', _draft, 'detacher', 'detached')
const [error] = useDetachStateWatcher('error', _error, 'detacher', 'detached')
const [fileList] = useDetachStateWatcher(
'fileList',
_fileList,
'detacher',
'detached'
)
const [hasChanges] = useDetachStateWatcher(
'hasChanges',
_hasChanges,
'detacher',
'detached'
)
const [hasShortCompileTimeout] = useDetachStateWatcher(
'hasShortCompileTimeout',
_hasShortCompileTimeout,
'detacher',
'detached'
)
const [highlights] = useDetachStateWatcher(
'highlights',
_highlights,
'detacher',
'detached'
)
const [isProjectOwner] = useDetachStateWatcher(
'isProjectOwner',
_isProjectOwner,
'detacher',
'detached'
)
const [lastCompileOptions] = useDetachStateWatcher(
'lastCompileOptions',
_lastCompileOptions,
'detacher',
'detached'
)
const [logEntries] = useDetachStateWatcher(
'logEntries',
_logEntries,
'detacher',
'detached'
)
const [logEntryAnnotations] = useDetachStateWatcher(
'logEntryAnnotations',
_logEntryAnnotations,
'detacher',
'detached'
)
const [pdfFile] = useDetachStateWatcher(
'pdfFile',
_pdfFile,
'detacher',
'detached'
)
const [pdfViewer] = useDetachStateWatcher(
'pdfViewer',
_pdfViewer,
'detacher',
'detached'
)
const [position] = useDetachStateWatcher(
'position',
_position,
'detacher',
'detached'
)
const [rawLog] = useDetachStateWatcher(
'rawLog',
_rawLog,
'detacher',
'detached'
)
const [showCompileTimeWarning] = useDetachStateWatcher(
'showCompileTimeWarning',
_showCompileTimeWarning,
'detacher',
'detached'
)
const [showLogs] = useDetachStateWatcher(
'showLogs',
_showLogs,
'detacher',
'detached'
)
const [stopOnFirstError] = useDetachStateWatcher(
'stopOnFirstError',
_stopOnFirstError,
'detacher',
'detached'
)
const [stopOnValidationError] = useDetachStateWatcher(
'stopOnValidationError',
_stopOnValidationError,
'detacher',
'detached'
)
const [stoppedOnFirstError] = useDetachStateWatcher(
'stoppedOnFirstError',
_stoppedOnFirstError,
'detacher',
'detached'
)
const [uncompiled] = useDetachStateWatcher(
'uncompiled',
_uncompiled,
'detacher',
'detached'
)
const [editedSinceCompileStarted] = useDetachStateWatcher(
'editedSinceCompileStarted',
_editedSinceCompileStarted,
'detacher',
'detached'
)
const [validationIssues] = useDetachStateWatcher(
'validationIssues',
_validationIssues,
'detacher',
'detached'
)
const setAnimateCompileDropdownArrow = useDetachAction(
'setAnimateCompileDropdownArrow',
_setAnimateCompileDropdownArrow,
'detached',
'detacher'
)
const setAutoCompile = useDetachAction(
'setAutoCompile',
_setAutoCompile,
'detached',
'detacher'
)
const setDraft = useDetachAction(
'setDraft',
_setDraft,
'detached',
'detacher'
)
const setError = useDetachAction(
'setError',
_setError,
'detacher',
'detached'
)
const setPosition = useDetachAction(
'setPosition',
_setPosition,
'detached',
'detacher'
)
const firstRenderDone = useDetachAction(
'firstRenderDone',
_firstRenderDone,
'detached',
'detacher'
)
const setHasLintingError = useDetachAction(
'setHasLintingError',
_setHasLintingError,
'detacher',
'detached'
)
const setHighlights = useDetachAction(
'setHighlights',
_setHighlights,
'detacher',
'detached'
)
const setShowCompileTimeWarning = useDetachAction(
'setShowCompileTimeWarning',
_setShowCompileTimeWarning,
'detached',
'detacher'
)
const setShowLogs = useDetachAction(
'setShowLogs',
_setShowLogs,
'detached',
'detacher'
)
const toggleLogs = useDetachAction(
'toggleLogs',
_toggleLogs,
'detached',
'detacher'
)
const setStopOnFirstError = useDetachAction(
'setStopOnFirstError',
_setStopOnFirstError,
'detached',
'detacher'
)
const setStopOnValidationError = useDetachAction(
'setStopOnValidationError',
_setStopOnValidationError,
'detached',
'detacher'
)
const cleanupCompileResult = useDetachAction(
'cleanupCompileResult',
_cleanupCompileResult,
'detached',
'detacher'
)
const recompileFromScratch = useDetachAction(
'recompileFromScratch',
_recompileFromScratch,
'detached',
'detacher'
)
const setCompiling = useDetachAction(
'setCompiling',
_setCompiling,
'detacher',
'detached'
)
const startCompile = useDetachAction(
'startCompile',
_startCompile,
'detached',
'detacher'
)
const stopCompile = useDetachAction(
'stopCompile',
_stopCompile,
'detached',
'detacher'
)
const setChangedAt = useDetachAction(
'setChangedAt',
_setChangedAt,
'detached',
'detacher'
)
const clearCache = useDetachAction(
'clearCache',
_clearCache,
'detached',
'detacher'
)
const syncToEntry = useDetachAction(
'sync-to-entry',
_syncToEntry,
'detached',
'detacher'
)
const recordAction = useDetachAction(
'record-action',
_recordAction,
'detached',
'detacher'
)
useCompileTriggers(startCompile, setChangedAt)
useLogEvents(setShowLogs)
const value = useMemo(
() => ({
animateCompileDropdownArrow,
autoCompile,
clearCache,
clearingCache,
clsiServerId,
codeCheckFailed,
compiling,
deliveryLatencies,
draft,
editedSinceCompileStarted,
error,
fileList,
hasChanges,
hasShortCompileTimeout,
highlights,
isProjectOwner,
lastCompileOptions,
logEntryAnnotations,
logEntries,
pdfDownloadUrl: pdfFile?.pdfDownloadUrl,
pdfFile,
pdfUrl: pdfFile?.pdfUrl,
pdfViewer,
position,
rawLog,
recompileFromScratch,
setAnimateCompileDropdownArrow,
setAutoCompile,
setCompiling,
setDraft,
setError,
setHasLintingError,
setHighlights,
setPosition,
setShowCompileTimeWarning,
setShowLogs,
toggleLogs,
setStopOnFirstError,
setStopOnValidationError,
showLogs,
showCompileTimeWarning,
startCompile,
stopCompile,
stopOnFirstError,
stopOnValidationError,
stoppedOnFirstError,
uncompiled,
validationIssues,
firstRenderDone,
setChangedAt,
cleanupCompileResult,
syncToEntry,
recordAction,
}),
[
animateCompileDropdownArrow,
autoCompile,
clearCache,
clearingCache,
clsiServerId,
codeCheckFailed,
compiling,
deliveryLatencies,
draft,
editedSinceCompileStarted,
error,
fileList,
hasChanges,
hasShortCompileTimeout,
highlights,
isProjectOwner,
lastCompileOptions,
logEntryAnnotations,
logEntries,
pdfFile,
pdfViewer,
position,
rawLog,
recompileFromScratch,
setAnimateCompileDropdownArrow,
setAutoCompile,
setCompiling,
setDraft,
setError,
setHasLintingError,
setHighlights,
setPosition,
setShowCompileTimeWarning,
setShowLogs,
toggleLogs,
setStopOnFirstError,
setStopOnValidationError,
showCompileTimeWarning,
showLogs,
startCompile,
stopCompile,
stopOnFirstError,
stopOnValidationError,
stoppedOnFirstError,
uncompiled,
validationIssues,
firstRenderDone,
setChangedAt,
cleanupCompileResult,
syncToEntry,
recordAction,
]
)
return (
<DetachCompileContext.Provider value={value}>
{children}
</DetachCompileContext.Provider>
)
}
export function useDetachCompileContext() {
const context = useContext(DetachCompileContext)
if (!context) {
throw new Error(
'useDetachCompileContext is ony available inside DetachCompileProvider'
)
}
return context
}

View File

@@ -0,0 +1,155 @@
import {
createContext,
useContext,
useCallback,
useMemo,
useEffect,
useState,
FC,
} from 'react'
import getMeta from '../../utils/meta'
import { buildUrlWithDetachRole } from '../utils/url-helper'
import useCallbackHandlers from '../hooks/use-callback-handlers'
import { debugConsole } from '@/utils/debugging'
export type DetachRole = 'detacher' | 'detached' | null
type Message = {
role: DetachRole
event: string
data?: any
}
export const DetachContext = createContext<
| {
role: DetachRole
setRole: (role: DetachRole) => void
broadcastEvent: (event: string, data?: any) => void
addEventHandler: (handler: (...args: any[]) => void) => void
deleteEventHandler: (handler: (...args: any[]) => void) => void
}
| undefined
>(undefined)
const debugPdfDetach = getMeta('ol-debugPdfDetach')
const projectId = getMeta('ol-project_id')
export const detachChannelId = `detach-${projectId}`
export const detachChannel =
'BroadcastChannel' in window
? new BroadcastChannel(detachChannelId)
: undefined
export const DetachProvider: FC = ({ children }) => {
const [lastDetachedConnectedAt, setLastDetachedConnectedAt] = useState<Date>()
const [role, setRole] = useState(() => getMeta('ol-detachRole') || null)
const {
addHandler: addEventHandler,
deleteHandler: deleteEventHandler,
callHandlers: callEventHandlers,
} = useCallbackHandlers()
useEffect(() => {
if (debugPdfDetach) {
debugConsole.warn('Effect', { role })
}
window.history.replaceState({}, '', buildUrlWithDetachRole(role).toString())
}, [role])
useEffect(() => {
if (detachChannel) {
const listener = (event: MessageEvent) => {
if (debugPdfDetach) {
debugConsole.warn(`Receiving:`, event.data)
}
callEventHandlers(event.data)
}
detachChannel.addEventListener('message', listener)
return () => {
detachChannel.removeEventListener('message', listener)
}
}
}, [callEventHandlers])
const broadcastEvent = useCallback(
(event: string, data?: any) => {
if (!role) {
if (debugPdfDetach) {
debugConsole.warn('Not Broadcasting (no role)', {
role,
event,
data,
})
}
return
}
if (debugPdfDetach) {
debugConsole.warn('Broadcasting', {
role,
event,
data,
})
}
const message: Message = { role, event }
if (data) {
message.data = data
}
detachChannel?.postMessage(message)
},
[role]
)
useEffect(() => {
broadcastEvent('connected')
}, [broadcastEvent])
useEffect(() => {
const onBeforeUnload = () => broadcastEvent('closed')
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [broadcastEvent])
useEffect(() => {
const updateLastDetachedConnectedAt = (message: Message) => {
if (message.role === 'detached' && message.event === 'connected') {
setLastDetachedConnectedAt(new Date())
}
}
addEventHandler(updateLastDetachedConnectedAt)
return () => deleteEventHandler(updateLastDetachedConnectedAt)
}, [addEventHandler, deleteEventHandler])
const value = useMemo(
() => ({
role,
setRole,
broadcastEvent,
lastDetachedConnectedAt,
addEventHandler,
deleteEventHandler,
}),
[
role,
setRole,
broadcastEvent,
lastDetachedConnectedAt,
addEventHandler,
deleteEventHandler,
]
)
return (
<DetachContext.Provider value={value}>{children}</DetachContext.Provider>
)
}
export function useDetachContext() {
const data = useContext(DetachContext)
if (!data) {
throw new Error('useDetachContext is only available inside DetachProvider')
}
return data
}

View File

@@ -0,0 +1,263 @@
import {
createContext,
Dispatch,
FC,
SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import useScopeValue from '../hooks/use-scope-value'
import useBrowserWindow from '../hooks/use-browser-window'
import { useIdeContext } from './ide-context'
import { useProjectContext } from './project-context'
import { useDetachContext } from './detach-context'
import getMeta from '../../utils/meta'
import { useUserContext } from './user-context'
import { saveProjectSettings } from '@/features/editor-left-menu/utils/api'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { WritefullAPI } from './types/writefull-instance'
export const EditorContext = createContext<
| {
cobranding?: {
logoImgUrl: string
brandVariationName: string
brandVariationId: number
brandId: number
brandVariationHomeUrl: string
publishGuideHtml?: string
partner?: string
brandedMenu?: boolean
submitBtnHtml?: string
}
hasPremiumCompile?: boolean
renameProject: (newName: string) => void
setPermissionsLevel: (permissionsLevel: PermissionsLevel) => void
showSymbolPalette?: boolean
toggleSymbolPalette?: () => void
insertSymbol?: (symbol: string) => void
isProjectOwner: boolean
isRestrictedTokenMember?: boolean
isPendingEditor: boolean
permissionsLevel: PermissionsLevel
deactivateTutorial: (tutorial: string) => void
inactiveTutorials: string[]
currentPopup: string | null
setCurrentPopup: Dispatch<SetStateAction<string | null>>
setOutOfSync: (value: boolean) => void
assistantUpgraded: boolean
setAssistantUpgraded: (value: boolean) => void
hasPremiumSuggestion: boolean
setHasPremiumSuggestion: (value: boolean) => void
setPremiumSuggestionResetDate: (date: Date) => void
premiumSuggestionResetDate: Date
writefullInstance: WritefullAPI | null
setWritefullInstance: (instance: WritefullAPI) => void
}
| undefined
>(undefined)
export const EditorProvider: FC = ({ children }) => {
const { socket } = useIdeContext()
const { id: userId, featureUsage } = useUserContext()
const { role } = useDetachContext()
const { showGenericMessageModal } = useModalsContext()
const { owner, features, _id: projectId, members } = useProjectContext()
const cobranding = useMemo(() => {
const brandVariation = getMeta('ol-brandVariation')
return (
brandVariation && {
logoImgUrl: brandVariation.logo_url,
brandVariationName: brandVariation.name,
brandVariationId: brandVariation.id,
brandId: brandVariation.brand_id,
brandVariationHomeUrl: brandVariation.home_url,
publishGuideHtml: brandVariation.publish_guide_html,
partner: brandVariation.partner,
brandedMenu: brandVariation.branded_menu,
submitBtnHtml: brandVariation.submit_button_html,
}
)
}, [])
const [projectName, setProjectName] = useScopeValue('project.name')
const [permissionsLevel, setPermissionsLevel] =
useScopeValue('permissionsLevel')
const [outOfSync, setOutOfSync] = useState(false)
const [showSymbolPalette] = useScopeValue('editor.showSymbolPalette')
const [toggleSymbolPalette] = useScopeValue('editor.toggleSymbolPalette')
const [inactiveTutorials, setInactiveTutorials] = useState(
() => getMeta('ol-inactiveTutorials') || []
)
const [currentPopup, setCurrentPopup] = useState<string | null>(null)
const [assistantUpgraded, setAssistantUpgraded] = useState(false)
const [hasPremiumSuggestion, setHasPremiumSuggestion] = useState<boolean>(
() => {
return Boolean(
featureUsage?.aiErrorAssistant &&
featureUsage?.aiErrorAssistant.remainingUsage > 0
)
}
)
const [premiumSuggestionResetDate, setPremiumSuggestionResetDate] =
useState<Date>(() => {
return featureUsage?.aiErrorAssistant?.resetDate
? new Date(featureUsage.aiErrorAssistant.resetDate)
: new Date()
})
const isPendingEditor = useMemo(
() =>
members?.some(
member =>
member._id === userId &&
(member.pendingEditor || member.pendingReviewer)
),
[members, userId]
)
const deactivateTutorial = useCallback(
tutorialKey => {
setInactiveTutorials([...inactiveTutorials, tutorialKey])
},
[inactiveTutorials]
)
useEffect(() => {
if (socket) {
socket.on('projectNameUpdated', setProjectName)
return () => socket.removeListener('projectNameUpdated', setProjectName)
}
}, [socket, setProjectName])
const renameProject = useCallback(
(newName: string) => {
setProjectName((oldName: string) => {
if (oldName !== newName) {
saveProjectSettings(projectId, { name: newName }).catch(
(response: any) => {
setProjectName(oldName)
const { data, status } = response
showGenericMessageModal(
'Error renaming project',
status === 400 ? data : 'Please try again in a moment'
)
}
)
}
return newName
})
},
[setProjectName, projectId, showGenericMessageModal]
)
const { setTitle } = useBrowserWindow()
useEffect(() => {
const parts = []
if (role === 'detached') {
parts.push('[PDF]')
}
if (projectName) {
parts.push(projectName)
parts.push('-')
}
parts.push('Online LaTeX Editor')
parts.push(getMeta('ol-ExposedSettings').appName)
const title = parts.join(' ')
setTitle(title)
}, [projectName, setTitle, role])
const insertSymbol = useCallback((symbol: string) => {
window.dispatchEvent(
new CustomEvent('editor:insert-symbol', {
detail: symbol,
})
)
}, [])
const [writefullInstance, setWritefullInstance] =
useState<WritefullAPI | null>(null)
const value = useMemo(
() => ({
cobranding,
hasPremiumCompile: features?.compileGroup === 'priority',
renameProject,
permissionsLevel: outOfSync ? 'readOnly' : permissionsLevel,
setPermissionsLevel,
isProjectOwner: owner?._id === userId,
isRestrictedTokenMember: getMeta('ol-isRestrictedTokenMember'),
isPendingEditor,
showSymbolPalette,
toggleSymbolPalette,
insertSymbol,
inactiveTutorials,
deactivateTutorial,
currentPopup,
setCurrentPopup,
setOutOfSync,
hasPremiumSuggestion,
setHasPremiumSuggestion,
premiumSuggestionResetDate,
setPremiumSuggestionResetDate,
assistantUpgraded,
setAssistantUpgraded,
writefullInstance,
setWritefullInstance,
}),
[
cobranding,
features?.compileGroup,
owner,
userId,
renameProject,
permissionsLevel,
setPermissionsLevel,
isPendingEditor,
showSymbolPalette,
toggleSymbolPalette,
insertSymbol,
inactiveTutorials,
deactivateTutorial,
currentPopup,
setCurrentPopup,
outOfSync,
setOutOfSync,
hasPremiumSuggestion,
setHasPremiumSuggestion,
premiumSuggestionResetDate,
setPremiumSuggestionResetDate,
assistantUpgraded,
setAssistantUpgraded,
writefullInstance,
setWritefullInstance,
]
)
return (
<EditorContext.Provider value={value}>{children}</EditorContext.Provider>
)
}
export function useEditorContext() {
const context = useContext(EditorContext)
if (!context) {
throw new Error('useEditorContext is only available inside EditorProvider')
}
return context
}

View File

@@ -0,0 +1,321 @@
import {
createContext,
useCallback,
useReducer,
useContext,
useMemo,
useState,
FC,
useEffect,
} from 'react'
import useScopeValue from '../hooks/use-scope-value'
import {
renameInTree,
deleteInTree,
moveInTree,
createEntityInTree,
} from '../../features/file-tree/util/mutate-in-tree'
import { countFiles } from '../../features/file-tree/util/count-in-tree'
import useDeepCompareEffect from '../../shared/hooks/use-deep-compare-effect'
import { docsInFolder } from '@/features/file-tree/util/docs-in-folder'
import useScopeValueSetterOnly from '@/shared/hooks/use-scope-value-setter-only'
import { Folder } from '../../../../types/folder'
import { Project } from '../../../../types/project'
import { MainDocument } from '../../../../types/project-settings'
import { FindResult } from '@/features/file-tree/util/path'
import {
StubSnapshotUtils,
useSnapshotContext,
} from '@/features/ide-react/context/snapshot-context'
import importOverleafModules from '../../../macros/import-overleaf-module.macro'
const { buildFileTree, createFolder } =
(importOverleafModules('snapshotUtils')[0]
?.import as typeof StubSnapshotUtils) || StubSnapshotUtils
const FileTreeDataContext = createContext<
| {
// fileTreeData is the up-to-date representation of the files list, updated
// by the file tree
fileTreeData: Folder
fileCount: { value: number; status: string; limit: number } | number
fileTreeReadOnly: boolean
hasFolders: boolean
selectedEntities: FindResult[]
setSelectedEntities: (selectedEntities: FindResult[]) => void
dispatchRename: (id: string, name: string) => void
dispatchMove: (id: string, target: string) => void
dispatchDelete: (id: string) => void
dispatchCreateFolder: (name: string, folder: any) => void
dispatchCreateDoc: (name: string, doc: any) => void
dispatchCreateFile: (name: string, file: any) => void
docs?: MainDocument[]
}
| undefined
>(undefined)
/* eslint-disable no-unused-vars */
enum ACTION_TYPES {
RENAME = 'RENAME',
RESET = 'RESET',
DELETE = 'DELETE',
MOVE = 'MOVE',
CREATE = 'CREATE',
}
/* eslint-enable no-unused-vars */
type Action =
| {
type: ACTION_TYPES.RESET
fileTreeData?: Folder
}
| {
type: ACTION_TYPES.RENAME
id: string
newName: string
}
| {
type: ACTION_TYPES.DELETE
id: string
}
| {
type: ACTION_TYPES.MOVE
entityId: string
toFolderId: string
}
| {
type: typeof ACTION_TYPES.CREATE
parentFolderId: string
entity: any // TODO
}
function fileTreeMutableReducer(
{ fileTreeData }: { fileTreeData: Folder },
action: Action
) {
switch (action.type) {
case ACTION_TYPES.RESET: {
const newFileTreeData = action.fileTreeData
return {
fileTreeData: newFileTreeData,
fileCount: countFiles(newFileTreeData),
}
}
case ACTION_TYPES.RENAME: {
const newFileTreeData = renameInTree(fileTreeData, action.id, {
newName: action.newName,
})
return {
fileTreeData: newFileTreeData,
fileCount: countFiles(newFileTreeData),
}
}
case ACTION_TYPES.DELETE: {
const newFileTreeData = deleteInTree(fileTreeData, action.id)
return {
fileTreeData: newFileTreeData,
fileCount: countFiles(newFileTreeData),
}
}
case ACTION_TYPES.MOVE: {
const newFileTreeData = moveInTree(
fileTreeData,
action.entityId,
action.toFolderId
)
return {
fileTreeData: newFileTreeData,
fileCount: countFiles(newFileTreeData),
}
}
case ACTION_TYPES.CREATE: {
const newFileTreeData = createEntityInTree(
fileTreeData,
action.parentFolderId,
action.entity
)
return {
fileTreeData: newFileTreeData,
fileCount: countFiles(newFileTreeData),
}
}
default: {
throw new Error(
`Unknown mutable file tree action type: ${(action as Action).type}`
)
}
}
}
const initialState = (rootFolder?: Folder[]) => {
const fileTreeData = rootFolder?.[0]
return {
fileTreeData,
fileCount: countFiles(fileTreeData),
}
}
export function useFileTreeData() {
const context = useContext(FileTreeDataContext)
if (!context) {
throw new Error(
'useFileTreeData is only available inside FileTreeDataProvider'
)
}
return context
}
export const FileTreeDataProvider: FC = ({ children }) => {
const [project] = useScopeValue<Project>('project')
const [currentDocumentId] = useScopeValue('editor.open_doc_id')
const [, setOpenDocName] = useScopeValueSetterOnly('editor.open_doc_name')
const [permissionsLevel] = useScopeValue('permissionsLevel')
const { fileTreeFromHistory, snapshot, snapshotVersion } =
useSnapshotContext()
const fileTreeReadOnly =
permissionsLevel === 'readOnly' || fileTreeFromHistory
const [rootFolder, setRootFolder] = useState(project?.rootFolder)
useEffect(() => {
if (fileTreeFromHistory) return
setRootFolder(project?.rootFolder)
}, [project, fileTreeFromHistory])
useEffect(() => {
if (!fileTreeFromHistory) return
if (!rootFolder || rootFolder?.[0]?._id) {
// Init or replace mongo rootFolder with stub while we load the snapshot.
// In the future, project:joined should only fire once the snapshot is ready.
setRootFolder([createFolder('', '')])
}
}, [fileTreeFromHistory, rootFolder])
useEffect(() => {
if (!fileTreeFromHistory || !snapshot) return
setRootFolder([buildFileTree(snapshot)])
}, [fileTreeFromHistory, snapshot, snapshotVersion])
const [{ fileTreeData, fileCount }, dispatch] = useReducer(
fileTreeMutableReducer,
rootFolder,
initialState
)
const [selectedEntities, setSelectedEntities] = useState<FindResult[]>([])
const docs = useMemo(
() => (fileTreeData ? docsInFolder(fileTreeData) : undefined),
[fileTreeData]
)
useDeepCompareEffect(() => {
dispatch({
type: ACTION_TYPES.RESET,
fileTreeData: rootFolder?.[0],
})
}, [rootFolder])
const dispatchCreateFolder = useCallback((parentFolderId, entity) => {
entity.type = 'folder'
dispatch({
type: ACTION_TYPES.CREATE,
parentFolderId,
entity,
})
}, [])
const dispatchCreateDoc = useCallback(
(parentFolderId: string, entity: any) => {
entity.type = 'doc'
dispatch({
type: ACTION_TYPES.CREATE,
parentFolderId,
entity,
})
},
[]
)
const dispatchCreateFile = useCallback(
(parentFolderId: string, entity: any) => {
entity.type = 'fileRef'
dispatch({
type: ACTION_TYPES.CREATE,
parentFolderId,
entity,
})
},
[]
)
const dispatchRename = useCallback(
(id: string, newName: string) => {
dispatch({
type: ACTION_TYPES.RENAME,
newName,
id,
})
if (id === currentDocumentId) {
setOpenDocName(newName)
}
},
[currentDocumentId, setOpenDocName]
)
const dispatchDelete = useCallback((id: string) => {
dispatch({ type: ACTION_TYPES.DELETE, id })
}, [])
const dispatchMove = useCallback((entityId: string, toFolderId: string) => {
dispatch({ type: ACTION_TYPES.MOVE, entityId, toFolderId })
}, [])
const value = useMemo(() => {
return {
dispatchCreateDoc,
dispatchCreateFile,
dispatchCreateFolder,
dispatchDelete,
dispatchMove,
dispatchRename,
fileCount,
fileTreeData,
fileTreeReadOnly,
hasFolders: fileTreeData?.folders.length > 0,
selectedEntities,
setSelectedEntities,
docs,
}
}, [
dispatchCreateDoc,
dispatchCreateFile,
dispatchCreateFolder,
dispatchDelete,
dispatchMove,
dispatchRename,
fileCount,
fileTreeData,
fileTreeReadOnly,
selectedEntities,
setSelectedEntities,
docs,
])
return (
<FileTreeDataContext.Provider value={value}>
{children}
</FileTreeDataContext.Provider>
)
}

View File

@@ -0,0 +1,66 @@
import { createContext, FC, useContext, useEffect, useMemo } from 'react'
import { ScopeValueStore } from '../../../../types/ide/scope-value-store'
import { ScopeEventEmitter } from '../../../../types/ide/scope-event-emitter'
import { Socket } from '@/features/ide-react/connection/types/socket'
export type Ide = {
$scope: Record<string, any>
socket: Socket
}
type IdeContextValue = Ide & {
scopeStore: ScopeValueStore
scopeEventEmitter: ScopeEventEmitter
}
export const IdeContext = createContext<IdeContextValue | undefined>(undefined)
export const IdeProvider: FC<{
ide: Ide
scopeStore: ScopeValueStore
scopeEventEmitter: ScopeEventEmitter
}> = ({ ide, scopeStore, scopeEventEmitter, children }) => {
/**
* Expose scopeStore via `window.overleaf.unstable.store`, so it can be accessed by external extensions.
*
* These properties are expected to be available:
* - `editor.view`
* - `project.spellcheckLanguage`
* - `editor.open_doc_name`,
* - `editor.open_doc_id`,
* - `settings.theme`
* - `settings.keybindings`
* - `settings.fontSize`
* - `settings.fontFamily`
* - `settings.lineHeight`
*/
useEffect(() => {
window.overleaf = {
...window.overleaf,
unstable: {
...window.overleaf?.unstable,
store: scopeStore,
},
}
}, [scopeStore])
const value = useMemo<IdeContextValue>(() => {
return {
...ide,
scopeStore,
scopeEventEmitter,
}
}, [ide, scopeStore, scopeEventEmitter])
return <IdeContext.Provider value={value}>{children}</IdeContext.Provider>
}
export function useIdeContext(): IdeContextValue {
const context = useContext(IdeContext)
if (!context) {
throw new Error('useIdeContext is only available inside IdeProvider')
}
return context
}

View File

@@ -0,0 +1,295 @@
import {
createContext,
useContext,
useCallback,
useMemo,
useEffect,
Dispatch,
SetStateAction,
FC,
useState,
} from 'react'
import useScopeValue from '../hooks/use-scope-value'
import useDetachLayout from '../hooks/use-detach-layout'
import localStorage from '../../infrastructure/local-storage'
import getMeta from '../../utils/meta'
import { DetachRole } from './detach-context'
import { debugConsole } from '@/utils/debugging'
import { BinaryFile } from '@/features/file-view/types/binary-file'
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
import useEventListener from '@/shared/hooks/use-event-listener'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { isMac } from '@/shared/utils/os'
import { sendSearchEvent } from '@/features/event-tracking/search-events'
import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
export type IdeLayout = 'sideBySide' | 'flat'
export type IdeView = 'editor' | 'file' | 'pdf' | 'history'
export type LayoutContextValue = {
reattach: () => void
detach: () => void
detachIsLinked: boolean
detachRole: DetachRole
changeLayout: (newLayout: IdeLayout, newView?: IdeView) => void
view: IdeView | null
setView: (view: IdeView | null) => void
chatIsOpen: boolean
setChatIsOpen: Dispatch<SetStateAction<LayoutContextValue['chatIsOpen']>>
reviewPanelOpen: boolean
setReviewPanelOpen: Dispatch<
SetStateAction<LayoutContextValue['reviewPanelOpen']>
>
miniReviewPanelVisible: boolean
setMiniReviewPanelVisible: Dispatch<
SetStateAction<LayoutContextValue['miniReviewPanelVisible']>
>
leftMenuShown: boolean
setLeftMenuShown: Dispatch<
SetStateAction<LayoutContextValue['leftMenuShown']>
>
loadingStyleSheet: boolean
setLoadingStyleSheet: Dispatch<
SetStateAction<LayoutContextValue['loadingStyleSheet']>
>
pdfLayout: IdeLayout
pdfPreviewOpen: boolean
projectSearchIsOpen: boolean
setProjectSearchIsOpen: Dispatch<SetStateAction<boolean>>
}
const debugPdfDetach = getMeta('ol-debugPdfDetach')
export const LayoutContext = createContext<LayoutContextValue | undefined>(
undefined
)
function setLayoutInLocalStorage(pdfLayout: IdeLayout) {
localStorage.setItem(
'pdf.layout',
pdfLayout === 'sideBySide' ? 'split' : 'flat'
)
}
export const LayoutProvider: FC = ({ children }) => {
// what to show in the "flat" view (editor or pdf)
const [view, _setView] = useScopeValue<IdeView | null>('ui.view')
const [openFile] = useScopeValue<BinaryFile | null>('openFile')
const historyToggleEmitter = useScopeEventEmitter('history:toggle', true)
const { isOpen: railIsOpen, setIsOpen: setRailIsOpen } = useRailContext()
const [prevRailIsOpen, setPrevRailIsOpen] = useState(railIsOpen)
const setView = useCallback(
(value: IdeView | null) => {
_setView(oldValue => {
// ensure that the "history:toggle" event is broadcast when switching in or out of history view
if (value === 'history' || oldValue === 'history') {
historyToggleEmitter()
}
if (value === 'history') {
setPrevRailIsOpen(railIsOpen)
setRailIsOpen(true)
}
if (oldValue === 'history') {
setRailIsOpen(prevRailIsOpen)
}
if (value === 'editor' && openFile) {
// if a file is currently opened, ensure the view is 'file' instead of
// 'editor' when the 'editor' view is requested. This is to ensure
// that the entity selected in the file tree is the one visible and
// that docs don't take precedence over files.
return 'file'
}
return value
})
},
[
_setView,
setRailIsOpen,
openFile,
historyToggleEmitter,
prevRailIsOpen,
setPrevRailIsOpen,
railIsOpen,
]
)
// whether the chat pane is open
const [chatIsOpen, setChatIsOpen] = useScopeValue<boolean>('ui.chatOpen')
// whether the review pane is open
const [reviewPanelOpen, setReviewPanelOpen] =
useScopeValue<boolean>('ui.reviewPanelOpen')
// whether the review pane is collapsed
const [miniReviewPanelVisible, setMiniReviewPanelVisible] =
useScopeValue<boolean>('ui.miniReviewPanelVisible')
// whether the menu pane is open
const [leftMenuShown, setLeftMenuShown] =
useScopeValue<boolean>('ui.leftMenuShown')
// whether the project search is open
const [projectSearchIsOpen, setProjectSearchIsOpen] = useState(false)
useEventListener(
'ui.toggle-left-menu',
useCallback(
event => {
setLeftMenuShown((event as CustomEvent<boolean>).detail)
},
[setLeftMenuShown]
)
)
useEventListener(
'ui.toggle-review-panel',
useCallback(() => {
setReviewPanelOpen(open => !open)
}, [setReviewPanelOpen])
)
useEventListener(
'keydown',
useCallback((event: KeyboardEvent) => {
if (
(isMac ? event.metaKey : event.ctrlKey) &&
event.shiftKey &&
event.code === 'KeyF'
) {
if (isSplitTestEnabled('full-project-search')) {
event.preventDefault()
sendSearchEvent('search-open', {
searchType: 'full-project',
method: 'keyboard',
})
setProjectSearchIsOpen(true)
}
}
}, [])
)
// whether to display the editor and preview side-by-side or full-width ("flat")
const [pdfLayout, setPdfLayout] = useScopeValue<IdeLayout>('ui.pdfLayout')
// whether stylesheet on theme is loading
const [loadingStyleSheet, setLoadingStyleSheet] = useState(false)
const changeLayout = useCallback(
(newLayout: IdeLayout, newView: IdeView = 'editor') => {
setPdfLayout(newLayout)
setView(newLayout === 'sideBySide' ? 'editor' : newView)
setLayoutInLocalStorage(newLayout)
},
[setPdfLayout, setView]
)
const {
reattach,
detach,
isLinking: detachIsLinking,
isLinked: detachIsLinked,
role: detachRole,
isRedundant: detachIsRedundant,
} = useDetachLayout()
const pdfPreviewOpen =
pdfLayout === 'sideBySide' || view === 'pdf' || detachRole === 'detacher'
useEffect(() => {
if (debugPdfDetach) {
debugConsole.warn('Layout Effect', {
detachIsRedundant,
detachRole,
detachIsLinking,
detachIsLinked,
})
}
if (detachRole !== 'detacher') return // not in a PDF detacher layout
if (detachIsRedundant) {
changeLayout('sideBySide')
return
}
if (detachIsLinking || detachIsLinked) {
// the tab is linked to a detached tab (or about to be linked); show
// editor only
changeLayout('flat', 'editor')
}
}, [
detachIsRedundant,
detachRole,
detachIsLinking,
detachIsLinked,
changeLayout,
])
const value = useMemo<LayoutContextValue>(
() => ({
reattach,
detach,
detachIsLinked,
detachRole,
changeLayout,
chatIsOpen,
leftMenuShown,
pdfLayout,
pdfPreviewOpen,
projectSearchIsOpen,
setProjectSearchIsOpen,
reviewPanelOpen,
miniReviewPanelVisible,
loadingStyleSheet,
setChatIsOpen,
setLeftMenuShown,
setPdfLayout,
setReviewPanelOpen,
setMiniReviewPanelVisible,
setLoadingStyleSheet,
setView,
view,
}),
[
reattach,
detach,
detachIsLinked,
detachRole,
changeLayout,
chatIsOpen,
leftMenuShown,
pdfLayout,
pdfPreviewOpen,
projectSearchIsOpen,
setProjectSearchIsOpen,
reviewPanelOpen,
miniReviewPanelVisible,
loadingStyleSheet,
setChatIsOpen,
setLeftMenuShown,
setPdfLayout,
setReviewPanelOpen,
setMiniReviewPanelVisible,
setLoadingStyleSheet,
setView,
view,
]
)
return (
<LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>
)
}
export function useLayoutContext() {
const context = useContext(LayoutContext)
if (!context) {
throw new Error('useLayoutContext is only available inside LayoutProvider')
}
return context
}

View File

@@ -0,0 +1,873 @@
import {
FC,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
Dispatch,
SetStateAction,
} from 'react'
import useScopeValue from '../hooks/use-scope-value'
import useScopeValueSetterOnly from '../hooks/use-scope-value-setter-only'
import usePersistedState from '../hooks/use-persisted-state'
import useAbortController from '../hooks/use-abort-controller'
import DocumentCompiler from '../../features/pdf-preview/util/compiler'
import {
send,
sendMB,
sendMBOnce,
sendMBSampled,
} from '../../infrastructure/event-tracking'
import {
buildLogEntryAnnotations,
buildRuleCounts,
buildRuleDeltas,
handleLogFiles,
handleOutputFiles,
} from '../../features/pdf-preview/util/output-files'
import { useProjectContext } from './project-context'
import { useEditorContext } from './editor-context'
import { buildFileList } from '../../features/pdf-preview/util/file-list'
import { useLayoutContext } from './layout-context'
import { useUserContext } from './user-context'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useDetachContext } from '@/shared/context/detach-context'
import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import { getJSON } from '@/infrastructure/fetch-json'
import { CompileResponseData } from '../../../../types/compile'
import {
PdfScrollPosition,
usePdfScrollPosition,
} from '@/shared/hooks/use-pdf-scroll-position'
import { PdfFileDataList } from '@/features/pdf-preview/util/types'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { captureException } from '@/infrastructure/error-reporter'
import OError from '@overleaf/o-error'
type PdfFile = Record<string, any>
export type CompileContext = {
autoCompile: boolean
clearingCache: boolean
clsiServerId?: string
codeCheckFailed: boolean
compiling: boolean
deliveryLatencies: Record<string, any>
draft: boolean
error?: string
fileList?: PdfFileDataList
hasChanges: boolean
hasShortCompileTimeout: boolean
highlights?: Record<string, any>[]
isProjectOwner: boolean
logEntries?: Record<string, any>
logEntryAnnotations?: Record<string, any>
outputFilesArchive?: string
pdfDownloadUrl?: string
pdfFile?: PdfFile
pdfUrl?: string
pdfViewer?: string
position?: PdfScrollPosition
rawLog?: string
setAutoCompile: (value: boolean) => void
setDraft: (value: any) => void
setError: (value: any) => void
setHasLintingError: (value: any) => void // only for storybook
setHighlights: (value: any) => void
setPosition: Dispatch<SetStateAction<PdfScrollPosition>>
setShowCompileTimeWarning: (value: any) => void
setShowLogs: (value: boolean) => void
toggleLogs: () => void
setStopOnFirstError: (value: boolean) => void
setStopOnValidationError: (value: boolean) => void
showCompileTimeWarning: boolean
showLogs: boolean
stopOnFirstError: boolean
stopOnValidationError: boolean
stoppedOnFirstError: boolean
uncompiled?: boolean
validationIssues?: Record<string, any>
firstRenderDone: (metrics: {
latencyFetch: number
latencyRender: number | undefined
pdfCachingMetrics: { viewerId: string }
}) => void
cleanupCompileResult?: () => void
animateCompileDropdownArrow: boolean
editedSinceCompileStarted: boolean
lastCompileOptions: any
setAnimateCompileDropdownArrow: (value: boolean) => void
recompileFromScratch: () => void
setCompiling: (value: boolean) => void
startCompile: (options?: any) => void
stopCompile: () => void
setChangedAt: (value: any) => void
clearCache: () => void
syncToEntry: (value: any, keepCurrentView?: boolean) => void
recordAction: (action: string) => void
}
export const LocalCompileContext = createContext<CompileContext | undefined>(
undefined
)
export const LocalCompileProvider: FC = ({ children }) => {
const { hasPremiumCompile, isProjectOwner } = useEditorContext()
const { openDocWithId, openDocs, currentDocument } = useEditorManagerContext()
const { role } = useDetachContext()
const {
_id: projectId,
rootDocId,
joinedOnce,
imageName,
compiler: compilerName,
} = useProjectContext()
const { pdfPreviewOpen } = useLayoutContext()
const { features, alphaProgram, labsProgram } = useUserContext()
const { fileTreeData } = useFileTreeData()
const { findEntityByPath } = useFileTreePathContext()
// whether a compile is in progress
const [compiling, setCompiling] = useState(false)
// whether to show the compile time warning
const [showCompileTimeWarning, setShowCompileTimeWarning] = useState(false)
const [hasShortCompileTimeout, setHasShortCompileTimeout] = useState(false)
// the log entries parsed from the compile output log
const [logEntries, setLogEntries] = useScopeValueSetterOnly('pdf.logEntries')
// annotations for display in the editor, built from the log entries
const [logEntryAnnotations, setLogEntryAnnotations] = useScopeValue(
'pdf.logEntryAnnotations'
)
// the PDF viewer and whether syntax validation is enabled globally
const { userSettings } = useUserSettingsContext()
const { pdfViewer, syntaxValidation } = userSettings
// the URL for downloading the PDF
const [, setPdfDownloadUrl] =
useScopeValueSetterOnly<string>('pdf.downloadUrl')
// the URL for loading the PDF in the preview pane
const [, setPdfUrl] = useScopeValueSetterOnly<string>('pdf.url')
// low level details for metrics
const [pdfFile, setPdfFile] = useState<PdfFile | undefined>()
useEffect(() => {
setPdfDownloadUrl(pdfFile?.pdfDownloadUrl)
setPdfUrl(pdfFile?.pdfUrl)
}, [pdfFile, setPdfDownloadUrl, setPdfUrl])
// the project is considered to be "uncompiled" if a doc has changed, or finished saving, since the last compile started.
const [uncompiled, setUncompiled] = useScopeValue('pdf.uncompiled')
// whether a doc has been edited since the last compile started
const [editedSinceCompileStarted, setEditedSinceCompileStarted] =
useState(false)
// the id of the CLSI server which ran the compile
const [clsiServerId, setClsiServerId] = useState<string>()
// data received in response to a compile request
const [data, setData] = useState<CompileResponseData>()
// the rootDocId used in the most recent compile request, which may not be the
// same as the project rootDocId. This is used to calculate correct paths when
// parsing the compile logs
const lastCompileRootDocId = data ? (data.rootDocId ?? rootDocId) : null
// callback to be invoked for PdfJsMetrics
const [firstRenderDone, setFirstRenderDone] = useState(() => () => {})
// latencies of compile/pdf download/rendering
const [deliveryLatencies, setDeliveryLatencies] = useState({})
// whether the project has been compiled yet
const [compiledOnce, setCompiledOnce] = useState(false)
// fetch initial compile response from cache
const [initialCompileFromCache, setInitialCompileFromCache] = useState(
isSplitTestEnabled('initial-compile-from-clsi-cache') &&
// Avoid fetching the initial compile from cache in PDF detach tab
role !== 'detached'
)
// fetch of initial compile from cache is pending
const [pendingInitialCompileFromCache, setPendingInitialCompileFromCache] =
useState(false)
// Raw data from clsi-cache, will need post-processing and check settings
const [dataFromCache, setDataFromCache] = useState<CompileResponseData>()
// whether the cache is being cleared
const [clearingCache, setClearingCache] = useState(false)
// whether the logs should be visible
const [showLogs, setShowLogs] = useState(false)
// whether the compile dropdown arrow should be animated
const [animateCompileDropdownArrow, setAnimateCompileDropdownArrow] =
useState(false)
const toggleLogs = useCallback(() => {
setShowLogs(prev => {
if (!prev) {
sendMBOnce('ide-open-logs-once')
}
return !prev
})
}, [setShowLogs])
// an error that occurred
const [error, setError] = useState<string>()
// the list of files that can be downloaded
const [fileList, setFileList] = useState<PdfFileDataList>()
// the raw contents of the log file
const [rawLog, setRawLog] = useState<string>()
// validation issues from CLSI
const [validationIssues, setValidationIssues] = useState()
// areas to highlight on the PDF, from synctex
const [highlights, setHighlights] = useState()
const [position, setPosition] = usePdfScrollPosition(lastCompileRootDocId)
// whether autocompile is switched on
const [autoCompile, setAutoCompile] = usePersistedState(
`autocompile_enabled:${projectId}`,
false,
true
)
// whether the compile should run in draft mode
const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, true)
// whether compiling should stop on first error
const [stopOnFirstError, setStopOnFirstError] = usePersistedState(
`stop_on_first_error:${projectId}`,
false,
true
)
// whether the last compiles stopped on first error
const [stoppedOnFirstError, setStoppedOnFirstError] = useState(false)
// whether compiling should be prevented if there are linting errors
const [stopOnValidationError, setStopOnValidationError] = usePersistedState(
`stop_on_validation_error:${projectId}`,
true,
true
)
// whether the editor linter found errors
const [hasLintingError, setHasLintingError] = useScopeValue('hasLintingError')
// the timestamp that a doc was last changed
const [changedAt, setChangedAt] = useState(0)
const { signal } = useAbortController()
const cleanupCompileResult = useCallback(() => {
setPdfFile(undefined)
setLogEntries(null)
setLogEntryAnnotations({})
}, [setPdfFile, setLogEntries, setLogEntryAnnotations])
const compilingRef = useRef(false)
useEffect(() => {
compilingRef.current = compiling
}, [compiling])
const _buildLogEntryAnnotations = useCallback(
entries =>
buildLogEntryAnnotations(entries, fileTreeData, lastCompileRootDocId),
[fileTreeData, lastCompileRootDocId]
)
const buildLogEntryAnnotationsRef = useRef(_buildLogEntryAnnotations)
useEffect(() => {
buildLogEntryAnnotationsRef.current = _buildLogEntryAnnotations
}, [_buildLogEntryAnnotations])
// the document compiler
const [compiler] = useState(() => {
return new DocumentCompiler({
projectId,
setChangedAt,
setCompiling,
setData,
setFirstRenderDone,
setDeliveryLatencies,
setError,
cleanupCompileResult,
compilingRef,
signal,
openDocs,
})
})
// keep currentDoc in sync with the compiler
useEffect(() => {
compiler.currentDoc = currentDocument
}, [compiler, currentDocument])
// keep the project rootDocId in sync with the compiler
useEffect(() => {
compiler.projectRootDocId = rootDocId
}, [compiler, rootDocId])
// keep draft setting in sync with the compiler
useEffect(() => {
compiler.setOption('draft', draft)
}, [compiler, draft])
// keep stop on first error setting in sync with the compiler
useEffect(() => {
compiler.setOption('stopOnFirstError', stopOnFirstError)
}, [compiler, stopOnFirstError])
useEffect(() => {
setUncompiled(changedAt > 0)
}, [setUncompiled, changedAt])
useEffect(() => {
setEditedSinceCompileStarted(changedAt > 0)
}, [setEditedSinceCompileStarted, changedAt])
// try to fetch the last compile result after opening the project, potentially before joining the project.
useEffect(() => {
if (initialCompileFromCache && !pendingInitialCompileFromCache) {
setPendingInitialCompileFromCache(true)
getJSON(`/project/${projectId}/output/cached/output.overleaf.json`)
.then((data: any) => {
// Hand data over to next effect, it will wait for project/doc loading.
setDataFromCache(data)
})
.catch(() => {
// Let the isAutoCompileOnLoad effect take over
setInitialCompileFromCache(false)
setPendingInitialCompileFromCache(false)
})
}
}, [projectId, initialCompileFromCache, pendingInitialCompileFromCache])
// Maybe adopt the compile from cache
useEffect(() => {
if (!dataFromCache) return // no compile from cache available
if (!joinedOnce) return // wait for joinProject, it populates the file-tree.
if (!currentDocument) return // wait for current doc to load, it affects the rootDoc override
if (compiledOnce) return // regular compile triggered
// Gracefully access file-tree and getRootDocOverride
let settingsUpToDate = false
try {
dataFromCache.rootDocId = findEntityByPath(
dataFromCache.options?.rootResourcePath || ''
)?.entity?._id
const rootDocOverride = compiler.getRootDocOverrideId() || rootDocId
settingsUpToDate =
rootDocOverride === dataFromCache.rootDocId &&
dataFromCache.options.imageName === imageName &&
dataFromCache.options.compiler === compilerName &&
dataFromCache.options.stopOnFirstError === stopOnFirstError &&
dataFromCache.options.draft === draft
} catch (err) {
captureException(
OError.tag(err as unknown as Error, 'validate compile options', {
options: dataFromCache.options,
})
)
}
if (settingsUpToDate) {
setData(dataFromCache)
setCompiledOnce(true)
}
setDataFromCache(undefined)
setInitialCompileFromCache(false)
setPendingInitialCompileFromCache(false)
}, [
dataFromCache,
joinedOnce,
currentDocument,
compiledOnce,
rootDocId,
findEntityByPath,
compiler,
compilerName,
imageName,
stopOnFirstError,
draft,
])
// always compile the PDF once after opening the project, after the doc has loaded
useEffect(() => {
if (
!compiledOnce &&
currentDocument &&
!initialCompileFromCache &&
!pendingInitialCompileFromCache
) {
setCompiledOnce(true)
compiler.compile({ isAutoCompileOnLoad: true })
}
}, [
compiledOnce,
currentDocument,
initialCompileFromCache,
pendingInitialCompileFromCache,
compiler,
])
useEffect(() => {
setHasShortCompileTimeout(
features?.compileTimeout !== undefined && features.compileTimeout <= 60
)
}, [features])
useEffect(() => {
if (hasShortCompileTimeout && compiling && isProjectOwner) {
const timeout = window.setTimeout(() => {
setShowCompileTimeWarning(true)
}, 30000)
return () => {
window.clearTimeout(timeout)
}
}
}, [compiling, isProjectOwner, hasShortCompileTimeout])
const hasCompileLogsEvents = useFeatureFlag('compile-log-events')
// compare log entry counts with the previous compile, and record actions between compiles
// these are refs rather than state so they don't trigger the effect to run
const previousRuleCountsRef = useRef<{
ruleCounts: Record<string, number>
rootDocId: string
} | null>(null)
const recordedActionsRef = useRef<Record<string, boolean>>({})
const recordAction = useCallback((action: string) => {
recordedActionsRef.current[action] = true
}, [])
// handle the data returned from a compile request
// note: this should _only_ run when `data` changes,
// the other dependencies must all be static
useEffect(() => {
if (!joinedOnce) return // wait for joinProject, it populates the premium flags.
const abortController = new AbortController()
const recordedActions = recordedActionsRef.current
recordedActionsRef.current = {}
if (data) {
if (data.clsiServerId) {
setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController
}
if (data.outputFiles) {
const outputFiles = new Map()
for (const outputFile of data.outputFiles) {
// Use a shadow-copy, we will update it in place and append to .url.
outputFiles.set(outputFile.path, { ...outputFile })
}
// set the PDF context
if (data.status === 'success') {
setPdfFile(handleOutputFiles(outputFiles, projectId, data))
}
setFileList(buildFileList(outputFiles, data))
// handle log files
// asynchronous (TODO: cancel on new compile?)
setLogEntryAnnotations(null)
setLogEntries(null)
setRawLog(undefined)
handleLogFiles(outputFiles, data, abortController.signal).then(
(result: Record<string, any>) => {
setRawLog(result.log)
setLogEntries(result.logEntries)
setLogEntryAnnotations(
buildLogEntryAnnotationsRef.current(result.logEntries.all)
)
// sample compile stats for real users
if (!alphaProgram) {
if (['success', 'stopped-on-first-error'].includes(data.status)) {
sendMBSampled(
'compile-result',
{
errors: result.logEntries.errors.length,
warnings: result.logEntries.warnings.length,
typesetting: result.logEntries.typesetting.length,
newPdfPreview: true, // TODO: is this useful?
stopOnFirstError: data.options.stopOnFirstError,
},
0.01
)
}
if (hasCompileLogsEvents || labsProgram) {
const ruleCounts = buildRuleCounts(
result.logEntries.all
) as Record<string, number>
const rootDocId = data.rootDocId || compiler.projectRootDocId
const previousRuleCounts = previousRuleCountsRef.current
previousRuleCountsRef.current = { ruleCounts, rootDocId }
const ruleDeltas =
previousRuleCounts &&
previousRuleCounts.rootDocId === rootDocId
? buildRuleDeltas(ruleCounts, previousRuleCounts.ruleCounts)
: {}
sendMB('compile-log-entries', {
status: data.status,
stopOnFirstError: data.options.stopOnFirstError,
isAutoCompileOnLoad: !!data.options.isAutoCompileOnLoad,
isAutoCompileOnChange: !!data.options.isAutoCompileOnChange,
rootDocId,
...recordedActions,
...ruleCounts,
...ruleDeltas,
})
}
}
}
)
}
switch (data.status) {
case 'success':
setError(undefined)
setShowLogs(false)
break
case 'stopped-on-first-error':
setError(undefined)
setShowLogs(true)
break
case 'clsi-maintenance':
case 'compile-in-progress':
case 'exited':
case 'failure':
case 'project-too-large':
case 'rate-limited':
case 'terminated':
case 'too-recently-compiled':
setError(data.status)
break
case 'timedout':
setError('timedout')
if (!hasPremiumCompile && isProjectOwner) {
send(
'subscription-funnel',
'editor-click-feature',
'compile-timeout'
)
}
break
case 'autocompile-backoff':
if (!data.options.isAutoCompileOnLoad) {
setError('autocompile-disabled')
setAutoCompile(false)
}
break
case 'unavailable':
setError('clsi-unavailable')
break
case 'validation-problems':
setError('validation-problems')
setValidationIssues(data.validationProblems)
break
default:
setError('error')
break
}
setStoppedOnFirstError(data.status === 'stopped-on-first-error')
}
return () => {
abortController.abort()
}
}, [
joinedOnce,
data,
alphaProgram,
labsProgram,
features,
hasCompileLogsEvents,
hasPremiumCompile,
isProjectOwner,
projectId,
setAutoCompile,
setClsiServerId,
setLogEntries,
setLogEntryAnnotations,
setPdfFile,
compiler,
])
// switch to logs if there's an error
useEffect(() => {
if (error) {
setShowLogs(true)
}
}, [error])
// whether there has been an autocompile linting error, if syntax validation is switched on
const autoCompileLintingError = Boolean(
autoCompile && syntaxValidation && hasLintingError
)
const codeCheckFailed = stopOnValidationError && autoCompileLintingError
// the project is available for auto-compiling
// (autocompile is enabled, the PDF preview is open, and the code check (if enabled) hasn't failed)
const canAutoCompile = Boolean(
autoCompile && pdfPreviewOpen && !codeCheckFailed
)
// show that the project has pending changes
const hasChanges = Boolean(canAutoCompile && uncompiled && compiledOnce)
// call the debounced autocompile function if the project is available for auto-compiling and it has changed
useEffect(() => {
if (canAutoCompile) {
if (changedAt > 0) {
compiler.debouncedAutoCompile()
}
} else {
compiler.debouncedAutoCompile.cancel()
}
}, [compiler, canAutoCompile, changedAt])
// cancel debounced recompile on unmount
useEffect(() => {
return () => {
compiler.debouncedAutoCompile.cancel()
}
}, [compiler])
// start a compile manually
const startCompile = useCallback(
options => {
setCompiledOnce(true)
compiler.compile(options)
},
[compiler, setCompiledOnce]
)
// stop a compile manually
const stopCompile = useCallback(() => {
compiler.stopCompile()
}, [compiler])
// clear the compile cache
const clearCache = useCallback(() => {
setClearingCache(true)
return compiler
.clearCache()
.then(() => {
setFileList(undefined)
setPdfFile(undefined)
})
.finally(() => {
setClearingCache(false)
})
}, [compiler])
const syncToEntry = useCallback(
(entry, keepCurrentView = false) => {
const result = findEntityByPath(entry.file)
if (result && result.type === 'doc') {
openDocWithId(result.entity._id, {
gotoLine: entry.line ?? undefined,
gotoColumn: entry.column ?? undefined,
keepCurrentView,
})
}
},
[findEntityByPath, openDocWithId]
)
// clear the cache then run a compile, triggered by a menu item
const recompileFromScratch = useCallback(() => {
clearCache().then(() => {
compiler.compile()
})
}, [clearCache, compiler])
// After a compile, the compiler sets `data.options` to the options that were
// used for that compile.
const lastCompileOptions = useMemo(() => data?.options || {}, [data])
useEffect(() => {
const listener = (event: Event) => {
setShowLogs((event as CustomEvent<boolean>).detail as boolean)
}
window.addEventListener('editor:show-logs', listener)
return () => {
window.removeEventListener('editor:show-logs', listener)
}
}, [])
const value = useMemo(
() => ({
animateCompileDropdownArrow,
autoCompile,
clearCache,
clearingCache,
clsiServerId,
codeCheckFailed,
compiling,
deliveryLatencies,
draft,
editedSinceCompileStarted,
error,
fileList,
hasChanges,
hasShortCompileTimeout,
highlights,
isProjectOwner,
lastCompileOptions,
logEntryAnnotations,
logEntries,
pdfDownloadUrl: pdfFile?.pdfDownloadUrl,
pdfFile,
pdfUrl: pdfFile?.pdfUrl,
pdfViewer,
position,
rawLog,
recompileFromScratch,
setAnimateCompileDropdownArrow,
setAutoCompile,
setCompiling,
setDraft,
setError,
setHasLintingError, // only for stories
setHighlights,
setPosition,
showCompileTimeWarning,
setShowCompileTimeWarning,
setShowLogs,
toggleLogs,
setStopOnFirstError,
setStopOnValidationError,
showLogs,
startCompile,
stopCompile,
stopOnFirstError,
stopOnValidationError,
stoppedOnFirstError,
uncompiled,
validationIssues,
firstRenderDone,
setChangedAt,
cleanupCompileResult,
syncToEntry,
recordAction,
}),
[
animateCompileDropdownArrow,
autoCompile,
clearCache,
clearingCache,
clsiServerId,
codeCheckFailed,
compiling,
deliveryLatencies,
draft,
editedSinceCompileStarted,
error,
fileList,
hasChanges,
hasShortCompileTimeout,
highlights,
isProjectOwner,
lastCompileOptions,
logEntries,
logEntryAnnotations,
position,
pdfFile,
pdfViewer,
rawLog,
recompileFromScratch,
setAnimateCompileDropdownArrow,
setAutoCompile,
setDraft,
setError,
setHasLintingError, // only for stories
setHighlights,
setPosition,
setShowCompileTimeWarning,
setStopOnFirstError,
setStopOnValidationError,
showCompileTimeWarning,
showLogs,
startCompile,
stopCompile,
stopOnFirstError,
stopOnValidationError,
stoppedOnFirstError,
uncompiled,
validationIssues,
firstRenderDone,
setChangedAt,
cleanupCompileResult,
setShowLogs,
toggleLogs,
syncToEntry,
recordAction,
]
)
return (
<LocalCompileContext.Provider value={value}>
{children}
</LocalCompileContext.Provider>
)
}
export function useLocalCompileContext() {
const context = useContext(LocalCompileContext)
if (!context) {
throw new Error(
'useLocalCompileContext is only available inside LocalCompileProvider'
)
}
return context
}

View File

@@ -0,0 +1,56 @@
import getMeta from '../../../utils/meta'
// When rendered without Angular, ide isn't defined. In that case we use
// a mock object that only has the required properties to pass proptypes
// checks and the values needed for the app. In the longer term, the mock
// object will replace ide completely.
export const getMockIde = () => {
return {
_id: getMeta('ol-project_id'),
$scope: {
$on: () => {},
$watch: () => {},
$applyAsync: () => {},
user: {},
project: {
_id: getMeta('ol-project_id'),
name: getMeta('ol-projectName'),
rootDocId: '',
members: [],
invites: [],
features: {
collaborators: 0,
compileGroup: 'standard',
trackChangesVisible: false,
references: false,
mendeley: false,
zotero: false,
},
publicAccessLevel: '',
owner: {
_id: '',
email: '',
},
},
permissionsLevel: 'readOnly',
editor: {
sharejs_doc: null,
showSymbolPalette: false,
toggleSymbolPalette: () => {},
},
ui: {
view: 'pdf',
chatOpen: false,
reviewPanelOpen: false,
leftMenuShown: false,
pdfLayout: 'flat',
},
pdf: {
uncompiled: true,
logEntryAnnotations: {},
},
settings: { syntaxValidation: false, pdfViewer: 'pdfjs' },
hasLintingError: false,
},
}
}

View File

@@ -0,0 +1,25 @@
import { createContext, Dispatch, FC, SetStateAction, useState } from 'react'
export type NestableDropdownContextType = {
selected: string | null
setSelected: Dispatch<SetStateAction<string | null>>
menuId: string
}
export const NestableDropdownContext = createContext<
NestableDropdownContextType | undefined
>(undefined)
export const NestableDropdownContextProvider: FC<{ id: string }> = ({
id,
children,
}) => {
const [selected, setSelected] = useState<string | null>(null)
return (
<NestableDropdownContext.Provider
value={{ selected, setSelected, menuId: id }}
>
{children}
</NestableDropdownContext.Provider>
)
}

View File

@@ -0,0 +1,98 @@
import { FC, createContext, useContext, useMemo, useState } from 'react'
import useScopeValue from '../hooks/use-scope-value'
import getMeta from '@/utils/meta'
import { ProjectContextValue } from './types/project-context'
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
const ProjectContext = createContext<ProjectContextValue | undefined>(undefined)
export function useProjectContext() {
const context = useContext(ProjectContext)
if (!context) {
throw new Error(
'useProjectContext is only available inside ProjectProvider'
)
}
return context
}
// when the provider is created the project is still not added to the Angular
// scope. A few props are populated to prevent errors in existing React
// components
const projectFallback = {
_id: getMeta('ol-project_id'),
name: '',
features: {},
}
export const ProjectProvider: FC = ({ children }) => {
const [project] = useScopeValue('project')
const joinedOnce = !!project
const {
_id,
compiler,
imageName,
name,
rootDoc_id: rootDocId,
members,
invites,
features,
publicAccesLevel: publicAccessLevel,
owner,
trackChangesState,
mainBibliographyDoc_id: mainBibliographyDocId,
} = project || projectFallback
const [projectSnapshot] = useState(() => new ProjectSnapshot(_id))
const tags = useMemo(
() =>
(getMeta('ol-projectTags') || [])
// `tag.name` data may be null for some old users
.map((tag: any) => ({ ...tag, name: tag.name ?? '' })),
[]
)
const value = useMemo(() => {
return {
_id,
compiler,
imageName,
name,
rootDocId,
members,
invites,
features,
publicAccessLevel,
owner,
tags,
trackChangesState,
mainBibliographyDocId,
projectSnapshot,
joinedOnce,
}
}, [
_id,
compiler,
imageName,
name,
rootDocId,
members,
invites,
features,
publicAccessLevel,
owner,
tags,
trackChangesState,
mainBibliographyDocId,
projectSnapshot,
joinedOnce,
])
return (
<ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>
)
}

View File

@@ -0,0 +1,56 @@
import { createContext, FC, useContext, useMemo } from 'react'
import getMeta from '../../utils/meta'
import { SplitTestInfo } from '../../../../types/split-test'
export const SplitTestContext = createContext<
| {
splitTestVariants: Record<string, string>
splitTestInfo: Record<string, SplitTestInfo>
}
| undefined
>(undefined)
export const SplitTestProvider: FC = ({ children }) => {
const value = useMemo(
() => ({
splitTestVariants: getMeta('ol-splitTestVariants') || {},
splitTestInfo: getMeta('ol-splitTestInfo') || {},
}),
[]
)
return (
<SplitTestContext.Provider value={value}>
{children}
</SplitTestContext.Provider>
)
}
export function useSplitTestContext() {
const context = useContext(SplitTestContext)
if (!context) {
throw new Error(
'useSplitTestContext is only available within SplitTestProvider'
)
}
return context
}
export function useFeatureFlag(name: string) {
const { splitTestVariants } = useSplitTestContext()
return splitTestVariants[name] === 'enabled'
}
export function useSplitTest(name: string): {
variant: string | undefined
info: SplitTestInfo | undefined
} {
const { splitTestVariants, splitTestInfo } = useSplitTestContext()
return {
variant: splitTestVariants[name],
info: splitTestInfo[name],
}
}

View File

@@ -0,0 +1,56 @@
import { UserId } from '../../../../../types/user'
import { PublicAccessLevel } from '../../../../../types/public-access-level'
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
export type ProjectContextMember = {
_id: UserId
privileges: 'readOnly' | 'readAndWrite' | 'review'
email: string
first_name: string
last_name: string
pendingEditor?: boolean
pendingReviewer?: boolean
}
export type ProjectContextValue = {
_id: string
name: string
rootDocId?: string
mainBibliographyDocId?: string
compiler: string
imageName: string
members: ProjectContextMember[]
invites: ProjectContextMember[]
features: {
collaborators?: number
compileGroup?: 'alpha' | 'standard' | 'priority'
trackChanges?: boolean
trackChangesVisible?: boolean
references?: boolean
mendeley?: boolean
zotero?: boolean
versioning?: boolean
gitBridge?: boolean
referencesSearch?: boolean
github?: boolean
}
publicAccessLevel?: PublicAccessLevel
owner: {
_id: UserId
email: string
first_name: string
last_name: string
privileges: string
signUpDate: string
}
tags: {
_id: string
name: string
color?: string
}[]
trackChangesState: boolean | Record<UserId | '__guests__', boolean>
projectSnapshot: ProjectSnapshot
joinedOnce: boolean
}
export type ProjectContextUpdateValue = Partial<ProjectContextValue>

View File

@@ -0,0 +1,38 @@
export interface WritefullEvents {
'writefull-login-complete': {
method: 'email-password' | 'login-with-overleaf'
}
'writefull-received-suggestions': { numberOfSuggestions: number }
'writefull-register-as-auto-account': { email: string }
'writefull-shared-analytics': { eventName: string; segmentation: object }
'writefull-ai-assist-show-paywall': { origin?: string }
}
type InsertPosition = {
parentSelector: string
insertBeforeSelector?: string
}
export interface WritefullAPI {
init({
toolbarPosition,
iconPosition,
hasAgreedToTOS,
overleafUserId,
}: {
toolbarPosition: InsertPosition
iconPosition: InsertPosition
hasAgreedToTOS: boolean
overleafUserId: string
}): Promise<void>
addEventListener<eventName extends keyof WritefullEvents>(
name: eventName,
callback: (detail: WritefullEvents[eventName]) => void
): void
removeEventListener<eventName extends keyof WritefullEvents>(
name: eventName,
callback: (detail: WritefullEvents[eventName]) => void
): void
openTableGenerator(): void
openEquationGenerator(): void
}

View File

@@ -0,0 +1,25 @@
import { createContext, FC, useContext, useMemo } from 'react'
import getMeta from '../../utils/meta'
import { LoggedOutUser, User } from '../../../../types/user'
export const UserContext = createContext<User | LoggedOutUser | undefined>(
undefined
)
export const UserProvider: FC = ({ children }) => {
const user = useMemo(() => getMeta('ol-user'), [])
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}
export function useUserContext() {
const context = useContext(UserContext)
if (!context) {
throw new Error(
'useUserContext is only available inside UserContext, or `ol-user` meta is not defined'
)
}
return context
}

View File

@@ -0,0 +1,93 @@
import {
createContext,
useContext,
useMemo,
Dispatch,
SetStateAction,
FC,
useState,
useEffect,
} from 'react'
import { UserSettings, Keybindings } from '../../../../types/user-settings'
import getMeta from '@/utils/meta'
import useScopeValue from '@/shared/hooks/use-scope-value'
import { userStyles } from '../utils/styles'
const defaultSettings: UserSettings = {
pdfViewer: 'pdfjs',
autoComplete: true,
autoPairDelimiters: true,
syntaxValidation: false,
editorTheme: 'textmate',
overallTheme: '',
mode: 'default',
fontSize: 12,
fontFamily: 'monaco',
lineHeight: 'normal',
mathPreview: true,
referencesSearchMode: 'advanced',
enableNewEditor: true,
}
type UserSettingsContextValue = {
userSettings: UserSettings
setUserSettings: Dispatch<
SetStateAction<UserSettingsContextValue['userSettings']>
>
}
type ScopeSettings = {
overallTheme: 'light' | 'dark'
keybindings: Keybindings
fontSize: number
fontFamily: string
lineHeight: number
}
export const UserSettingsContext = createContext<
UserSettingsContextValue | undefined
>(undefined)
export const UserSettingsProvider: FC = ({ children }) => {
const [userSettings, setUserSettings] = useState<UserSettings>(
() => getMeta('ol-userSettings') || defaultSettings
)
// update the global scope 'settings' value, for extensions
const [, setScopeSettings] = useScopeValue<ScopeSettings>('settings')
useEffect(() => {
const { fontFamily, lineHeight } = userStyles(userSettings)
setScopeSettings({
overallTheme: userSettings.overallTheme === 'light-' ? 'light' : 'dark',
keybindings: userSettings.mode === 'none' ? 'default' : userSettings.mode,
fontFamily,
lineHeight,
fontSize: userSettings.fontSize,
})
}, [setScopeSettings, userSettings])
const value = useMemo<UserSettingsContextValue>(
() => ({
userSettings,
setUserSettings,
}),
[userSettings, setUserSettings]
)
return (
<UserSettingsContext.Provider value={value}>
{children}
</UserSettingsContext.Provider>
)
}
export function useUserSettingsContext() {
const context = useContext(UserSettingsContext)
if (!context) {
throw new Error(
'useUserSettingsContext is only available inside UserSettingsProvider'
)
}
return context
}

View File

@@ -0,0 +1,101 @@
import { useCallback, useState } from 'react'
import * as eventTracking from '@/infrastructure/event-tracking'
import { postJSON } from '@/infrastructure/fetch-json'
import { debugConsole } from '@/utils/debugging'
import { useEditorContext } from '@/shared/context/editor-context'
export const useTutorial = (
tutorialKey: string,
eventData: Record<string, any> = {}
) => {
const [showPopup, setShowPopup] = useState(false)
const { deactivateTutorial, currentPopup, setCurrentPopup } =
useEditorContext()
const completeTutorial = useCallback(
async ({
event = 'promo-click',
action = 'complete',
...rest
}: {
event: 'promo-click' | 'promo-dismiss'
action: 'complete' | 'postpone'
} & Record<string, any>) => {
eventTracking.sendMB(event, { ...eventData, ...rest })
try {
await postJSON(`/tutorial/${tutorialKey}/${action}`)
} catch (err) {
debugConsole.error(err)
}
setShowPopup(false)
deactivateTutorial(tutorialKey)
},
[deactivateTutorial, eventData, tutorialKey]
)
const dismissTutorial = useCallback(async () => {
await completeTutorial({
event: 'promo-dismiss',
action: 'complete',
})
}, [completeTutorial])
const maybeLater = useCallback(async () => {
await completeTutorial({
event: 'promo-click',
action: 'postpone',
button: 'maybe-later',
})
}, [completeTutorial])
// try to show the popup if we don't already have one showing, returns true if it can show, false if it can't
const tryShowingPopup = useCallback(
(eventName: string = 'promo-prompt') => {
if (currentPopup === null) {
setCurrentPopup(tutorialKey)
setShowPopup(true)
eventTracking.sendMB(eventName, eventData)
return true
}
return false
},
[currentPopup, setCurrentPopup, tutorialKey, eventData]
)
const clearPopup = useCallback(() => {
// popups should only clear themselves, in cases they need to cleanup or shouldnt show anymore
// allow forcing the clear if needed, eg: higher prio alert needs to show
if (currentPopup === tutorialKey) {
setCurrentPopup(null)
setShowPopup(false)
}
}, [setCurrentPopup, setShowPopup, currentPopup, tutorialKey])
const clearAndShow = useCallback(
(eventName: string = 'promo-prompt') => {
setCurrentPopup(tutorialKey)
setShowPopup(true)
eventTracking.sendMB(eventName, eventData)
},
[setCurrentPopup, setShowPopup, tutorialKey, eventData]
)
const hideUntilReload = useCallback(() => {
clearPopup()
deactivateTutorial(tutorialKey)
}, [clearPopup, deactivateTutorial, tutorialKey])
return {
completeTutorial,
dismissTutorial,
maybeLater,
tryShowingPopup,
clearPopup,
clearAndShow,
showPopup,
hideUntilReload,
}
}
export default useTutorial

View File

@@ -0,0 +1,14 @@
import 'abort-controller/polyfill'
import { useEffect, useState } from 'react'
export default function useAbortController() {
const [controller] = useState(() => new AbortController())
useEffect(() => {
return () => {
controller.abort()
}
}, [controller])
return controller
}

View File

@@ -0,0 +1,82 @@
import * as React from 'react'
import useSafeDispatch from './use-safe-dispatch'
import { Nullable } from '../../../../types/utils'
import { FetchError } from '../../infrastructure/fetch-json'
type State<T, E> = {
status: 'idle' | 'pending' | 'resolved' | 'rejected'
data: Nullable<T>
error: Nullable<E>
}
type Action<T, E> = Partial<State<T, E>>
const defaultInitialState: State<null, null> = {
status: 'idle',
data: null,
error: null,
}
function useAsync<T = any, E extends Error | FetchError = Error>(
initialState?: Partial<State<T, E>>
) {
const initialStateRef = React.useRef({
...defaultInitialState,
...initialState,
})
const [{ status, data, error }, setState] = React.useReducer(
(state: State<T, E>, action: Action<T, E>) => ({ ...state, ...action }),
initialStateRef.current
)
const safeSetState = useSafeDispatch(setState)
const setData = React.useCallback(
(data: Nullable<T>) => safeSetState({ data, status: 'resolved' }),
[safeSetState]
)
const setError = React.useCallback(
(error: Nullable<E>) => safeSetState({ error, status: 'rejected' }),
[safeSetState]
)
const reset = React.useCallback(
() => safeSetState(initialStateRef.current),
[safeSetState]
)
const runAsync = React.useCallback(
(promise: Promise<T>) => {
safeSetState({ status: 'pending' })
return promise.then(
data => {
setData(data)
return data
},
error => {
setError(error)
return Promise.reject(error)
}
)
},
[safeSetState, setData, setError]
)
return {
isIdle: status === 'idle',
isLoading: status === 'pending',
isError: status === 'rejected',
isSuccess: status === 'resolved',
setData,
setError,
error,
status,
data,
runAsync,
reset,
}
}
export default useAsync
export type UseAsyncReturnType = ReturnType<typeof useAsync>

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
import { useLocation } from './use-location'
function useBookmarkableTabSet(defaultState) {
const location = useLocation()
const [activeTabState, setActiveTabState] = useState(() => {
const url = new URL(window.location.href)
return url.hash.slice(1) || defaultState
})
function setActiveTab(eventKey) {
setActiveTabState(eventKey)
location.assign(`#${eventKey}`)
}
useEffect(() => {
const handlePopstate = () => {
const newUrl = new URL(window.location.href)
setActiveTabState(newUrl.hash.slice(1) || defaultState)
}
window.addEventListener('popstate', handlePopstate)
return () => {
window.removeEventListener('popstate', handlePopstate)
}
})
return [activeTabState, setActiveTab]
}
export default useBookmarkableTabSet

View File

@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
let titleIsFlashing = false
let originalTitle = ''
let flashIntervalHandle: ReturnType<typeof setInterval>
function flashTitle(message: string) {
if (document.hasFocus() || titleIsFlashing) {
return
}
function swapTitle() {
if (window.document.title === originalTitle) {
window.document.title = message
} else {
window.document.title = originalTitle
}
}
originalTitle = window.document.title
window.document.title = message
titleIsFlashing = true
flashIntervalHandle = setInterval(swapTitle, 800)
}
function stopFlashingTitle() {
if (!titleIsFlashing) {
return
}
clearInterval(flashIntervalHandle)
window.document.title = originalTitle
originalTitle = ''
titleIsFlashing = false
}
function setTitle(title: string) {
if (titleIsFlashing) {
originalTitle = title
} else {
window.document.title = title
}
}
function useBrowserWindow() {
const [hasFocus, setHasFocus] = useState(() => document.hasFocus())
useEffect(() => {
function handleFocusEvent() {
setHasFocus(true)
}
function handleBlurEvent() {
setHasFocus(false)
}
window.addEventListener('focus', handleFocusEvent)
window.addEventListener('blur', handleBlurEvent)
return () => {
window.removeEventListener('focus', handleFocusEvent)
window.removeEventListener('blur', handleBlurEvent)
}
}, [])
return { hasFocus, flashTitle, stopFlashingTitle, setTitle }
}
export default useBrowserWindow

View File

@@ -0,0 +1,21 @@
import { useCallback, useRef } from 'react'
export default function useCallbackHandlers() {
const handlersRef = useRef(new Set<(...arg: any[]) => void>())
const addHandler = useCallback((handler: (...args: any[]) => void) => {
handlersRef.current.add(handler)
}, [])
const deleteHandler = useCallback((handler: (...args: any[]) => void) => {
handlersRef.current.delete(handler)
}, [])
const callHandlers = useCallback((...args: any[]) => {
for (const handler of handlersRef.current) {
handler(...args)
}
}, [])
return { addHandler, deleteHandler, callHandlers }
}

View File

@@ -0,0 +1,38 @@
import importOverleafModules from '../../../macros/import-overleaf-module.macro'
import {
JSXElementConstructor,
useCallback,
useState,
type UIEvent,
} from 'react'
const [contactUsModalModules] = importOverleafModules('contactUsModal')
const ContactUsModal: JSXElementConstructor<{
show: boolean
handleHide: () => void
autofillProjectUrl: boolean
}> = contactUsModalModules?.import.default
export const useContactUsModal = (options = { autofillProjectUrl: true }) => {
const [show, setShow] = useState(false)
const hideModal = useCallback((event?: Event) => {
event?.preventDefault()
setShow(false)
}, [])
const showModal = useCallback((event?: Event | UIEvent) => {
event?.preventDefault()
setShow(true)
}, [])
const modal = ContactUsModal && (
<ContactUsModal
show={show}
handleHide={hideModal}
autofillProjectUrl={options.autofillProjectUrl}
/>
)
return { modal, hideModal, showModal }
}

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react'
/**
* @template T
* @param {T} value
* @param {number} delay
* @returns {T}
*/
export default function useDebounce<T>(value: T, delay = 0) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
window.clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}

View File

@@ -0,0 +1,16 @@
import { useEffect, useRef } from 'react'
import _ from 'lodash'
export default function useDeepCompareEffect<T>(
callback: () => void,
dependencies: T[]
) {
const ref = useRef<T[]>()
return useEffect(() => {
if (_.isEqual(dependencies, ref.current)) {
return
}
ref.current = dependencies
callback()
}, dependencies) // eslint-disable-line react-hooks/exhaustive-deps
}

View File

@@ -0,0 +1,52 @@
import { useCallback, useEffect } from 'react'
import { useDetachContext } from '../context/detach-context'
import getMeta from '../../utils/meta'
import { debugConsole } from '@/utils/debugging'
const debugPdfDetach = getMeta('ol-debugPdfDetach')
export default function useDetachAction(
actionName,
actionFunction,
senderRole,
targetRole
) {
const { role, broadcastEvent, addEventHandler, deleteEventHandler } =
useDetachContext()
const eventName = `action-${actionName}`
const triggerFn = useCallback(
(...args) => {
if (role === senderRole) {
broadcastEvent(eventName, { args })
} else {
actionFunction(...args)
}
},
[role, senderRole, eventName, actionFunction, broadcastEvent]
)
const handleActionEvent = useCallback(
message => {
if (message.event !== eventName) {
return
}
if (role !== targetRole) {
return
}
if (debugPdfDetach) {
debugConsole.warn(`Do ${actionFunction.name} on event ${eventName}`)
}
actionFunction(...message.data.args)
},
[role, targetRole, eventName, actionFunction]
)
useEffect(() => {
addEventHandler(handleActionEvent)
return () => deleteEventHandler(handleActionEvent)
}, [addEventHandler, deleteEventHandler, handleActionEvent])
return triggerFn
}

View File

@@ -0,0 +1,191 @@
import { useCallback, useState, useEffect, useRef } from 'react'
import { useDetachContext } from '../context/detach-context'
import getMeta from '../../utils/meta'
import { buildUrlWithDetachRole } from '../utils/url-helper'
import * as eventTracking from '../../infrastructure/event-tracking'
import usePreviousValue from './use-previous-value'
import { debugConsole } from '@/utils/debugging'
const debugPdfDetach = getMeta('ol-debugPdfDetach')
const LINKING_TIMEOUT = 60000
const RELINK_TIMEOUT = 10000
export default function useDetachLayout() {
const { role, setRole, broadcastEvent, addEventHandler, deleteEventHandler } =
useDetachContext()
// isLinking: when the tab expects to be linked soon (e.g. on detach)
const [isLinking, setIsLinking] = useState(false)
// isLinked: when the tab is linked to another tab (of different role)
const [isLinked, setIsLinked] = useState(false)
// isRedundant: when a second detacher tab is opened, the first becomes
// redundant
const [isRedundant, setIsRedundant] = useState(false)
const uiTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
if (debugPdfDetach) {
debugConsole.warn('Effect', { isLinked })
}
setIsLinking(false)
}, [isLinked, setIsLinking])
useEffect(() => {
if (debugPdfDetach) {
debugConsole.warn('Effect', { role, isLinked })
}
if (role === 'detached' && isLinked) {
eventTracking.sendMB('project-layout-detached')
}
}, [role, isLinked])
useEffect(() => {
if (uiTimeoutRef.current) {
clearTimeout(uiTimeoutRef.current)
}
if (role === 'detacher' && isLinked === false) {
// the detacher tab either a) disconnected from its detached tab(s), b)is
// loading and no detached tab(s) is connected yet or c) is detaching and
// waiting for the detached tab to connect. Start a timeout to put
// the tab back in non-detacher role in case no detached tab are connected
uiTimeoutRef.current = setTimeout(
() => {
setRole(null)
},
isLinking ? LINKING_TIMEOUT : RELINK_TIMEOUT
)
}
}, [role, isLinking, isLinked, setRole])
useEffect(() => {
if (debugPdfDetach) {
debugConsole.warn('Effect', { isLinking })
}
}, [isLinking])
const previousRole = usePreviousValue(role)
useEffect(() => {
if (previousRole && !role) {
eventTracking.sendMB('project-layout-reattached')
}
}, [previousRole, role])
const reattach = useCallback(() => {
broadcastEvent('reattach')
setRole(null)
setIsLinked(false)
}, [setRole, setIsLinked, broadcastEvent])
const detach = useCallback(() => {
setIsRedundant(false)
setRole('detacher')
setIsLinking(true)
window.open(buildUrlWithDetachRole('detached').toString(), '_blank')
}, [setRole, setIsLinking])
const handleEventForDetacherFromDetacher = useCallback(() => {
if (debugPdfDetach) {
debugConsole.warn(
'Duplicate detacher detected, turning into a regular editor again'
)
}
setIsRedundant(true)
setIsLinked(false)
setRole(null)
}, [setRole, setIsLinked])
const handleEventForDetacherFromDetached = useCallback(
message => {
switch (message.event) {
case 'connected':
broadcastEvent('up')
setIsLinked(true)
break
case 'up':
setIsLinked(true)
break
case 'closed':
setIsLinked(false)
break
}
},
[setIsLinked, broadcastEvent]
)
const handleEventForDetachedFromDetacher = useCallback(
message => {
switch (message.event) {
case 'connected':
broadcastEvent('up')
setIsLinked(true)
break
case 'up':
setIsLinked(true)
break
case 'closed':
setIsLinked(false)
break
case 'reattach':
setIsLinked(false) // set as unlinked, in case closing is not allowed
window.close()
break
}
},
[setIsLinked, broadcastEvent]
)
const handleEventForDetachedFromDetached = useCallback(
message => {
switch (message.event) {
case 'closed':
broadcastEvent('up')
break
}
},
[broadcastEvent]
)
const handleEvent = useCallback(
message => {
if (role === 'detacher') {
if (message.role === 'detacher') {
handleEventForDetacherFromDetacher()
} else if (message.role === 'detached') {
handleEventForDetacherFromDetached(message)
}
} else if (role === 'detached') {
if (message.role === 'detacher') {
handleEventForDetachedFromDetacher(message)
} else if (message.role === 'detached') {
handleEventForDetachedFromDetached(message)
}
}
},
[
role,
handleEventForDetacherFromDetacher,
handleEventForDetacherFromDetached,
handleEventForDetachedFromDetacher,
handleEventForDetachedFromDetached,
]
)
useEffect(() => {
addEventHandler(handleEvent)
return () => deleteEventHandler(handleEvent)
}, [addEventHandler, deleteEventHandler, handleEvent])
return {
reattach,
detach,
isLinked,
isLinking,
role,
isRedundant,
}
}

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react'
import useDetachState from './use-detach-state'
function useDetachStateWatcher(key, stateValue, senderRole, targetRole) {
const [value, setValue] = useDetachState(
key,
stateValue,
senderRole,
targetRole
)
useEffect(() => {
setValue(stateValue)
}, [setValue, stateValue])
return [value, setValue]
}
export default useDetachStateWatcher

View File

@@ -0,0 +1,63 @@
import { useEffect, useState, useCallback } from 'react'
import { useDetachContext } from '../context/detach-context'
import getMeta from '../../utils/meta'
import { debugConsole } from '@/utils/debugging'
const debugPdfDetach = getMeta('ol-debugPdfDetach')
export default function useDetachState(
key,
defaultValue,
senderRole,
targetRole
) {
const [value, setValue] = useState(defaultValue)
const {
role,
broadcastEvent,
lastDetachedConnectedAt,
addEventHandler,
deleteEventHandler,
} = useDetachContext()
const eventName = `state-${key}`
// lastDetachedConnectedAt is added as a dependency in order to re-broadcast
// all states when a new detached tab connects
useEffect(() => {
if (role === senderRole) {
broadcastEvent(eventName, { value })
}
}, [
role,
senderRole,
eventName,
value,
broadcastEvent,
lastDetachedConnectedAt,
])
const handleStateEvent = useCallback(
message => {
if (message.event !== eventName) {
return
}
if (role !== targetRole) {
return
}
if (debugPdfDetach) {
debugConsole.warn(`Set ${message.data.value} for ${eventName}`)
}
setValue(message.data.value)
},
[role, targetRole, eventName, setValue]
)
useEffect(() => {
addEventHandler(handleStateEvent)
return () => deleteEventHandler(handleStateEvent)
}, [addEventHandler, deleteEventHandler, handleStateEvent])
return [value, setValue]
}

View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react'
// The use of the EventListener type means that this can only be used for
// built-in DOM event types rather than custom events.
// There are libraries such as usehooks-ts that provide hooks like this with
// support for type-safe custom events that we may want to look into.
export default function useDomEventListener(
eventTarget: EventTarget,
eventName: string,
listener: EventListener
) {
useEffect(() => {
eventTarget.addEventListener(eventName, listener)
return () => {
eventTarget.removeEventListener(eventName, listener)
}
}, [eventTarget, eventName, listener])
}

View File

@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { findDOMNode } from 'react-dom'
export default function useDropdown(defaultOpen = false) {
const [open, setOpen] = useState(defaultOpen)
// store the dropdown node for use in the "click outside" event listener
const ref = useRef<ReturnType<typeof findDOMNode>>(null)
// react-bootstrap v0.x passes `component` instead of `node` to the ref callback
const handleRef = useCallback(
component => {
if (component) {
// eslint-disable-next-line react/no-find-dom-node
ref.current = findDOMNode(component)
}
},
[ref]
)
// prevent a click on the dropdown toggle propagating to the original handler
const handleClick = useCallback(event => {
event.stopPropagation()
}, [])
// handle dropdown toggle
const handleToggle = useCallback(value => {
setOpen(Boolean(value))
}, [])
// close the dropdown on click outside the dropdown
const handleDocumentClick = useCallback(
event => {
if (ref.current && !ref.current.contains(event.target)) {
setOpen(false)
}
},
[ref]
)
// add/remove listener for click anywhere in document
useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleDocumentClick)
}
return () => {
document.removeEventListener('mousedown', handleDocumentClick)
}
}, [open, handleDocumentClick])
// return props for the Dropdown component
return { ref: handleRef, onClick: handleClick, onToggle: handleToggle, open }
}

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react'
/**
* @param {string} eventName
* @param {function} [listener]
*/
export default function useEventListener(eventName, listener) {
useEffect(() => {
window.addEventListener(eventName, listener)
return () => {
window.removeEventListener(eventName, listener)
}
}, [eventName, listener])
}

View File

@@ -0,0 +1,67 @@
import { useRef, useState, useLayoutEffect } from 'react'
import classNames from 'classnames'
function useExpandCollapse({
initiallyExpanded = false,
collapsedSize = 0,
dimension = 'height',
classes = { container: '', containerCollapsed: '' },
} = {}) {
const ref = useRef<{ scrollHeight: number; scrollWidth: number }>()
const [isExpanded, setIsExpanded] = useState(initiallyExpanded)
const [sizing, setSizing] = useState<{
size: number | null
needsExpandCollapse: boolean | null
}>({
size: null,
needsExpandCollapse: null,
})
useLayoutEffect(() => {
const expandCollapseEl = ref.current
if (expandCollapseEl) {
const expandedSize =
dimension === 'height'
? expandCollapseEl.scrollHeight
: expandCollapseEl.scrollWidth
const needsExpandCollapse = expandedSize > collapsedSize
if (isExpanded) {
setSizing({ size: expandedSize, needsExpandCollapse })
} else {
setSizing({
size: needsExpandCollapse ? collapsedSize : expandedSize,
needsExpandCollapse,
})
}
}
}, [isExpanded, collapsedSize, dimension])
const expandableClasses = classNames(
'expand-collapse-container',
classes.container,
!isExpanded ? classes.containerCollapsed : null
)
function handleToggle() {
setIsExpanded(!isExpanded)
}
return {
isExpanded,
needsExpandCollapse: sizing.needsExpandCollapse,
expandableProps: {
ref,
style: {
[dimension === 'height' ? 'height' : 'width']: `${sizing.size}px`,
},
className: expandableClasses,
},
toggleProps: {
onClick: handleToggle,
},
}
}
export default useExpandCollapse

View File

@@ -0,0 +1,14 @@
import { useLayoutEffect, useRef } from 'react'
export default function useIsMounted() {
const mounted = useRef(false)
useLayoutEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [mounted])
return mounted
}

View File

@@ -0,0 +1,52 @@
import { useCallback, useMemo } from 'react'
import useIsMounted from './use-is-mounted'
import { location } from '@/shared/components/location'
export const useLocation = () => {
const isMounted = useIsMounted()
const assign = useCallback(
url => {
if (isMounted.current) {
location.assign(url)
}
},
[isMounted]
)
const replace = useCallback(
url => {
if (isMounted.current) {
location.replace(url)
}
},
[isMounted]
)
const reload = useCallback(() => {
if (isMounted.current) {
location.reload()
}
}, [isMounted])
const setHash = useCallback(
(hash: string) => {
if (isMounted.current) {
location.setHash(hash)
}
},
[isMounted]
)
const toString = useCallback(() => {
if (isMounted.current) {
return location.toString()
}
return ''
}, [isMounted])
return useMemo(
() => ({ assign, replace, reload, setHash, toString }),
[assign, replace, reload, setHash, toString]
)
}

View File

@@ -0,0 +1,12 @@
import { NestableDropdownContext } from '@/shared/context/nestable-dropdown-context'
import { useContext } from 'react'
export const useNestableDropdown = () => {
const context = useContext(NestableDropdownContext)
if (context === undefined) {
throw new Error(
'useNestableDropdown must be used within a NestableDropdownContextProvider'
)
}
return context
}

View File

@@ -0,0 +1,56 @@
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'
import customLocalStorage from '@/infrastructure/local-storage'
import { debugConsole } from '@/utils/debugging'
export type PdfScrollPosition = Record<string, any> | undefined
export const usePdfScrollPosition = (
lastCompileRootDocId: string | null | undefined
): [PdfScrollPosition, Dispatch<SetStateAction<PdfScrollPosition>>] => {
// scroll position of the PDF
const [position, setPosition] = useState<PdfScrollPosition>()
const lastCompileRootDocIdRef = useRef<string | null | undefined>(
lastCompileRootDocId
)
useEffect(() => {
lastCompileRootDocIdRef.current = lastCompileRootDocId
}, [lastCompileRootDocId])
const initialScrollPositionRef = useRef<PdfScrollPosition | null>(null)
// load the stored PDF scroll position when the compiled root doc changes
useEffect(() => {
if (lastCompileRootDocId) {
const position = customLocalStorage.getItem(
`pdf.position.${lastCompileRootDocId}`
)
if (position) {
debugConsole.log('loaded position for', lastCompileRootDocId, position)
initialScrollPositionRef.current = position
setPosition(position)
}
}
}, [lastCompileRootDocId])
// store the current root doc's PDF scroll position when it changes
useEffect(() => {
if (
lastCompileRootDocIdRef.current &&
position &&
position !== initialScrollPositionRef.current
) {
debugConsole.log(
'storing position for',
lastCompileRootDocIdRef.current,
position
)
customLocalStorage.setItem(
`pdf.position.${lastCompileRootDocIdRef.current}`,
position
)
}
}, [position])
return [position, setPosition]
}

View File

@@ -0,0 +1,97 @@
import {
useState,
useCallback,
useEffect,
SetStateAction,
Dispatch,
} from 'react'
import _ from 'lodash'
import localStorage from '../../infrastructure/local-storage'
import { debugConsole } from '@/utils/debugging'
const safeStringify = (value: unknown) => {
try {
return JSON.stringify(value)
} catch (e) {
debugConsole.error('double stringify exception', e)
return null
}
}
const safeParse = (value: string) => {
try {
return JSON.parse(value)
} catch (e) {
debugConsole.error('double parse exception', e)
return null
}
}
function usePersistedState<T = any>(
key: string,
defaultValue?: T,
listen = false,
// The option below is for backward compatibility with Angular
// which sometimes stringifies the values twice
doubleStringifyAndParse = false
): [T, Dispatch<SetStateAction<T>>] {
const getItem = useCallback(
(key: string) => {
const item = localStorage.getItem(key)
return doubleStringifyAndParse ? safeParse(item) : item
},
[doubleStringifyAndParse]
)
const setItem = useCallback(
(key: string, value: unknown) => {
const val = doubleStringifyAndParse ? safeStringify(value) : value
localStorage.setItem(key, val)
},
[doubleStringifyAndParse]
)
const [value, setValue] = useState<T>(() => {
return getItem(key) ?? defaultValue
})
const updateFunction = useCallback(
(newValue: SetStateAction<T>) => {
setValue(value => {
const actualNewValue = _.isFunction(newValue)
? newValue(value)
: newValue
if (actualNewValue === defaultValue) {
localStorage.removeItem(key)
} else {
setItem(key, actualNewValue)
}
return actualNewValue
})
},
[key, defaultValue, setItem]
)
useEffect(() => {
if (listen) {
const listener = (event: StorageEvent) => {
if (event.key === key) {
// note: this value is read via getItem rather than from event.newValue
// because getItem handles deserializing the JSON that's stored in localStorage.
setValue(getItem(key) ?? defaultValue)
}
}
window.addEventListener('storage', listener)
return () => {
window.removeEventListener('storage', listener)
}
}
}, [defaultValue, key, listen, getItem])
return [value, updateFunction]
}
export default usePersistedState

View File

@@ -0,0 +1,9 @@
import { useEffect, useRef } from 'react'
export default function usePreviousValue<T>(value: T) {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
})
return ref.current
}

View File

@@ -0,0 +1,20 @@
import { useRef } from 'react'
import ReCAPTCHA from 'react-google-recaptcha'
export const useRecaptcha = () => {
const ref = useRef<ReCAPTCHA | null>(null)
const getReCaptchaToken = async (): Promise<
ReturnType<ReCAPTCHA['executeAsync']>
> => {
if (!ref.current) {
return null
}
// Reset the reCAPTCHA before each submission.
// The reCAPTCHA token is meant to be used once per validation
ref.current.reset()
return await ref.current.executeAsync()
}
return { ref, getReCaptchaToken }
}

View File

@@ -0,0 +1,36 @@
import { useRef, useEffect, useCallback, useState } from 'react'
export function useRefWithAutoFocus<T extends HTMLElement = HTMLElement>() {
const autoFocusedRef = useRef<T>(null)
const [hasFocused, setHasFocused] = useState(false)
const resetAutoFocus = useCallback(() => setHasFocused(false), [])
// Run on every render but use hasFocused to ensure that the autofocus only
// happens once
useEffect(() => {
if (hasFocused) {
return
}
let request: number | null = null
if (autoFocusedRef.current) {
request = window.requestAnimationFrame(() => {
if (autoFocusedRef.current) {
autoFocusedRef.current.focus()
setHasFocused(true)
request = null
}
})
}
// Cancel a pending autofocus prior to autofocus actually happening on
// render, and on unmount
return () => {
if (request !== null) {
window.cancelAnimationFrame(request)
}
}
})
return { autoFocusedRef, resetAutoFocus }
}

View File

@@ -0,0 +1,54 @@
import { useState, useCallback, useEffect } from 'react'
import customLocalStorage from '@/infrastructure/local-storage'
import usePersistedState from '@/shared/hooks/use-persisted-state'
/**
* @typedef {Object} RemindMeLater
* @property {boolean} stillDissmissed - whether the user has dismissed the notification, or if the notification is still withing the 1 day reminder period
* @property {function} remindThemLater - saves that the user has dismissed the notification for 1 day in local storage
* @property {function} saveDismissed - saves that the user has dismissed the notification in local storage
*/
/**
*
* @param {string} key the unique key used to keep track of what popup is currently being shown (usually the component name)
* @param {string} notificationLocation what page the notification originates from (eg, the editor page, project page, etc)
* @returns {RemindMeLater} an object containing whether the notification is still dismissed, and functions to remind the user later or save that they have dismissed the notification
*/
export default function useRemindMeLater(
key: string,
notificationLocation: string = 'editor'
) {
const [dismissedUntil, setDismissedUntil] = usePersistedState<
Date | undefined
>(`${notificationLocation}.has_dismissed_${key}_until`)
const [stillDissmissed, setStillDismissed] = useState(true)
useEffect(() => {
const alertDismissed = customLocalStorage.getItem(
`${notificationLocation}.has_dismissed_${key}`
)
const isStillDismissed = Boolean(
dismissedUntil && new Date(dismissedUntil) > new Date()
)
setStillDismissed(alertDismissed || isStillDismissed)
}, [setStillDismissed, dismissedUntil, key, notificationLocation])
const remindThemLater = useCallback(() => {
const until = new Date()
until.setDate(until.getDate() + 1) // 1 day
setDismissedUntil(until)
}, [setDismissedUntil])
const saveDismissed = useCallback(() => {
customLocalStorage.setItem(
`${notificationLocation}.has_dismissed_${key}`,
true
)
}, [key, notificationLocation])
return { stillDissmissed, remindThemLater, saveDismissed }
}

View File

@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef } from 'react'
export const useResizeObserver = (handleResize: (element: Element) => void) => {
const resizeRef = useRef<{
element: Element
observer: ResizeObserver
} | null>(null)
const elementRef = useCallback(
(element: Element | null) => {
if (element && 'ResizeObserver' in window) {
if (resizeRef.current) {
resizeRef.current.observer.unobserve(resizeRef.current.element)
}
const observer = new ResizeObserver(([entry]) => {
handleResize(entry.target)
})
resizeRef.current = { element, observer }
observer.observe(element)
handleResize(element) // trigger the callback once
}
},
[handleResize]
)
useEffect(() => {
return () => {
if (resizeRef.current) {
resizeRef.current.observer.unobserve(resizeRef.current.element)
}
}
}, [])
return { elementRef, resizeRef }
}

View File

@@ -0,0 +1,113 @@
import { useState, useEffect, useRef } from 'react'
import usePersistedState from './use-persisted-state'
import { Nullable } from '../../../../types/utils'
type Pos = Nullable<{
x: number
}>
function useResizeBase(
state: [Pos, React.Dispatch<React.SetStateAction<Pos>>]
) {
const [mousePos, setMousePos] = state
const isResizingRef = useRef(false)
const handleRef = useRef<HTMLElement | null>(null)
const defaultHandleStyles = useRef<React.CSSProperties>({
cursor: 'col-resize',
userSelect: 'none',
})
useEffect(() => {
const handleMouseDown = function (e: MouseEvent) {
if (e.button !== 0) {
return
}
if (defaultHandleStyles.current.cursor) {
document.body.style.cursor = defaultHandleStyles.current.cursor
}
isResizingRef.current = true
}
const handle = handleRef.current
handle?.addEventListener('mousedown', handleMouseDown)
return () => {
handle?.removeEventListener('mousedown', handleMouseDown)
}
}, [])
useEffect(() => {
const handleMouseUp = function () {
document.body.style.cursor = 'default'
isResizingRef.current = false
}
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mouseup', handleMouseUp)
}
}, [])
useEffect(() => {
const handleMouseMove = function (e: MouseEvent) {
if (isResizingRef.current) {
setMousePos({ x: e.clientX })
}
}
document.addEventListener('mousemove', handleMouseMove)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
}
}, [setMousePos])
const getTargetProps = ({ style }: { style?: React.CSSProperties } = {}) => {
return {
style: {
...style,
},
}
}
const setHandleRef = (node: HTMLElement | null) => {
handleRef.current = node
}
const getHandleProps = ({ style }: { style?: React.CSSProperties } = {}) => {
if (style?.cursor) {
defaultHandleStyles.current.cursor = style.cursor
}
return {
style: {
...defaultHandleStyles.current,
...style,
},
ref: setHandleRef,
}
}
return <const>{
mousePos,
getHandleProps,
getTargetProps,
}
}
function useResize() {
const state = useState<Pos>(null)
return useResizeBase(state)
}
function usePersistedResize({ name }: { name: string }) {
const state = usePersistedState<Pos>(`resizeable-${name}`, null)
return useResizeBase(state)
}
export { useResize, usePersistedResize }

View File

@@ -0,0 +1,17 @@
import * as React from 'react'
import useIsMounted from './use-is-mounted'
function useSafeDispatch<T>(dispatch: React.Dispatch<T>) {
const mounted = useIsMounted()
return React.useCallback<(args: T) => void>(
action => {
if (mounted.current) {
dispatch(action)
}
},
[dispatch, mounted]
) as React.Dispatch<T>
}
export default useSafeDispatch

View File

@@ -0,0 +1,18 @@
import { useCallback } from 'react'
import { useIdeContext } from '../context/ide-context'
import { ScopeEventName } from '../../../../types/ide/scope-event-emitter'
import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
export default function useScopeEventEmitter<T extends ScopeEventName>(
eventName: T,
broadcast = true
) {
const { scopeEventEmitter } = useIdeContext()
return useCallback(
(...detail: IdeEvents[T]) => {
scopeEventEmitter.emit(eventName, broadcast, ...detail)
},
[scopeEventEmitter, eventName, broadcast]
)
}

View File

@@ -0,0 +1,15 @@
import { useEffect } from 'react'
import { useIdeContext } from '../context/ide-context'
import { ScopeEventName } from '../../../../types/ide/scope-event-emitter'
import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter'
export default function useScopeEventListener<T extends ScopeEventName>(
eventName: T,
listener: (event: Event, ...args: IdeEvents[T]) => void
) {
const { scopeEventEmitter } = useIdeContext()
useEffect(() => {
return scopeEventEmitter.on(eventName, listener)
}, [scopeEventEmitter, eventName, listener])
}

View File

@@ -0,0 +1,38 @@
import {
type Dispatch,
type SetStateAction,
useCallback,
useState,
} from 'react'
import { useIdeContext } from '../context/ide-context'
import _ from 'lodash'
/**
* Similar to `useScopeValue`, but instead of creating a two-way binding, only
* changes in react-> angular direction are propagated, with `value` remaining
* local and independent of its value in the Angular scope.
*
* The interface is compatible with React.useState(), including
* the option of passing a function to the setter.
*/
export default function useScopeValueSetterOnly<T = any>(
path: string, // dot '.' path of a property in the Angular scope.
defaultValue?: T
): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
const { scopeStore } = useIdeContext()
const [value, setValue] = useState<T | undefined>(defaultValue)
const scopeSetter = useCallback(
(newValue: SetStateAction<T | undefined>) => {
setValue(val => {
const actualNewValue = _.isFunction(newValue) ? newValue(val) : newValue
scopeStore.set(path, actualNewValue)
return actualNewValue
})
},
[path, scopeStore]
)
return [value, scopeSetter]
}

View File

@@ -0,0 +1,46 @@
import {
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useState,
} from 'react'
import _ from 'lodash'
import { useIdeContext } from '../context/ide-context'
/**
* Binds a property in an Angular scope making it accessible in a React
* component. The interface is compatible with React.useState(), including
* the option of passing a function to the setter.
*
* The generic type is not an actual guarantee because the value for a path is
* returned as undefined when there is nothing in the scope store for that path.
*/
export default function useScopeValue<T = any>(
path: string // dot '.' path of a property in the Angular scope
): [T, Dispatch<SetStateAction<T>>] {
const { scopeStore } = useIdeContext()
const [value, setValue] = useState<T>(() => scopeStore.get(path))
useEffect(() => {
return scopeStore.watch<T>(path, (newValue: T) => {
// NOTE: this is deliberately wrapped in a function,
// to avoid calling setValue directly with a value that's a function
setValue(() => newValue)
})
}, [path, scopeStore])
const scopeSetter = useCallback(
(newValue: SetStateAction<T>) => {
setValue(val => {
const actualNewValue = _.isFunction(newValue) ? newValue(val) : newValue
scopeStore.set(path, actualNewValue)
return actualNewValue
})
},
[path, scopeStore]
)
return [value, scopeSetter]
}

View File

@@ -0,0 +1,111 @@
import { useEffect, useState, useRef } from 'react'
const DEFAULT_TIMEOUT = 3000
const hash = window.location.hash.substring(1)
const events = <const>['keydown', 'touchmove', 'wheel']
const isKeyboardEvent = (event: Event): event is KeyboardEvent => {
return event.constructor.name === 'KeyboardEvent'
}
type UseScrollToIdOnLoadProps = {
timeout?: number
}
function UseScrollToIdOnLoad({
timeout = DEFAULT_TIMEOUT,
}: UseScrollToIdOnLoadProps = {}) {
const [offsetTop, setOffsetTop] = useState<number | null>(null)
const requestRef = useRef<number | null>(null)
const targetRef = useRef<HTMLElement | null>(null)
const cancelAnimationFrame = () => {
if (requestRef.current) {
window.cancelAnimationFrame(requestRef.current)
}
}
const cancelEventListeners = () => {
events.forEach(eventType => {
window.removeEventListener(eventType, eventListenersCallbackRef.current)
})
}
const eventListenersCallback = (
event: KeyboardEvent | TouchEvent | WheelEvent
) => {
const keys = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown'])
if (!isKeyboardEvent(event) || keys.has(event.key)) {
// Remove scroll checks
cancelAnimationFrame()
// Remove event listeners
cancelEventListeners()
}
}
const eventListenersCallbackRef = useRef(eventListenersCallback)
// Scroll to the target
useEffect(() => {
if (!offsetTop) {
return
}
window.scrollTo({
top: offsetTop,
})
}, [offsetTop])
// Bail out from scrolling automatically in `${timeout}` milliseconds
useEffect(() => {
if (!hash) {
return
}
setTimeout(() => {
cancelAnimationFrame()
cancelEventListeners()
}, timeout)
}, [timeout])
// Scroll to target by recursively looking for the target element
useEffect(() => {
if (!hash) {
return
}
const offsetTop = () => {
if (targetRef.current) {
setOffsetTop(targetRef.current.offsetTop)
} else {
targetRef.current = document.getElementById(hash)
}
requestRef.current = window.requestAnimationFrame(offsetTop)
}
requestRef.current = window.requestAnimationFrame(offsetTop)
return () => {
cancelAnimationFrame()
}
}, [])
// Set up the event listeners that will cancel the target element lookup
useEffect(() => {
if (!hash) {
return
}
events.forEach(eventType => {
window.addEventListener(eventType, eventListenersCallbackRef.current)
})
return () => {
cancelEventListeners()
}
}, [])
}
export default UseScrollToIdOnLoad

View File

@@ -0,0 +1,43 @@
import { useCallback } from 'react'
import { useDetachCompileContext as useCompileContext } from '../context/detach-compile-context'
import { useProjectContext } from '../context/project-context'
import * as eventTracking from '../../infrastructure/event-tracking'
type UseStopOnFirstErrorProps = {
eventSource?: string
}
export function useStopOnFirstError(opts: UseStopOnFirstErrorProps = {}) {
const { eventSource } = opts
const { stopOnFirstError, setStopOnFirstError } = useCompileContext()
const { _id: projectId } = useProjectContext()
type Opts = {
projectId: string
source?: UseStopOnFirstErrorProps['eventSource']
}
const enableStopOnFirstError = useCallback(() => {
if (!stopOnFirstError) {
const opts: Opts = { projectId }
if (eventSource) {
opts.source = eventSource
}
eventTracking.sendMB('stop-on-first-error-enabled', opts)
}
setStopOnFirstError(true)
}, [eventSource, projectId, stopOnFirstError, setStopOnFirstError])
const disableStopOnFirstError = useCallback(() => {
const opts: Opts = { projectId }
if (eventSource) {
opts.source = eventSource
}
if (stopOnFirstError) {
eventTracking.sendMB('stop-on-first-error-disabled', opts)
}
setStopOnFirstError(false)
}, [eventSource, projectId, stopOnFirstError, setStopOnFirstError])
return { enableStopOnFirstError, disableStopOnFirstError }
}

View File

@@ -0,0 +1,8 @@
import { useEditorContext } from '../context/editor-context'
function useViewerPermissions() {
const { permissionsLevel } = useEditorContext()
return permissionsLevel === 'readOnly'
}
export default useViewerPermissions

View File

@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import i18n from '@/i18n'
import { useTranslation } from 'react-i18next'
function useWaitForI18n() {
const { ready: isHookReady } = useTranslation()
const [isLocaleDataLoaded, setIsLocaleDataLoaded] = useState(false)
const [error, setError] = useState<Error>()
useEffect(() => {
i18n
.then(() => {
setIsLocaleDataLoaded(true)
})
.catch(error => {
setError(error)
})
}, [])
return {
isReady: isHookReady && isLocaleDataLoaded,
error,
}
}
export default useWaitForI18n

View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react'
import { useUserContext } from '@/shared/context/user-context'
import { useUserChannel } from './use-user-channel'
export const useBroadcastUser = () => {
const user = useUserContext()
const channel = useUserChannel()
useEffect(() => {
channel?.postMessage(user)
}, [channel, user])
}

View File

@@ -0,0 +1,16 @@
import { useEffect } from 'react'
import { useUserChannel } from './use-user-channel'
export const useReceiveUser = (
handleData: (data: Record<string, any>) => void
) => {
const channel = useUserChannel()
useEffect(() => {
const abortController = new AbortController()
channel?.addEventListener('message', ({ data }) => handleData(data), {
signal: abortController.signal,
})
return () => abortController.abort()
}, [channel, handleData])
}

View File

@@ -0,0 +1,15 @@
import { useEffect, useRef } from 'react'
export const useUserChannel = (): BroadcastChannel | null => {
const channelRef = useRef<BroadcastChannel | null>(null)
if (channelRef.current === null && 'BroadcastChannel' in window) {
channelRef.current = new BroadcastChannel('user')
}
useEffect(() => {
return () => channelRef.current?.close()
}, [])
return channelRef.current
}

View File

@@ -0,0 +1,21 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2673_2754)">
<path d="M11.894 9.3478C8.10162 8.49142 7.51419 7.90399 6.65781 4.11161C6.61857 3.93828 6.46429 3.81485 6.286 3.81485C6.10772 3.81485 5.95343 3.93828 5.91419 4.11161C5.05743 7.90399 4.47038 8.49142 0.678002 9.3478C0.504288 9.38742 0.380859 9.54133 0.380859 9.71961C0.380859 9.8979 0.504288 10.0518 0.678002 10.0914C4.47038 10.9482 5.05743 11.5356 5.91419 15.3276C5.95343 15.5009 6.10772 15.6244 6.286 15.6244C6.46429 15.6244 6.61857 15.5009 6.65781 15.3276C7.51457 11.5356 8.10162 10.9482 11.894 10.0914C12.0677 10.0518 12.1908 9.8979 12.1908 9.71961C12.1908 9.54133 12.0673 9.38742 11.894 9.3478Z" fill="url(#paint0_linear_2673_2754)"/>
<path d="M15.322 3.44343C13.306 2.98819 13.023 2.70514 12.5677 0.689523C12.5281 0.515809 12.3742 0.392761 12.1959 0.392761C12.0176 0.392761 11.8637 0.515809 11.8241 0.689523C11.3689 2.70514 11.0858 2.98819 9.0702 3.44343C8.89648 3.48305 8.77344 3.63695 8.77344 3.81524C8.77344 3.99352 8.89648 4.14743 9.0702 4.18705C11.0858 4.64228 11.3689 4.92533 11.8241 6.94133C11.8637 7.11467 12.0176 7.23809 12.1959 7.23809C12.3742 7.23809 12.5281 7.11467 12.5677 6.94133C13.023 4.92533 13.306 4.64228 15.322 4.18705C15.4953 4.14743 15.6188 3.99352 15.6188 3.81524C15.6188 3.63695 15.4953 3.48305 15.322 3.44343Z" fill="url(#paint1_linear_2673_2754)"/>
</g>
<defs>
<linearGradient id="paint0_linear_2673_2754" x1="12.1908" y1="3.81485" x2="-1.57528" y2="10.0509" gradientUnits="userSpaceOnUse">
<stop stop-color="#214475"/>
<stop offset="0.295154" stop-color="#254C84"/>
<stop offset="1" stop-color="#6597E0"/>
</linearGradient>
<linearGradient id="paint1_linear_2673_2754" x1="15.6188" y1="0.392762" x2="7.63952" y2="4.00726" gradientUnits="userSpaceOnUse">
<stop stop-color="#214475"/>
<stop offset="0.295154" stop-color="#254C84"/>
<stop offset="1" stop-color="#6597E0"/>
</linearGradient>
<clipPath id="clip0_2673_2754">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Some files were not shown because too many files have changed in this diff Show More