overleaf/services/web/frontend/js/features/ide-react/context/outline-context.tsx
Alf Eaton c2b553e915 [ide-react] Improve file tree and outline components in the editor sidebar (#16225)
* Upgrade react-resizable-panels
* Add FileTreeOpenProvider
* Add OutlineProvider and OutlineContainer
* Convert Outline tests to Cypress

GitOrigin-RevId: afd9ae8190edf37642e36a4ffb331f1182c8982d
2023-12-18 09:03:53 +00:00

195 lines
4.8 KiB
TypeScript

import {
createContext,
Dispatch,
FC,
SetStateAction,
useCallback,
useContext,
useMemo,
useState,
} from 'react'
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
import useEventListener from '@/shared/hooks/use-event-listener'
import * as eventTracking from '@/infrastructure/event-tracking'
import useScopeValue from '@/shared/hooks/use-scope-value'
import isValidTeXFile from '@/main/is-valid-tex-file'
import localStorage from '@/infrastructure/local-storage'
import { useProjectContext } from '@/shared/context/project-context'
export type PartialFlatOutline = {
level: number
title: string
line: number
}[]
export type FlatOutlineState =
| {
items: PartialFlatOutline
partial: boolean
}
| undefined
const OutlineContext = createContext<
| {
flatOutline: FlatOutlineState
setFlatOutline: Dispatch<SetStateAction<FlatOutlineState>>
highlightedLine: number
jumpToLine: (lineNumber: number, syncToPdf: boolean) => void
canShowOutline: boolean
outlineExpanded: boolean
toggleOutlineExpanded: () => void
}
| undefined
>(undefined)
export const OutlineProvider: FC = ({ children }) => {
const [flatOutline, setFlatOutline] = useState<FlatOutlineState>(undefined)
const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
useState<number>(-1)
const [binaryFileOpened, setBinaryFileOpened] = useState<boolean>(false)
const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] =
useState<boolean>(false)
const [ignoreNextScroll, setIgnoreNextScroll] = useState<boolean>(false)
const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true)
useEventListener(
'file-view:file-opened',
useCallback(_ => {
setBinaryFileOpened(true)
}, [])
)
useEventListener(
'scroll:editor:update',
useCallback(
evt => {
if (ignoreNextScroll) {
setIgnoreNextScroll(false)
return
}
setCurrentlyHighlightedLine(evt.detail + 1)
},
[ignoreNextScroll]
)
)
useEventListener(
'cursor:editor:update',
useCallback(
evt => {
if (ignoreNextCursorUpdate) {
setIgnoreNextCursorUpdate(false)
return
}
setCurrentlyHighlightedLine(evt.detail.row + 1)
},
[ignoreNextCursorUpdate]
)
)
useEventListener(
'doc:after-opened',
useCallback(evt => {
if (evt.detail) {
setIgnoreNextCursorUpdate(true)
}
setBinaryFileOpened(false)
setIgnoreNextScroll(true)
}, [])
)
const jumpToLine = useCallback(
(lineNumber: number, syncToPdf: boolean) => {
setIgnoreNextScroll(true)
goToLineEmitter(lineNumber, 0, syncToPdf)
eventTracking.sendMB('outline-jump-to-line')
},
[goToLineEmitter]
)
const highlightedLine = useMemo(
() =>
closestSectionLineNumber(flatOutline?.items, currentlyHighlightedLine),
[flatOutline, currentlyHighlightedLine]
)
// TODO: update when the file is renamed
const [docName] = useScopeValue<string | null>('editor.open_doc_name')
const isTexFile = useMemo(
() => (docName ? isValidTeXFile(docName) : false),
[docName]
)
const { _id: projectId } = useProjectContext()
const storageKey = `file_outline.expanded.${projectId}`
const [outlineExpanded, setOutlineExpanded] = useState(
() => localStorage.getItem(storageKey) !== false
)
const canShowOutline = isTexFile && !binaryFileOpened
const toggleOutlineExpanded = useCallback(() => {
if (canShowOutline) {
localStorage.setItem(storageKey, !outlineExpanded)
eventTracking.sendMB(
outlineExpanded ? 'outline-collapse' : 'outline-expand'
)
setOutlineExpanded(!outlineExpanded)
}
}, [canShowOutline, outlineExpanded, storageKey])
const value = useMemo(
() => ({
flatOutline,
setFlatOutline,
highlightedLine,
jumpToLine,
canShowOutline,
outlineExpanded,
toggleOutlineExpanded,
}),
[
flatOutline,
highlightedLine,
jumpToLine,
canShowOutline,
outlineExpanded,
toggleOutlineExpanded,
]
)
return (
<OutlineContext.Provider value={value}>{children}</OutlineContext.Provider>
)
}
export const useOutlineContext = () => {
const context = useContext(OutlineContext)
if (!context) {
throw new Error(
'useOutlineProvider is only available inside OutlineProvider'
)
}
return context
}
const closestSectionLineNumber = (
outline: { line: number }[] | undefined,
lineNumber: number
): number => {
if (!outline) {
return -1
}
let highestLine = -1
for (const section of outline) {
if (section.line > lineNumber) {
return highestLine
}
highestLine = section.line
}
return highestLine
}