first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user