first commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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