first commit
This commit is contained in:
@@ -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
|
@@ -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
|
64
services/web/frontend/js/shared/components/beta-badge.tsx
Normal file
64
services/web/frontend/js/shared/components/beta-badge.tsx
Normal 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
|
@@ -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
|
28
services/web/frontend/js/shared/components/close.tsx
Normal file
28
services/web/frontend/js/shared/components/close.tsx
Normal 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
|
@@ -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>
|
||||
)
|
@@ -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'
|
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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" />,
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
||||
</>
|
||||
)
|
@@ -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)}</>
|
||||
}
|
||||
)
|
@@ -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>
|
||||
)
|
||||
}
|
7
services/web/frontend/js/shared/components/history.ts
Normal file
7
services/web/frontend/js/shared/components/history.ts
Normal 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)
|
||||
},
|
||||
}
|
10
services/web/frontend/js/shared/components/icon-checked.jsx
Normal file
10
services/web/frontend/js/shared/components/icon-checked.jsx
Normal 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
|
44
services/web/frontend/js/shared/components/icon.tsx
Normal file
44
services/web/frontend/js/shared/components/icon.tsx
Normal 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
|
26
services/web/frontend/js/shared/components/interstitial.tsx
Normal file
26
services/web/frontend/js/shared/components/interstitial.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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
|
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@@ -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} />
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
27
services/web/frontend/js/shared/components/location.js
Normal file
27
services/web/frontend/js/shared/components/location.js
Normal 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()
|
||||
},
|
||||
}
|
51
services/web/frontend/js/shared/components/material-icon.tsx
Normal file
51
services/web/frontend/js/shared/components/material-icon.tsx
Normal 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
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
@@ -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
|
126
services/web/frontend/js/shared/components/notification.tsx
Normal file
126
services/web/frontend/js/shared/components/notification.tsx
Normal 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
|
170
services/web/frontend/js/shared/components/pagination.jsx
Normal file
170
services/web/frontend/js/shared/components/pagination.jsx
Normal 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
|
38
services/web/frontend/js/shared/components/panel-heading.tsx
Normal file
38
services/web/frontend/js/shared/components/panel-heading.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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
|
23
services/web/frontend/js/shared/components/processing.jsx
Normal file
23
services/web/frontend/js/shared/components/processing.jsx
Normal 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')}…
|
||||
<Icon type="refresh" fw spin />
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
|
||||
Processing.propTypes = {
|
||||
isProcessing: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
export default Processing
|
42
services/web/frontend/js/shared/components/radio-chip.tsx
Normal file
42
services/web/frontend/js/shared/components/radio-chip.tsx
Normal 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
|
36
services/web/frontend/js/shared/components/recaptcha-2.tsx
Normal file
36
services/web/frontend/js/shared/components/recaptcha-2.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
220
services/web/frontend/js/shared/components/select.tsx
Normal file
220
services/web/frontend/js/shared/components/select.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
27
services/web/frontend/js/shared/components/stepper.tsx
Normal file
27
services/web/frontend/js/shared/components/stepper.tsx
Normal 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>
|
||||
)
|
||||
}
|
26
services/web/frontend/js/shared/components/switch.tsx
Normal file
26
services/web/frontend/js/shared/components/switch.tsx
Normal 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
|
@@ -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
|
@@ -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
|
@@ -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
|
@@ -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 />
|
||||
|
||||
{t('unlimited_projects')}
|
||||
</li>
|
||||
<li>
|
||||
<Check />
|
||||
|
||||
{t('collabs_per_proj', { collabcount: 'Multiple' })}
|
||||
</li>
|
||||
<li>
|
||||
<Check />
|
||||
|
||||
{t('full_doc_history')}
|
||||
</li>
|
||||
<li>
|
||||
<Check />
|
||||
|
||||
{t('sync_to_dropbox')}
|
||||
</li>
|
||||
<li>
|
||||
<Check />
|
||||
|
||||
{t('sync_to_github')}
|
||||
</li>
|
||||
<li>
|
||||
<Check />
|
||||
|
||||
{t('compile_larger_projects')}
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(UpgradeBenefits)
|
177
services/web/frontend/js/shared/components/upgrade-prompt.tsx
Normal file
177
services/web/frontend/js/shared/components/upgrade-prompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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
|
||||
}
|
155
services/web/frontend/js/shared/context/detach-context.tsx
Normal file
155
services/web/frontend/js/shared/context/detach-context.tsx
Normal 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
|
||||
}
|
263
services/web/frontend/js/shared/context/editor-context.tsx
Normal file
263
services/web/frontend/js/shared/context/editor-context.tsx
Normal 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
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
66
services/web/frontend/js/shared/context/ide-context.tsx
Normal file
66
services/web/frontend/js/shared/context/ide-context.tsx
Normal 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
|
||||
}
|
295
services/web/frontend/js/shared/context/layout-context.tsx
Normal file
295
services/web/frontend/js/shared/context/layout-context.tsx
Normal 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
|
||||
}
|
@@ -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
|
||||
}
|
56
services/web/frontend/js/shared/context/mock/mock-ide.js
Normal file
56
services/web/frontend/js/shared/context/mock/mock-ide.js
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
@@ -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>
|
||||
)
|
||||
}
|
98
services/web/frontend/js/shared/context/project-context.tsx
Normal file
98
services/web/frontend/js/shared/context/project-context.tsx
Normal 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>
|
||||
)
|
||||
}
|
@@ -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],
|
||||
}
|
||||
}
|
@@ -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>
|
@@ -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
|
||||
}
|
25
services/web/frontend/js/shared/context/user-context.tsx
Normal file
25
services/web/frontend/js/shared/context/user-context.tsx
Normal 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
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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
|
@@ -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
|
||||
}
|
82
services/web/frontend/js/shared/hooks/use-async.ts
Normal file
82
services/web/frontend/js/shared/hooks/use-async.ts
Normal 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>
|
@@ -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
|
68
services/web/frontend/js/shared/hooks/use-browser-window.ts
Normal file
68
services/web/frontend/js/shared/hooks/use-browser-window.ts
Normal 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
|
@@ -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 }
|
||||
}
|
@@ -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 }
|
||||
}
|
23
services/web/frontend/js/shared/hooks/use-debounce.ts
Normal file
23
services/web/frontend/js/shared/hooks/use-debounce.ts
Normal 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
|
||||
}
|
@@ -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
|
||||
}
|
52
services/web/frontend/js/shared/hooks/use-detach-action.js
Normal file
52
services/web/frontend/js/shared/hooks/use-detach-action.js
Normal 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
|
||||
}
|
191
services/web/frontend/js/shared/hooks/use-detach-layout.ts
Normal file
191
services/web/frontend/js/shared/hooks/use-detach-layout.ts
Normal 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,
|
||||
}
|
||||
}
|
@@ -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
|
63
services/web/frontend/js/shared/hooks/use-detach-state.js
Normal file
63
services/web/frontend/js/shared/hooks/use-detach-state.js
Normal 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]
|
||||
}
|
@@ -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])
|
||||
}
|
54
services/web/frontend/js/shared/hooks/use-dropdown.ts
Normal file
54
services/web/frontend/js/shared/hooks/use-dropdown.ts
Normal 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 }
|
||||
}
|
15
services/web/frontend/js/shared/hooks/use-event-listener.js
Normal file
15
services/web/frontend/js/shared/hooks/use-event-listener.js
Normal 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])
|
||||
}
|
67
services/web/frontend/js/shared/hooks/use-expand-collapse.ts
Normal file
67
services/web/frontend/js/shared/hooks/use-expand-collapse.ts
Normal 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
|
14
services/web/frontend/js/shared/hooks/use-is-mounted.ts
Normal file
14
services/web/frontend/js/shared/hooks/use-is-mounted.ts
Normal 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
|
||||
}
|
52
services/web/frontend/js/shared/hooks/use-location.ts
Normal file
52
services/web/frontend/js/shared/hooks/use-location.ts
Normal 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]
|
||||
)
|
||||
}
|
@@ -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
|
||||
}
|
@@ -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]
|
||||
}
|
97
services/web/frontend/js/shared/hooks/use-persisted-state.ts
Normal file
97
services/web/frontend/js/shared/hooks/use-persisted-state.ts
Normal 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
|
@@ -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
|
||||
}
|
20
services/web/frontend/js/shared/hooks/use-recaptcha.ts
Normal file
20
services/web/frontend/js/shared/hooks/use-recaptcha.ts
Normal 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 }
|
||||
}
|
@@ -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 }
|
||||
}
|
54
services/web/frontend/js/shared/hooks/use-remind-me-later.ts
Normal file
54
services/web/frontend/js/shared/hooks/use-remind-me-later.ts
Normal 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 }
|
||||
}
|
39
services/web/frontend/js/shared/hooks/use-resize-observer.ts
Normal file
39
services/web/frontend/js/shared/hooks/use-resize-observer.ts
Normal 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 }
|
||||
}
|
113
services/web/frontend/js/shared/hooks/use-resize.ts
Normal file
113
services/web/frontend/js/shared/hooks/use-resize.ts
Normal 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 }
|
17
services/web/frontend/js/shared/hooks/use-safe-dispatch.ts
Normal file
17
services/web/frontend/js/shared/hooks/use-safe-dispatch.ts
Normal 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
|
@@ -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]
|
||||
)
|
||||
}
|
@@ -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])
|
||||
}
|
@@ -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]
|
||||
}
|
46
services/web/frontend/js/shared/hooks/use-scope-value.ts
Normal file
46
services/web/frontend/js/shared/hooks/use-scope-value.ts
Normal 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]
|
||||
}
|
@@ -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
|
@@ -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 }
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
import { useEditorContext } from '../context/editor-context'
|
||||
|
||||
function useViewerPermissions() {
|
||||
const { permissionsLevel } = useEditorContext()
|
||||
return permissionsLevel === 'readOnly'
|
||||
}
|
||||
|
||||
export default useViewerPermissions
|
26
services/web/frontend/js/shared/hooks/use-wait-for-i18n.ts
Normal file
26
services/web/frontend/js/shared/hooks/use-wait-for-i18n.ts
Normal 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
|
@@ -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])
|
||||
}
|
@@ -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])
|
||||
}
|
@@ -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
|
||||
}
|
21
services/web/frontend/js/shared/svgs/ai-sparkle-text.svg
Normal file
21
services/web/frontend/js/shared/svgs/ai-sparkle-text.svg
Normal 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
Reference in New Issue
Block a user