mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-05 06:09:20 +00:00
[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
This commit is contained in:
parent
5e7665e322
commit
c2b553e915
37 changed files with 869 additions and 721 deletions
16
package-lock.json
generated
16
package-lock.json
generated
|
@ -36995,9 +36995,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-resizable-panels": {
|
||||
"version": "0.0.63",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-0.0.63.tgz",
|
||||
"integrity": "sha512-AfA8b6kouhL4rBvgUGs17uzWVlYPaJIwwTCVeWRxNpUHJlCG1h9RIMlzA1849AZGsaNJO3j/SNdI5SS4GZDE3g==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-1.0.1.tgz",
|
||||
"integrity": "sha512-bFKrVLO6VCDp9+zXvLybB3Ldd7MF+Q6E+qE6sxlDfVAlIwEWksJ94CD5RNXTN9a0E3YyAZUkhJEw3a9aCgymzA==",
|
||||
"dev": true,
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || ^17.0.0 || ^18.0.0",
|
||||
|
@ -46748,7 +46748,7 @@
|
|||
"react-i18next": "^13.3.1",
|
||||
"react-linkify": "^1.0.0-alpha",
|
||||
"react-refresh": "^0.14.0",
|
||||
"react-resizable-panels": "^0.0.63",
|
||||
"react-resizable-panels": "^1.0.1",
|
||||
"react2angular": "^4.0.6",
|
||||
"react2angular-shared-context": "^1.1.0",
|
||||
"requirejs": "^2.3.6",
|
||||
|
@ -55574,7 +55574,7 @@
|
|||
"react-i18next": "^13.3.1",
|
||||
"react-linkify": "^1.0.0-alpha",
|
||||
"react-refresh": "^0.14.0",
|
||||
"react-resizable-panels": "^0.0.63",
|
||||
"react-resizable-panels": "^1.0.1",
|
||||
"react2angular": "^4.0.6",
|
||||
"react2angular-shared-context": "^1.1.0",
|
||||
"recurly": "^4.0.0",
|
||||
|
@ -78887,9 +78887,9 @@
|
|||
}
|
||||
},
|
||||
"react-resizable-panels": {
|
||||
"version": "0.0.63",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-0.0.63.tgz",
|
||||
"integrity": "sha512-AfA8b6kouhL4rBvgUGs17uzWVlYPaJIwwTCVeWRxNpUHJlCG1h9RIMlzA1849AZGsaNJO3j/SNdI5SS4GZDE3g==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-1.0.1.tgz",
|
||||
"integrity": "sha512-bFKrVLO6VCDp9+zXvLybB3Ldd7MF+Q6E+qE6sxlDfVAlIwEWksJ94CD5RNXTN9a0E3YyAZUkhJEw3a9aCgymzA==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
|
|
|
@ -22,6 +22,6 @@ aside.editor-sidebar.full-size(
|
|||
set-started-free-trial="setStartedFreeTrial"
|
||||
)
|
||||
|
||||
.outline-container(
|
||||
outline-container(
|
||||
vertical-resizable-bottom
|
||||
)
|
||||
|
|
|
@ -34,7 +34,7 @@ const FileTreeRoot = React.memo(function FileTreeRoot({
|
|||
}) {
|
||||
const { _id: projectId } = useProjectContext(projectContextPropTypes)
|
||||
const { fileTreeData } = useFileTreeData()
|
||||
const isReady = projectId && fileTreeData
|
||||
const isReady = Boolean(projectId && fileTreeData)
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) onInit()
|
||||
|
|
|
@ -2,7 +2,6 @@ import React, { FC, useState } from 'react'
|
|||
import { Panel, PanelGroup } from 'react-resizable-panels'
|
||||
import NoSelectionPane from '@/features/ide-react/components/editor/no-selection-pane'
|
||||
import FileView from '@/features/file-view/components/file-view'
|
||||
import { fileViewFile } from '@/features/ide-react/hooks/use-file-tree'
|
||||
import MultipleSelectionPane from '@/features/ide-react/components/editor/multiple-selection-pane'
|
||||
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
|
||||
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
|
||||
|
@ -12,12 +11,12 @@ import { usePdfPane } from '@/features/ide-react/hooks/use-pdf-pane'
|
|||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
import { fileViewFile } from '@/features/ide-react/util/file-view'
|
||||
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
|
||||
|
||||
export const EditorAndPdf: FC<{
|
||||
editorPane: React.ReactNode
|
||||
selectedEntityCount: number
|
||||
openEntity: any // TODO
|
||||
}> = ({ editorPane, selectedEntityCount, openEntity }) => {
|
||||
}> = ({ editorPane }) => {
|
||||
const [resizing, setResizing] = useState(false)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
@ -33,6 +32,8 @@ export const EditorAndPdf: FC<{
|
|||
|
||||
const { view, pdfLayout } = useLayoutContext()
|
||||
|
||||
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
|
||||
|
||||
const editorIsOpen =
|
||||
view === 'editor' || view === 'file' || pdfLayout === 'sideBySide'
|
||||
|
||||
|
@ -50,8 +51,8 @@ export const EditorAndPdf: FC<{
|
|||
<Panel
|
||||
id="panel-main"
|
||||
order={1}
|
||||
defaultSizePercentage={50}
|
||||
minSizePercentage={25}
|
||||
defaultSize={50}
|
||||
minSize={25}
|
||||
className={classNames('ide-react-panel', {
|
||||
'ide-panel-group-resizing': resizing,
|
||||
})}
|
||||
|
@ -96,8 +97,8 @@ export const EditorAndPdf: FC<{
|
|||
ref={pdfPanelRef}
|
||||
id="panel-pdf"
|
||||
order={2}
|
||||
defaultSizePercentage={50}
|
||||
minSizePercentage={25}
|
||||
defaultSize={50}
|
||||
minSize={25}
|
||||
collapsible
|
||||
onCollapse={handlePdfPaneCollapse}
|
||||
onExpand={handlePdfPaneExpand}
|
||||
|
|
|
@ -1,47 +1,45 @@
|
|||
import React from 'react'
|
||||
import { Panel, PanelGroup } from 'react-resizable-panels'
|
||||
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
|
||||
import { FileTree } from '@/features/ide-react/components/file-tree'
|
||||
import {
|
||||
FileTreeDeleteHandler,
|
||||
FileTreeSelectHandler,
|
||||
} from '@/features/ide-react/types/file-tree'
|
||||
import classNames from 'classnames'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import { OutlineContainer } from '@/features/outline/components/outline-container'
|
||||
import { useOutlinePane } from '@/features/ide-react/hooks/use-outline-pane'
|
||||
|
||||
type EditorSidebarProps = {
|
||||
shouldShow?: boolean
|
||||
onFileTreeInit: () => void
|
||||
onFileTreeSelect: FileTreeSelectHandler
|
||||
onFileTreeDelete: FileTreeDeleteHandler
|
||||
}
|
||||
export default function EditorSidebar() {
|
||||
const { view } = useLayoutContext()
|
||||
|
||||
const { outlineDisabled, outlineRef } = useOutlinePane()
|
||||
|
||||
export default function EditorSidebar({
|
||||
shouldShow = false,
|
||||
onFileTreeInit,
|
||||
onFileTreeSelect,
|
||||
onFileTreeDelete,
|
||||
}: EditorSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={classNames('ide-react-editor-sidebar', {
|
||||
hidden: !shouldShow,
|
||||
hidden: view === 'history',
|
||||
})}
|
||||
>
|
||||
<PanelGroup autoSaveId="ide-editor-sidebar-layout" direction="vertical">
|
||||
<Panel
|
||||
defaultSizePercentage={75}
|
||||
defaultSize={50}
|
||||
minSize={25}
|
||||
className="ide-react-file-tree-panel"
|
||||
id="panel-file-tree"
|
||||
order={1}
|
||||
>
|
||||
<FileTree
|
||||
onInit={onFileTreeInit}
|
||||
onSelect={onFileTreeSelect}
|
||||
onDelete={onFileTreeDelete}
|
||||
/>
|
||||
<FileTree />
|
||||
</Panel>
|
||||
<VerticalResizeHandle />
|
||||
<Panel defaultSizePercentage={25} id="panel-outline">
|
||||
<div className="outline-container" />
|
||||
|
||||
<VerticalResizeHandle disabled={outlineDisabled} />
|
||||
|
||||
<Panel
|
||||
defaultSize={50}
|
||||
maxSize={75}
|
||||
id="panel-outline"
|
||||
order={2}
|
||||
collapsible
|
||||
ref={outlineRef}
|
||||
style={{ minHeight: 32 }} // keep the header visible
|
||||
>
|
||||
<OutlineContainer />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</aside>
|
||||
|
|
|
@ -7,13 +7,15 @@ import classNames from 'classnames'
|
|||
import { LoadingPane } from '@/features/ide-react/components/editor/loading-pane'
|
||||
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
|
||||
import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
|
||||
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
|
||||
|
||||
const SymbolPalettePane = lazy(
|
||||
() => import('@/features/ide-react/components/editor/symbol-palette-pane')
|
||||
)
|
||||
|
||||
export const EditorPane: FC<{ show: boolean }> = ({ show }) => {
|
||||
export const EditorPane: FC = () => {
|
||||
const [editor] = useScopeValue<EditorScopeValue>('editor')
|
||||
const { selectedEntityCount, openEntity } = useFileTreeOpenContext()
|
||||
|
||||
const isLoading = Boolean(
|
||||
(!editor.sharejs_doc || editor.opening) &&
|
||||
|
@ -22,12 +24,12 @@ export const EditorPane: FC<{ show: boolean }> = ({ show }) => {
|
|||
)
|
||||
|
||||
return (
|
||||
<div className="ide-react-editor-content full-size">
|
||||
<PanelGroup
|
||||
autoSaveId="ide-editor-layout"
|
||||
direction="vertical"
|
||||
className={classNames({ hidden: !show })}
|
||||
>
|
||||
<div
|
||||
className={classNames('ide-react-editor-content', 'full-size', {
|
||||
hidden: openEntity?.type !== 'doc' || selectedEntityCount !== 1,
|
||||
})}
|
||||
>
|
||||
<PanelGroup autoSaveId="ide-editor-layout" direction="vertical">
|
||||
<Panel
|
||||
id="panel-source-editor"
|
||||
order={1}
|
||||
|
@ -43,9 +45,9 @@ export const EditorPane: FC<{ show: boolean }> = ({ show }) => {
|
|||
<Panel
|
||||
id="panel-symbol-palette"
|
||||
order={2}
|
||||
defaultSizePixels={250}
|
||||
minSizePixels={250}
|
||||
maxSizePixels={336}
|
||||
defaultSize={25}
|
||||
minSize={10}
|
||||
maxSize={50}
|
||||
>
|
||||
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
||||
<SymbolPalettePane />
|
||||
|
|
|
@ -1,26 +1,19 @@
|
|||
import FileTreeRoot from '@/features/file-tree/components/file-tree-root'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import { useReferencesContext } from '@/features/ide-react/context/references-context'
|
||||
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
|
||||
import {
|
||||
FileTreeDeleteHandler,
|
||||
FileTreeSelectHandler,
|
||||
} from '@/features/ide-react/types/file-tree'
|
||||
import { RefProviders } from '../../../../../types/user'
|
||||
import FileTreeRoot from '@/features/file-tree/components/file-tree-root'
|
||||
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
|
||||
|
||||
type FileTreeProps = {
|
||||
onInit: () => void
|
||||
onSelect: FileTreeSelectHandler
|
||||
onDelete: FileTreeDeleteHandler
|
||||
}
|
||||
|
||||
export function FileTree({ onInit, onSelect, onDelete }: FileTreeProps) {
|
||||
export function FileTree() {
|
||||
const user = useUserContext()
|
||||
const { indexAllReferences } = useReferencesContext()
|
||||
const { setStartedFreeTrial } = useIdeReactContext()
|
||||
const { isConnected } = useConnectionContext()
|
||||
const { handleFileTreeInit, handleFileTreeSelect, handleFileTreeDelete } =
|
||||
useFileTreeOpenContext()
|
||||
|
||||
const [refProviders, setRefProviders] = useState<RefProviders>(
|
||||
() => user.refProviders || {}
|
||||
|
@ -45,9 +38,9 @@ export function FileTree({ onInit, onSelect, onDelete }: FileTreeProps) {
|
|||
setRefProviderEnabled={setRefProviderEnabled}
|
||||
setStartedFreeTrial={setStartedFreeTrial}
|
||||
isConnected={isConnected}
|
||||
onInit={onInit}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onInit={handleFileTreeInit}
|
||||
onSelect={handleFileTreeSelect}
|
||||
onDelete={handleFileTreeDelete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -11,11 +11,11 @@ import { HistoryProvider } from '@/features/history/context/history-context'
|
|||
import History from '@/features/ide-react/components/history'
|
||||
import EditorSidebar from '@/features/ide-react/components/editor-sidebar'
|
||||
import { EditorPane } from '@/features/ide-react/components/editor/editor-pane'
|
||||
import { useFileTree } from '@/features/ide-react/hooks/use-file-tree'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSidebarPane } from '@/features/ide-react/hooks/use-sidebar-pane'
|
||||
import { useChatPane } from '@/features/ide-react/hooks/use-chat-pane'
|
||||
import { EditorAndPdf } from '@/features/ide-react/components/editor-and-pdf'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
|
||||
export const MainLayout: FC = () => {
|
||||
const { view } = useLayoutContext()
|
||||
|
@ -43,23 +43,12 @@ export const MainLayout: FC = () => {
|
|||
handlePaneExpand: handleChatExpand,
|
||||
} = useChatPane()
|
||||
|
||||
const {
|
||||
selectedEntityCount,
|
||||
openEntity,
|
||||
openDocId,
|
||||
handleFileTreeInit,
|
||||
handleFileTreeSelect,
|
||||
handleFileTreeDelete,
|
||||
} = useFileTree()
|
||||
const { currentDocumentId } = useEditorManagerContext()
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
// keep the editor pane open
|
||||
const editorPane = openDocId ? (
|
||||
<EditorPane
|
||||
show={openEntity?.type === 'doc' && selectedEntityCount === 1}
|
||||
/>
|
||||
) : null
|
||||
// keep the editor pane open when a doc is open, even if the history view is open
|
||||
const editorPane = currentDocumentId ? <EditorPane /> : null
|
||||
|
||||
return (
|
||||
<div className="ide-react-main">
|
||||
|
@ -78,18 +67,14 @@ export const MainLayout: FC = () => {
|
|||
ref={sidebarPanelRef}
|
||||
id="panel-sidebar"
|
||||
order={1}
|
||||
defaultSizePixels={200}
|
||||
minSizePixels={150}
|
||||
defaultSize={10}
|
||||
minSize={10}
|
||||
maxSize={30}
|
||||
collapsible
|
||||
onCollapse={handleSidebarCollapse}
|
||||
onExpand={handleSidebarExpand}
|
||||
>
|
||||
<EditorSidebar
|
||||
shouldShow={view !== 'history'}
|
||||
onFileTreeInit={handleFileTreeInit}
|
||||
onFileTreeSelect={handleFileTreeSelect}
|
||||
onFileTreeDelete={handleFileTreeDelete}
|
||||
/>
|
||||
<EditorSidebar />
|
||||
{view === 'history' && <HistorySidebar />}
|
||||
</Panel>
|
||||
|
||||
|
@ -120,11 +105,7 @@ export const MainLayout: FC = () => {
|
|||
<History />
|
||||
</HistoryProvider>
|
||||
) : (
|
||||
<EditorAndPdf
|
||||
editorPane={editorPane}
|
||||
selectedEntityCount={selectedEntityCount}
|
||||
openEntity={openEntity}
|
||||
/>
|
||||
<EditorAndPdf editorPane={editorPane} />
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
|
@ -139,8 +120,9 @@ export const MainLayout: FC = () => {
|
|||
ref={chatPanelRef}
|
||||
id="panel-chat"
|
||||
order={2}
|
||||
defaultSizePixels={200}
|
||||
minSizePixels={150}
|
||||
defaultSize={20}
|
||||
minSize={10}
|
||||
maxSize={30}
|
||||
collapsible
|
||||
onCollapse={handleChatCollapse}
|
||||
onExpand={handleChatExpand}
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
import { PanelResizeHandle } from 'react-resizable-panels'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PanelResizeHandleProps } from 'react-resizable-panels/dist/declarations/src/PanelResizeHandle'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export function VerticalResizeHandle(props: PanelResizeHandleProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<PanelResizeHandle {...props}>
|
||||
<div className="vertical-resize-handle" title={t('resize')} />
|
||||
<div
|
||||
className={classNames('vertical-resize-handle', {
|
||||
'vertical-resize-handle-enabled': !props.disabled,
|
||||
})}
|
||||
title={t('resize')}
|
||||
/>
|
||||
</PanelResizeHandle>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,28 +1,46 @@
|
|||
import { FileRef } from '../../../../../types/file-ref'
|
||||
import { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||
import {
|
||||
createContext,
|
||||
FC,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import { useSelectFileTreeEntity } from '@/features/ide-react/hooks/use-select-file-tree-entity'
|
||||
import useScopeValue from '@/shared/hooks/use-scope-value'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||
import {
|
||||
FileTreeDeleteHandler,
|
||||
FileTreeDocumentFindResult,
|
||||
FileTreeFileRefFindResult,
|
||||
FileTreeFindResult,
|
||||
} from '@/features/ide-react/types/file-tree'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
|
||||
import { convertFileRefToBinaryFile } from '@/features/ide-react/util/file-view'
|
||||
|
||||
export const useFileTree = () => {
|
||||
const FileTreeOpenContext = createContext<
|
||||
| {
|
||||
selectedEntityCount: number
|
||||
openEntity: FileTreeDocumentFindResult | FileTreeFileRefFindResult | null
|
||||
handleFileTreeInit: () => void
|
||||
handleFileTreeSelect: (selectedEntities: FileTreeFindResult[]) => void
|
||||
handleFileTreeDelete: (entity: FileTreeFindResult) => void
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
|
||||
export const FileTreeOpenProvider: FC = ({ children }) => {
|
||||
const { rootDocId } = useProjectContext()
|
||||
const { eventEmitter } = useIdeReactContext()
|
||||
const { eventEmitter, projectJoined } = useIdeReactContext()
|
||||
const {
|
||||
openDocId: openDocWithId,
|
||||
currentDocumentId: openDocId,
|
||||
openInitialDoc,
|
||||
} = useEditorManagerContext()
|
||||
const { projectJoined } = useIdeReactContext()
|
||||
const { selectEntity } = useSelectFileTreeEntity()
|
||||
const [, setOpenFile] = useScopeValue<BinaryFile | null>('openFile')
|
||||
const [openEntity, setOpenEntity] = useState<
|
||||
|
@ -68,8 +86,8 @@ export const useFileTree = () => {
|
|||
[fileTreeReady, setOpenFile, openDocWithId]
|
||||
)
|
||||
|
||||
const handleFileTreeDelete: FileTreeDeleteHandler = useCallback(
|
||||
entity => {
|
||||
const handleFileTreeDelete = useCallback(
|
||||
(entity: FileTreeFindResult) => {
|
||||
eventEmitter.emit('entity:deleted', entity)
|
||||
// Select the root document if the current document was deleted
|
||||
if (entity.entity._id === openDocId) {
|
||||
|
@ -104,38 +122,37 @@ export const useFileTree = () => {
|
|||
}
|
||||
}, [fileTreeReady, openInitialDoc, projectJoined, rootDocId])
|
||||
|
||||
return {
|
||||
selectedEntityCount,
|
||||
openEntity,
|
||||
openDocId,
|
||||
const value = useMemo(() => {
|
||||
return {
|
||||
selectedEntityCount,
|
||||
openEntity,
|
||||
handleFileTreeInit,
|
||||
handleFileTreeSelect,
|
||||
handleFileTreeDelete,
|
||||
}
|
||||
}, [
|
||||
handleFileTreeDelete,
|
||||
handleFileTreeInit,
|
||||
handleFileTreeSelect,
|
||||
handleFileTreeDelete,
|
||||
}
|
||||
openEntity,
|
||||
selectedEntityCount,
|
||||
])
|
||||
|
||||
return (
|
||||
<FileTreeOpenContext.Provider value={value}>
|
||||
{children}
|
||||
</FileTreeOpenContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function convertFileRefToBinaryFile(fileRef: FileRef): BinaryFile {
|
||||
return {
|
||||
_id: fileRef._id,
|
||||
name: fileRef.name,
|
||||
id: fileRef._id,
|
||||
type: 'file',
|
||||
selected: true,
|
||||
linkedFileData: fileRef.linkedFileData,
|
||||
created: fileRef.created ? new Date(fileRef.created) : new Date(),
|
||||
}
|
||||
}
|
||||
export const useFileTreeOpenContext = () => {
|
||||
const context = useContext(FileTreeOpenContext)
|
||||
|
||||
// `FileViewHeader`, which is TypeScript, expects a BinaryFile, which has a
|
||||
// `created` property of type `Date`, while `TPRFileViewInfo`, written in JS,
|
||||
// into which `FileViewHeader` passes its BinaryFile, expects a file object with
|
||||
// `created` property of type `string`, which is a mismatch. `TPRFileViewInfo`
|
||||
// is the only one making runtime complaints and it seems that other uses of
|
||||
// `FileViewHeader` pass in a string for `created`, so that's what this function
|
||||
// does too.
|
||||
export function fileViewFile(fileRef: FileRef) {
|
||||
return {
|
||||
...convertFileRefToBinaryFile(fileRef),
|
||||
created: fileRef.created,
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useFileTreeOpenContext is only available inside FileTreeOpenProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
|
@ -0,0 +1,195 @@
|
|||
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
|
||||
}
|
|
@ -20,6 +20,8 @@ import { ModalsContextProvider } from '@/features/ide-react/context/modals-conte
|
|||
import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
||||
import { PermissionsProvider } from '@/features/ide-react/context/permissions-context'
|
||||
import { FileTreeOpenProvider } from '@/features/ide-react/context/file-tree-open-context'
|
||||
import { OutlineProvider } from '@/features/ide-react/context/outline-context'
|
||||
|
||||
export const ReactContextRoot: FC = ({ children }) => {
|
||||
return (
|
||||
|
@ -42,11 +44,15 @@ export const ReactContextRoot: FC = ({ children }) => {
|
|||
<DetachCompileProvider>
|
||||
<ChatProvider>
|
||||
<EditorManagerProvider>
|
||||
<OnlineUsersProvider>
|
||||
<MetadataProvider>
|
||||
{children}
|
||||
</MetadataProvider>
|
||||
</OnlineUsersProvider>
|
||||
<FileTreeOpenProvider>
|
||||
<OnlineUsersProvider>
|
||||
<MetadataProvider>
|
||||
<OutlineProvider>
|
||||
{children}
|
||||
</OutlineProvider>
|
||||
</MetadataProvider>
|
||||
</OnlineUsersProvider>
|
||||
</FileTreeOpenProvider>
|
||||
</EditorManagerProvider>
|
||||
</ChatProvider>
|
||||
</DetachCompileProvider>
|
||||
|
|
|
@ -12,7 +12,7 @@ export default function useFixedSizeColumn(isOpen: boolean) {
|
|||
|
||||
const handleLayout: PanelGroupOnLayout = useCallback(() => {
|
||||
if (fixedPanelRef.current) {
|
||||
fixedPanelSizeRef.current = fixedPanelRef.current.getSize().sizePixels
|
||||
fixedPanelSizeRef.current = fixedPanelRef.current.getSize()
|
||||
setInitialLayoutDone(true)
|
||||
}
|
||||
}, [])
|
||||
|
@ -43,9 +43,7 @@ export default function useFixedSizeColumn(isOpen: boolean) {
|
|||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
// when the panel group resizes, set the size of this panel to the previous size, in pixels
|
||||
fixedPanelRef.current?.resize({
|
||||
sizePixels: fixedPanelSizeRef.current,
|
||||
})
|
||||
fixedPanelRef.current?.resize(fixedPanelSizeRef.current)
|
||||
})
|
||||
|
||||
resizeObserver.observe(panelGroupElement)
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
import localStorage from '@/infrastructure/local-storage'
|
||||
|
||||
export const useOutlinePane = () => {
|
||||
const { canShowOutline, outlineExpanded } = useOutlineContext()
|
||||
const { _id: projectId } = useProjectContext()
|
||||
const outlineDisabled = !canShowOutline || !outlineExpanded
|
||||
|
||||
const outlineRef = useRef<ImperativePanelHandle>(null)
|
||||
|
||||
// store the expanded height in localStorage when collapsing,
|
||||
// so it can be restored when expanding after reloading the page
|
||||
useEffect(() => {
|
||||
const outlinePane = outlineRef.current
|
||||
|
||||
if (outlinePane) {
|
||||
// NOTE: outline size is shared across projects
|
||||
const storageKey = 'ide-panel.outline.size'
|
||||
|
||||
if (outlineDisabled) {
|
||||
// collapsing, so store the current size if > 0
|
||||
const size = outlinePane.getSize()
|
||||
if (size > 0) {
|
||||
localStorage.setItem(storageKey, size)
|
||||
}
|
||||
|
||||
outlinePane.collapse()
|
||||
} else {
|
||||
outlinePane.expand()
|
||||
|
||||
// if the panel has been expanded to zero height, use the stored height instead
|
||||
if (outlinePane.getSize() === 0) {
|
||||
const size = Number(localStorage.getItem(storageKey) || 50)
|
||||
outlinePane.resize(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [outlineDisabled, projectId])
|
||||
|
||||
return { outlineDisabled, outlineRef }
|
||||
}
|
|
@ -32,9 +32,3 @@ export type FileTreeFindResult =
|
|||
| FileTreeFolderFindResult
|
||||
| FileTreeDocumentFindResult
|
||||
| FileTreeFileRefFindResult
|
||||
|
||||
export type FileTreeSelectHandler = (
|
||||
selectedEntities: FileTreeFindResult[]
|
||||
) => void
|
||||
|
||||
export type FileTreeDeleteHandler = (entity: FileTreeFindResult) => void
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { FileRef } from '../../../../../types/file-ref'
|
||||
import { BinaryFile } from '@/features/file-view/types/binary-file'
|
||||
|
||||
export function convertFileRefToBinaryFile(fileRef: FileRef): BinaryFile {
|
||||
return {
|
||||
_id: fileRef._id,
|
||||
name: fileRef.name,
|
||||
id: fileRef._id,
|
||||
type: 'file',
|
||||
selected: true,
|
||||
linkedFileData: fileRef.linkedFileData,
|
||||
created: fileRef.created ? new Date(fileRef.created) : new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
// `FileViewHeader`, which is TypeScript, expects a BinaryFile, which has a
|
||||
// `created` property of type `Date`, while `TPRFileViewInfo`, written in JS,
|
||||
// into which `FileViewHeader` passes its BinaryFile, expects a file object with
|
||||
// `created` property of type `string`, which is a mismatch. `TPRFileViewInfo`
|
||||
// is the only one making runtime complaints and it seems that other uses of
|
||||
// `FileViewHeader` pass in a string for `created`, so that's what this function
|
||||
// does too.
|
||||
export function fileViewFile(fileRef: FileRef) {
|
||||
return {
|
||||
...convertFileRefToBinaryFile(fileRef),
|
||||
created: fileRef.created,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
import { FC, memo, useEffect, useRef, useState } from 'react'
|
||||
import OutlinePane from '@/features/outline/components/outline-pane'
|
||||
import {
|
||||
FlatOutlineState,
|
||||
PartialFlatOutline,
|
||||
useOutlineContext,
|
||||
} from '@/features/ide-react/context/outline-context'
|
||||
import {
|
||||
nestOutline,
|
||||
Outline,
|
||||
} from '@/features/source-editor/utils/tree-operations/outline'
|
||||
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
export const OutlineContainer: FC = memo(() => {
|
||||
const {
|
||||
flatOutline,
|
||||
highlightedLine,
|
||||
canShowOutline,
|
||||
jumpToLine,
|
||||
outlineExpanded,
|
||||
toggleOutlineExpanded,
|
||||
} = useOutlineContext()
|
||||
|
||||
const outlineToggledEmitter = useScopeEventEmitter('outline-toggled')
|
||||
|
||||
const [outline, setOutline] = useState<{
|
||||
items: Outline[]
|
||||
partial: boolean
|
||||
}>(() => ({ items: [], partial: false }))
|
||||
|
||||
const prevFlatOutlineRef = useRef<FlatOutlineState>(undefined)
|
||||
|
||||
// when the flat outline changes, calculate the nested outline
|
||||
// TODO: only calculate when outlineExpanded is true
|
||||
useEffect(() => {
|
||||
const prevFlatOutline = prevFlatOutlineRef.current
|
||||
prevFlatOutlineRef.current = flatOutline
|
||||
|
||||
if (flatOutline) {
|
||||
if (outlineChanged(prevFlatOutline?.items, flatOutline.items)) {
|
||||
debugConsole.log('Rebuilding changed outline')
|
||||
setOutline({
|
||||
items: nestOutline(flatOutline.items),
|
||||
partial: flatOutline.partial,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setOutline({ items: [], partial: false })
|
||||
}
|
||||
}, [flatOutline])
|
||||
|
||||
return (
|
||||
<div className="outline-container">
|
||||
<OutlinePane
|
||||
outline={outline.items}
|
||||
onToggle={outlineToggledEmitter}
|
||||
isTexFile={canShowOutline}
|
||||
jumpToLine={jumpToLine}
|
||||
highlightedLine={highlightedLine}
|
||||
isPartial={outline.partial}
|
||||
expanded={outlineExpanded}
|
||||
toggleExpanded={toggleOutlineExpanded}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
OutlineContainer.displayName = 'OutlineContainer'
|
||||
|
||||
const outlineChanged = (
|
||||
a: PartialFlatOutline | undefined,
|
||||
b: PartialFlatOutline
|
||||
): boolean => {
|
||||
if (!a) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const aItem = a[i]
|
||||
const bItem = b[i]
|
||||
if (
|
||||
aItem.level !== bItem.level ||
|
||||
aItem.line !== bItem.line ||
|
||||
aItem.title !== bItem.title
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
|
@ -1,13 +1,11 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import OutlineRoot from './outline-root'
|
||||
import Icon from '../../../shared/components/icon'
|
||||
import localStorage from '../../../infrastructure/local-storage'
|
||||
import withErrorBoundary from '../../../infrastructure/error-boundary'
|
||||
import { useProjectContext } from '../../../shared/context/project-context'
|
||||
import Tooltip from '../../../shared/components/tooltip'
|
||||
|
||||
const OutlinePane = React.memo(function OutlinePane({
|
||||
|
@ -15,22 +13,13 @@ const OutlinePane = React.memo(function OutlinePane({
|
|||
outline,
|
||||
jumpToLine,
|
||||
onToggle,
|
||||
eventTracking,
|
||||
highlightedLine,
|
||||
show,
|
||||
isPartial = false,
|
||||
expanded,
|
||||
toggleExpanded,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { _id: projectId } = useProjectContext({
|
||||
_id: PropTypes.string.isRequired,
|
||||
})
|
||||
|
||||
const storageKey = `file_outline.expanded.${projectId}`
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
const storedExpandedState = localStorage.getItem(storageKey) !== false
|
||||
return storedExpandedState
|
||||
})
|
||||
const isOpen = isTexFile && expanded
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -41,27 +30,13 @@ const OutlinePane = React.memo(function OutlinePane({
|
|||
'outline-pane-disabled': !isTexFile,
|
||||
})
|
||||
|
||||
function handleExpandCollapseClick() {
|
||||
if (isTexFile) {
|
||||
localStorage.setItem(storageKey, !expanded)
|
||||
eventTracking.sendMB(expanded ? 'outline-collapse' : 'outline-expand')
|
||||
setExpanded(!expanded)
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: This flag is for disabling the rendering of the component. Used while
|
||||
// both an Angular and React-based file outline is present in the code base.
|
||||
if (!show) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={headerClasses}>
|
||||
<header className="outline-header">
|
||||
<button
|
||||
className="outline-header-expand-collapse-btn"
|
||||
disabled={!isTexFile}
|
||||
onClick={handleExpandCollapseClick}
|
||||
onClick={toggleExpanded}
|
||||
aria-label={expanded ? t('hide_outline') : t('show_outline')}
|
||||
>
|
||||
<Icon
|
||||
|
@ -85,7 +60,7 @@ const OutlinePane = React.memo(function OutlinePane({
|
|||
)}
|
||||
</button>
|
||||
</header>
|
||||
{expanded && isTexFile ? (
|
||||
{isOpen && (
|
||||
<div className="outline-body">
|
||||
<OutlineRoot
|
||||
outline={outline}
|
||||
|
@ -93,7 +68,7 @@ const OutlinePane = React.memo(function OutlinePane({
|
|||
highlightedLine={highlightedLine}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
@ -103,10 +78,10 @@ OutlinePane.propTypes = {
|
|||
outline: PropTypes.array.isRequired,
|
||||
jumpToLine: PropTypes.func.isRequired,
|
||||
onToggle: PropTypes.func.isRequired,
|
||||
eventTracking: PropTypes.object.isRequired,
|
||||
highlightedLine: PropTypes.number,
|
||||
show: PropTypes.bool.isRequired,
|
||||
isPartial: PropTypes.bool,
|
||||
expanded: PropTypes.bool,
|
||||
toggleExpanded: PropTypes.func.isRequired,
|
||||
}
|
||||
|
||||
export default withErrorBoundary(OutlinePane)
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import App from '@/base'
|
||||
import { react2angular } from 'react2angular'
|
||||
import { rootContext } from '@/shared/context/root-context'
|
||||
import { OutlineContainer } from '@/features/outline/components/outline-container'
|
||||
|
||||
App.component(
|
||||
'outlineContainer',
|
||||
react2angular(rootContext.use(OutlineContainer))
|
||||
)
|
|
@ -1,190 +1,33 @@
|
|||
import { createPortal } from 'react-dom'
|
||||
import { useCodeMirrorStateContext } from './codemirror-editor'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import OutlinePane from '../../outline/components/outline-pane'
|
||||
import React, { useEffect } from 'react'
|
||||
import { documentOutline } from '../languages/latex/document-outline'
|
||||
import isValidTeXFile from '../../../main/is-valid-tex-file'
|
||||
import useScopeValue from '../../../shared/hooks/use-scope-value'
|
||||
import useScopeEventEmitter from '../../../shared/hooks/use-scope-event-emitter'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import { nestOutline, Outline } from '../utils/tree-query'
|
||||
import { ProjectionStatus } from '../utils/tree-operations/projection'
|
||||
import useEventListener from '../../../shared/hooks/use-event-listener'
|
||||
import useDebounce from '../../../shared/hooks/use-debounce'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type PartialFlatOutline = {
|
||||
level: number
|
||||
title: string
|
||||
line: number
|
||||
}[]
|
||||
|
||||
const outlineChanged = (
|
||||
a: PartialFlatOutline | undefined,
|
||||
b: PartialFlatOutline
|
||||
): boolean => {
|
||||
if (!a) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const aItem = a[i]
|
||||
const bItem = b[i]
|
||||
if (
|
||||
aItem.level !== bItem.level ||
|
||||
aItem.line !== bItem.line ||
|
||||
aItem.title !== bItem.title
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
|
||||
|
||||
export const CodemirrorOutline = React.memo(function CodemirrorOutline() {
|
||||
const { setFlatOutline } = useOutlineContext()
|
||||
|
||||
const state = useCodeMirrorStateContext()
|
||||
const debouncedState = useDebounce(state, 100)
|
||||
const [docName] = useScopeValue<string>('editor.open_doc_name')
|
||||
const goToLineEmitter = useScopeEventEmitter('editor:gotoLine', true)
|
||||
const outlineToggledEmitter = useScopeEventEmitter('outline-toggled')
|
||||
const [currentlyHighlightedLine, setCurrentlyHighlightedLine] =
|
||||
useState<number>(-1)
|
||||
const isTexFile = useMemo(() => isValidTeXFile(docName), [docName])
|
||||
const [ignoreNextCursorUpdate, setIgnoreNextCursorUpdate] =
|
||||
useState<boolean>(false)
|
||||
const [ignoreNextScroll, setIgnoreNextScroll] = useState<boolean>(false)
|
||||
const [binaryFileOpened, setBinaryFileOpened] = useState<boolean>(false)
|
||||
|
||||
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 outlineResult = debouncedState.field(documentOutline, false)
|
||||
|
||||
// when the outline projection changes, calculate the flat outline
|
||||
const flatOutline = useMemo<PartialFlatOutline | undefined>(() => {
|
||||
if (!outlineResult || outlineResult.status === ProjectionStatus.Pending) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// We have a (potentially partial) outline.
|
||||
return outlineResult.items.map(element => {
|
||||
const { level, title, line } = element
|
||||
return { level, title, line }
|
||||
})
|
||||
}, [outlineResult])
|
||||
|
||||
const [outline, setOutline] = useState<Outline[]>([])
|
||||
|
||||
const prevFlatOutlineRef = useRef<PartialFlatOutline | undefined>(undefined)
|
||||
|
||||
// when the flat outline changes, calculate the nested outline
|
||||
useEffect(() => {
|
||||
const prevFlatOutline = prevFlatOutlineRef.current
|
||||
prevFlatOutlineRef.current = flatOutline
|
||||
|
||||
if (flatOutline) {
|
||||
if (outlineChanged(prevFlatOutline, flatOutline)) {
|
||||
setOutline(nestOutline(flatOutline))
|
||||
}
|
||||
if (outlineResult && outlineResult.status !== ProjectionStatus.Pending) {
|
||||
// We have a (potentially partial) outline.
|
||||
setFlatOutline({
|
||||
items: outlineResult.items.map(element => ({
|
||||
level: element.level,
|
||||
title: element.title,
|
||||
line: element.line,
|
||||
})),
|
||||
partial: outlineResult?.status === ProjectionStatus.Partial,
|
||||
})
|
||||
} else {
|
||||
setOutline([])
|
||||
setFlatOutline(undefined)
|
||||
}
|
||||
}, [flatOutline])
|
||||
}, [outlineResult, setFlatOutline])
|
||||
|
||||
const jumpToLine = useCallback(
|
||||
(lineNumber, syncToPdf) => {
|
||||
setIgnoreNextScroll(true)
|
||||
goToLineEmitter(lineNumber, 0, syncToPdf)
|
||||
eventTracking.sendMB('outline-jump-to-line')
|
||||
},
|
||||
[goToLineEmitter]
|
||||
)
|
||||
|
||||
const highlightedLine = useMemo(
|
||||
() => closestSectionLineNumber(flatOutline, currentlyHighlightedLine),
|
||||
[flatOutline, currentlyHighlightedLine]
|
||||
)
|
||||
|
||||
const outlineDomElement = document.querySelector('.outline-container')
|
||||
if (!outlineDomElement) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<OutlinePane
|
||||
outline={outline}
|
||||
onToggle={outlineToggledEmitter}
|
||||
eventTracking={eventTracking}
|
||||
isTexFile={isTexFile && !binaryFileOpened}
|
||||
jumpToLine={jumpToLine}
|
||||
highlightedLine={highlightedLine}
|
||||
show
|
||||
isPartial={outlineResult?.status === ProjectionStatus.Partial}
|
||||
/>,
|
||||
outlineDomElement
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
|
|
@ -3,6 +3,7 @@ import { SyntaxNode, SyntaxNodeRef } from '@lezer/common'
|
|||
import { NodeIntersectsChangeFn, ProjectionItem } from './projection'
|
||||
import * as tokens from '../../lezer-latex/latex.terms.mjs'
|
||||
import { getEnvironmentArguments, getEnvironmentName } from './environments'
|
||||
import { PartialFlatOutline } from '@/features/ide-react/context/outline-context'
|
||||
|
||||
export type Outline = {
|
||||
line: number
|
||||
|
@ -116,7 +117,7 @@ export const enterNode = (
|
|||
}
|
||||
const name = command.getChild('SectioningArgument')?.getChild('LongArg')
|
||||
|
||||
if (name == null || command == null) {
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -185,14 +186,13 @@ const flatItemToOutline = (item: {
|
|||
title: string
|
||||
line: number
|
||||
level: number
|
||||
}): Outline => {
|
||||
const { title, line, level } = item
|
||||
return { title, line, level }
|
||||
}
|
||||
}): Outline => ({
|
||||
title: item.title,
|
||||
line: item.line,
|
||||
level: item.level,
|
||||
})
|
||||
|
||||
export const nestOutline = (
|
||||
flatOutline: { title: string; line: number; level: number }[]
|
||||
): Outline[] => {
|
||||
export const nestOutline = (flatOutline: PartialFlatOutline): Outline[] => {
|
||||
const parentStack: Outline[] = []
|
||||
const outline = []
|
||||
|
||||
|
|
|
@ -46,6 +46,7 @@ import './features/share-project-modal/controllers/react-share-project-modal-con
|
|||
import './features/source-editor/controllers/grammarly-advert-controller'
|
||||
import './features/history/controllers/history-controller'
|
||||
import './features/editor-left-menu/controllers/editor-left-menu-controller'
|
||||
import './features/outline/controllers/outline-controller'
|
||||
import { cleanupServiceWorker } from './utils/service-worker-cleanup'
|
||||
import { reportCM6Perf } from './infrastructure/cm6-performance'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
|
|
@ -15,6 +15,7 @@ import { FileTreeDataProvider } from './file-tree-data-context'
|
|||
import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
|
||||
import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
||||
import { OutlineProvider } from '@/features/ide-react/context/outline-context'
|
||||
|
||||
export function ContextRoot({ children, ide }) {
|
||||
return (
|
||||
|
@ -31,7 +32,9 @@ export function ContextRoot({ children, ide }) {
|
|||
<LayoutProvider>
|
||||
<LocalCompileProvider>
|
||||
<DetachCompileProvider>
|
||||
<ChatProvider>{children}</ChatProvider>
|
||||
<ChatProvider>
|
||||
<OutlineProvider>{children}</OutlineProvider>
|
||||
</ChatProvider>
|
||||
</DetachCompileProvider>
|
||||
</LocalCompileProvider>
|
||||
</LayoutProvider>
|
||||
|
|
|
@ -22,6 +22,7 @@ import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
|
|||
import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
|
||||
import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
||||
import { OutlineProvider } from '@/features/ide-react/context/outline-context'
|
||||
|
||||
const scopeWatchers: [string, (value: any) => void][] = []
|
||||
|
||||
|
@ -245,6 +246,7 @@ export const ScopeDecorator = (
|
|||
IdeAngularProvider,
|
||||
LayoutProvider,
|
||||
LocalCompileProvider,
|
||||
OutlineProvider,
|
||||
ProjectProvider,
|
||||
ProjectSettingsProvider,
|
||||
SplitTestProvider,
|
||||
|
@ -267,7 +269,9 @@ export const ScopeDecorator = (
|
|||
<Providers.LayoutProvider>
|
||||
<Providers.LocalCompileProvider>
|
||||
<Providers.DetachCompileProvider>
|
||||
<Story />
|
||||
<Providers.OutlineProvider>
|
||||
<Story />
|
||||
</Providers.OutlineProvider>
|
||||
</Providers.DetachCompileProvider>
|
||||
</Providers.LocalCompileProvider>
|
||||
</Providers.LayoutProvider>
|
||||
|
|
|
@ -49,14 +49,13 @@ export default {
|
|||
component: OutlinePane,
|
||||
argTypes: {
|
||||
jumpToLine: { action: 'jumpToLine' },
|
||||
onToggle: { action: 'onToggle' },
|
||||
toggleExpanded: { action: 'toggleExpanded' },
|
||||
},
|
||||
args: {
|
||||
eventTracking: { sendMB: () => {} },
|
||||
isTexFile: true,
|
||||
outline: [],
|
||||
jumpToLine: () => {},
|
||||
onToggle: () => {},
|
||||
show: true,
|
||||
expanded: true,
|
||||
},
|
||||
decorators: [ScopeDecorator],
|
||||
}
|
||||
|
|
|
@ -92,8 +92,15 @@
|
|||
height: 6px;
|
||||
background-color: @vertical-resizable-resizer-bg;
|
||||
|
||||
&:hover {
|
||||
background-color: @vertical-resizable-resizer-hover-bg;
|
||||
&.vertical-resize-handle-enabled {
|
||||
&:hover {
|
||||
background-color: @vertical-resizable-resizer-hover-bg;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.vertical-resize-handle-enabled) {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&::after {
|
||||
|
@ -120,6 +127,7 @@
|
|||
|
||||
.ide-react-file-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Prevent the file tree expanding beyond the boundary of the panel
|
||||
.file-tree {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
.outline-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: @file-tree-bg;
|
||||
}
|
||||
|
||||
|
|
|
@ -332,7 +332,7 @@
|
|||
"react-i18next": "^13.3.1",
|
||||
"react-linkify": "^1.0.0-alpha",
|
||||
"react-refresh": "^0.14.0",
|
||||
"react-resizable-panels": "^0.0.63",
|
||||
"react-resizable-panels": "^1.0.1",
|
||||
"react2angular": "^4.0.6",
|
||||
"react2angular-shared-context": "^1.1.0",
|
||||
"requirejs": "^2.3.6",
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
import OutlineItem from '../../../../../frontend/js/features/outline/components/outline-item'
|
||||
|
||||
describe('<OutlineItem />', function () {
|
||||
it('renders basic item', function () {
|
||||
cy.mount(
|
||||
<OutlineItem
|
||||
// @ts-ignore
|
||||
outlineItem={{
|
||||
title: 'Test Title',
|
||||
line: 1,
|
||||
}}
|
||||
jumpToLine={cy.stub()}
|
||||
/>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { current: false })
|
||||
cy.findByRole('button', { name: 'Test Title' })
|
||||
cy.findByRole('button', { name: 'Collapse' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('collapses and expands', function () {
|
||||
cy.mount(
|
||||
<OutlineItem
|
||||
// @ts-ignore
|
||||
outlineItem={{
|
||||
title: 'Parent',
|
||||
line: 1,
|
||||
children: [{ title: 'Child', line: 2 }],
|
||||
}}
|
||||
jumpToLine={cy.stub()}
|
||||
/>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Child' })
|
||||
cy.findByRole('button', { name: 'Collapse' }).click()
|
||||
cy.findByRole('button', { name: 'Expand' })
|
||||
cy.findByRole('button', { name: 'Child' }).should('not.exist')
|
||||
})
|
||||
|
||||
it('highlights', function () {
|
||||
cy.mount(
|
||||
<OutlineItem
|
||||
// @ts-ignore
|
||||
outlineItem={{
|
||||
title: 'Parent',
|
||||
line: 1,
|
||||
}}
|
||||
jumpToLine={cy.stub()}
|
||||
highlightedLine={1}
|
||||
matchesHighlightedLine
|
||||
/>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { current: true })
|
||||
})
|
||||
|
||||
it('highlights when has collapsed highlighted child', function () {
|
||||
cy.mount(
|
||||
<OutlineItem
|
||||
// @ts-ignore
|
||||
outlineItem={{
|
||||
title: 'Parent',
|
||||
line: 1,
|
||||
children: [{ title: 'Child', line: 2 }],
|
||||
}}
|
||||
jumpToLine={cy.stub()}
|
||||
highlightedLine={2}
|
||||
containsHighlightedLine
|
||||
/>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { name: 'Parent', current: false })
|
||||
cy.findByRole('treeitem', { name: 'Child', current: true })
|
||||
cy.findByRole('button', { name: 'Collapse' }).click()
|
||||
cy.findByRole('treeitem', { name: 'Parent', current: true })
|
||||
})
|
||||
|
||||
it('click and double-click jump to location', function () {
|
||||
cy.mount(
|
||||
<OutlineItem
|
||||
// @ts-ignore
|
||||
outlineItem={{
|
||||
title: 'Parent',
|
||||
line: 1,
|
||||
}}
|
||||
jumpToLine={cy.stub().as('jumpToLine')}
|
||||
/>
|
||||
)
|
||||
|
||||
cy.findByRole('button', { name: 'Parent' }).click()
|
||||
cy.get('@jumpToLine').should('have.been.calledOnceWith', 1, false)
|
||||
|
||||
cy.findByRole('button', { name: 'Parent' }).dblclick()
|
||||
cy.get('@jumpToLine').should('have.been.calledThrice')
|
||||
cy.get('@jumpToLine').should('have.been.calledWith', 1, true)
|
||||
})
|
||||
})
|
|
@ -1,109 +0,0 @@
|
|||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { screen, render, fireEvent, waitFor } from '@testing-library/react'
|
||||
|
||||
import OutlineItem from '../../../../../frontend/js/features/outline/components/outline-item'
|
||||
|
||||
describe('<OutlineItem />', function () {
|
||||
const jumpToLine = sinon.stub()
|
||||
|
||||
afterEach(function () {
|
||||
jumpToLine.reset()
|
||||
})
|
||||
|
||||
it('renders basic item', function () {
|
||||
const outlineItem = {
|
||||
title: 'Test Title',
|
||||
line: 1,
|
||||
}
|
||||
render(<OutlineItem outlineItem={outlineItem} jumpToLine={jumpToLine} />)
|
||||
|
||||
screen.getByRole('treeitem', { current: false })
|
||||
screen.getByRole('button', { name: outlineItem.title })
|
||||
expect(screen.queryByRole('button', { name: 'Collapse' })).to.not.exist
|
||||
})
|
||||
|
||||
it('collapses and expands', function () {
|
||||
const outlineItem = {
|
||||
title: 'Parent',
|
||||
line: 1,
|
||||
children: [{ title: 'Child', line: 2 }],
|
||||
}
|
||||
render(<OutlineItem outlineItem={outlineItem} jumpToLine={jumpToLine} />)
|
||||
|
||||
const collapseButton = screen.getByRole('button', { name: 'Collapse' })
|
||||
|
||||
// test that children are rendered
|
||||
screen.getByRole('button', { name: 'Child' })
|
||||
|
||||
fireEvent.click(collapseButton)
|
||||
|
||||
screen.getByRole('button', { name: 'Expand' })
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Child' })).to.not.exist
|
||||
})
|
||||
|
||||
it('highlights', function () {
|
||||
const outlineItem = {
|
||||
title: 'Parent',
|
||||
line: 1,
|
||||
}
|
||||
|
||||
render(
|
||||
<OutlineItem
|
||||
outlineItem={outlineItem}
|
||||
jumpToLine={jumpToLine}
|
||||
highlightedLine={1}
|
||||
matchesHighlightedLine
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByRole('treeitem', { current: true })
|
||||
})
|
||||
|
||||
it('highlights when has collapsed highlighted child', function () {
|
||||
const outlineItem = {
|
||||
title: 'Parent',
|
||||
line: 1,
|
||||
children: [{ title: 'Child', line: 2 }],
|
||||
}
|
||||
render(
|
||||
<OutlineItem
|
||||
outlineItem={outlineItem}
|
||||
jumpToLine={jumpToLine}
|
||||
highlightedLine={2}
|
||||
containsHighlightedLine
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByRole('treeitem', { name: 'Parent', current: false })
|
||||
screen.getByRole('treeitem', { name: 'Child', current: true })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
|
||||
screen.getByRole('treeitem', { name: 'Parent', current: true })
|
||||
})
|
||||
|
||||
it('click and double-click jump to location', async function () {
|
||||
const outlineItem = {
|
||||
title: 'Parent',
|
||||
line: 1,
|
||||
}
|
||||
render(<OutlineItem outlineItem={outlineItem} jumpToLine={jumpToLine} />)
|
||||
|
||||
const titleButton = screen.getByRole('button', { name: outlineItem.title })
|
||||
|
||||
fireEvent.click(titleButton, { detail: 1 })
|
||||
await waitFor(() => {
|
||||
expect(jumpToLine).to.be.calledOnce
|
||||
expect(jumpToLine).to.be.calledWith(1, false)
|
||||
})
|
||||
|
||||
jumpToLine.reset()
|
||||
fireEvent.click(titleButton, { detail: 2 })
|
||||
await waitFor(() => {
|
||||
expect(jumpToLine).to.be.calledOnce
|
||||
expect(jumpToLine).to.be.calledWith(1, true)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,45 @@
|
|||
import OutlineList from '../../../../../frontend/js/features/outline/components/outline-list'
|
||||
|
||||
describe('<OutlineList />', function () {
|
||||
it('renders items', function () {
|
||||
cy.mount(
|
||||
<OutlineList
|
||||
// @ts-ignore
|
||||
outline={[
|
||||
{ title: 'Section 1', line: 1, level: 10 },
|
||||
{ title: 'Section 2', line: 2, level: 10 },
|
||||
]}
|
||||
isRoot
|
||||
jumpToLine={cy.stub()}
|
||||
/>
|
||||
)
|
||||
|
||||
cy.findByRole('treeitem', { name: 'Section 1' })
|
||||
cy.findByRole('treeitem', { name: 'Section 2' })
|
||||
})
|
||||
|
||||
it('renders as root', function () {
|
||||
cy.mount(
|
||||
<OutlineList
|
||||
// @ts-ignore
|
||||
outline={[{ title: 'Section', line: 1, level: 10 }]}
|
||||
isRoot
|
||||
jumpToLine={cy.stub()}
|
||||
/>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
})
|
||||
|
||||
it('renders as non-root', function () {
|
||||
cy.mount(
|
||||
<OutlineList
|
||||
// @ts-ignore
|
||||
outline={[{ title: 'Section', line: 1, level: 10 }]}
|
||||
jumpToLine={cy.stub()}
|
||||
/>
|
||||
)
|
||||
|
||||
cy.findByRole('group')
|
||||
})
|
||||
})
|
|
@ -1,52 +0,0 @@
|
|||
import { screen, render } from '@testing-library/react'
|
||||
|
||||
import OutlineList from '../../../../../frontend/js/features/outline/components/outline-list'
|
||||
|
||||
describe('<OutlineList />', function () {
|
||||
const jumpToLine = () => {}
|
||||
|
||||
it('renders items', function () {
|
||||
const outline = [
|
||||
{
|
||||
title: 'Section 1',
|
||||
line: 1,
|
||||
level: 10,
|
||||
},
|
||||
{
|
||||
title: 'Section 2',
|
||||
line: 2,
|
||||
level: 10,
|
||||
},
|
||||
]
|
||||
render(<OutlineList outline={outline} isRoot jumpToLine={jumpToLine} />)
|
||||
|
||||
screen.getByRole('treeitem', { name: 'Section 1' })
|
||||
screen.getByRole('treeitem', { name: 'Section 2' })
|
||||
})
|
||||
|
||||
it('renders as root', function () {
|
||||
const outline = [
|
||||
{
|
||||
title: 'Section',
|
||||
line: 1,
|
||||
level: 10,
|
||||
},
|
||||
]
|
||||
render(<OutlineList outline={outline} isRoot jumpToLine={jumpToLine} />)
|
||||
|
||||
screen.getByRole('tree')
|
||||
})
|
||||
|
||||
it('renders as non-root', function () {
|
||||
const outline = [
|
||||
{
|
||||
title: 'Section',
|
||||
line: 1,
|
||||
level: 10,
|
||||
},
|
||||
]
|
||||
render(<OutlineList outline={outline} jumpToLine={jumpToLine} />)
|
||||
|
||||
screen.getByRole('group')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,118 @@
|
|||
import OutlinePane from '@/features/outline/components/outline-pane'
|
||||
import { EditorProviders, PROJECT_ID } from '../../../helpers/editor-providers'
|
||||
import { useState } from 'react'
|
||||
|
||||
describe('<OutlinePane />', function () {
|
||||
it('renders expanded outline', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<OutlinePane
|
||||
isTexFile
|
||||
outline={[{ title: 'Section', line: 1, level: 10 }]}
|
||||
jumpToLine={cy.stub()}
|
||||
onToggle={cy.stub()}
|
||||
expanded
|
||||
toggleExpanded={cy.stub()}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
})
|
||||
|
||||
it('renders disabled outline', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<OutlinePane
|
||||
isTexFile
|
||||
outline={[]}
|
||||
jumpToLine={cy.stub()}
|
||||
onToggle={cy.stub()}
|
||||
expanded
|
||||
toggleExpanded={cy.stub()}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree').should('not.exist')
|
||||
})
|
||||
|
||||
it('expand outline and use local storage', function () {
|
||||
window.localStorage.setItem(`file_outline.expanded.${PROJECT_ID}`, 'false')
|
||||
|
||||
const onToggle = cy.stub()
|
||||
|
||||
const Container = () => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<OutlinePane
|
||||
isTexFile
|
||||
outline={[{ title: 'Section', line: 1, level: 10 }]}
|
||||
jumpToLine={cy.stub()}
|
||||
onToggle={onToggle}
|
||||
expanded={expanded}
|
||||
toggleExpanded={() => {
|
||||
window.localStorage.setItem(
|
||||
`file_outline.expanded.${PROJECT_ID}`,
|
||||
expanded ? 'false' : 'true'
|
||||
)
|
||||
setExpanded(!expanded)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<Container />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('tree').should('not.exist')
|
||||
|
||||
cy.findByRole('button', {
|
||||
name: 'Show File outline',
|
||||
}).click()
|
||||
|
||||
cy.findByRole('tree').then(() => {
|
||||
expect(onToggle).to.be.calledTwice
|
||||
expect(
|
||||
window.localStorage.getItem(`file_outline.expanded.${PROJECT_ID}`)
|
||||
).to.equal('true')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows warning on partial result', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<OutlinePane
|
||||
isTexFile
|
||||
outline={[]}
|
||||
jumpToLine={cy.stub()}
|
||||
onToggle={cy.stub()}
|
||||
toggleExpanded={cy.stub()}
|
||||
isPartial
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('status')
|
||||
})
|
||||
|
||||
it('shows no warning on non-partial result', function () {
|
||||
cy.mount(
|
||||
<EditorProviders>
|
||||
<OutlinePane
|
||||
isTexFile
|
||||
outline={[]}
|
||||
jumpToLine={cy.stub()}
|
||||
onToggle={cy.stub()}
|
||||
toggleExpanded={cy.stub()}
|
||||
/>
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.findByRole('status').should('not.exist')
|
||||
})
|
||||
})
|
|
@ -1,149 +0,0 @@
|
|||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { screen, fireEvent } from '@testing-library/react'
|
||||
|
||||
import OutlinePane from '../../../../../frontend/js/features/outline/components/outline-pane'
|
||||
import { renderWithEditorContext } from '../../../helpers/render-with-context'
|
||||
|
||||
describe('<OutlinePane />', function () {
|
||||
const jumpToLine = () => {}
|
||||
const onToggle = sinon.stub()
|
||||
const eventTracking = { sendMB: sinon.stub() }
|
||||
|
||||
function render(children) {
|
||||
renderWithEditorContext(children, { projectId: '123abc' })
|
||||
}
|
||||
|
||||
let originalLocalStorage
|
||||
before(function () {
|
||||
originalLocalStorage = global.localStorage
|
||||
|
||||
Object.defineProperty(global, 'localStorage', {
|
||||
value: {
|
||||
getItem: sinon.stub().returns(null),
|
||||
setItem: sinon.stub(),
|
||||
removeItem: sinon.stub(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
onToggle.reset()
|
||||
eventTracking.sendMB.reset()
|
||||
global.localStorage.getItem.resetHistory()
|
||||
global.localStorage.setItem.resetHistory()
|
||||
})
|
||||
|
||||
after(function () {
|
||||
Object.defineProperty(global, 'localStorage', {
|
||||
value: originalLocalStorage,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders expanded outline', function () {
|
||||
const outline = [
|
||||
{
|
||||
title: 'Section',
|
||||
line: 1,
|
||||
level: 10,
|
||||
},
|
||||
]
|
||||
render(
|
||||
<OutlinePane
|
||||
isTexFile
|
||||
outline={outline}
|
||||
jumpToLine={jumpToLine}
|
||||
onToggle={onToggle}
|
||||
eventTracking={eventTracking}
|
||||
show
|
||||
/>
|
||||
)
|
||||
|
||||
screen.getByRole('tree')
|
||||
})
|
||||
|
||||
it('renders disabled outline', function () {
|
||||
const outline = []
|
||||
render(
|
||||
<OutlinePane
|
||||
isTexFile={false}
|
||||
outline={outline}
|
||||
jumpToLine={jumpToLine}
|
||||
onToggle={onToggle}
|
||||
eventTracking={eventTracking}
|
||||
show
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('tree')).to.be.null
|
||||
})
|
||||
|
||||
it('expand outline and use local storage', function () {
|
||||
global.localStorage.getItem.callsFake(key => {
|
||||
if (key.startsWith('file_outline.expanded.')) {
|
||||
return false
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const outline = [
|
||||
{
|
||||
title: 'Section',
|
||||
line: 1,
|
||||
level: 10,
|
||||
},
|
||||
]
|
||||
render(
|
||||
<OutlinePane
|
||||
isTexFile
|
||||
outline={outline}
|
||||
jumpToLine={jumpToLine}
|
||||
onToggle={onToggle}
|
||||
eventTracking={eventTracking}
|
||||
show
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('tree')).to.be.null
|
||||
const collapseButton = screen.getByRole('button', {
|
||||
name: 'Show File outline',
|
||||
})
|
||||
fireEvent.click(collapseButton)
|
||||
|
||||
screen.getByRole('tree')
|
||||
expect(global.localStorage.setItem).to.be.calledOnce
|
||||
expect(global.localStorage.setItem).to.be.calledWithMatch(/123abc/, 'true')
|
||||
expect(onToggle).to.be.calledTwice
|
||||
})
|
||||
|
||||
it('shows warning on partial result', function () {
|
||||
render(
|
||||
<OutlinePane
|
||||
isTexFile
|
||||
outline={[]}
|
||||
jumpToLine={jumpToLine}
|
||||
onToggle={onToggle}
|
||||
eventTracking={eventTracking}
|
||||
show
|
||||
isPartial
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('status')).to.exist
|
||||
})
|
||||
|
||||
it('shows no warning on non-partial result', function () {
|
||||
render(
|
||||
<OutlinePane
|
||||
isTexFile
|
||||
outline={[]}
|
||||
jumpToLine={jumpToLine}
|
||||
onToggle={onToggle}
|
||||
eventTracking={eventTracking}
|
||||
show
|
||||
isPartial={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('status')).to.not.exist
|
||||
})
|
||||
})
|
|
@ -0,0 +1,22 @@
|
|||
import OutlineRoot from '../../../../../frontend/js/features/outline/components/outline-root'
|
||||
|
||||
describe('<OutlineRoot />', function () {
|
||||
it('renders outline', function () {
|
||||
cy.mount(
|
||||
<OutlineRoot
|
||||
outline={[{ title: 'Section', line: 1, level: 10 }]}
|
||||
jumpToLine={cy.stub()}
|
||||
/>
|
||||
)
|
||||
|
||||
cy.findByRole('tree')
|
||||
cy.findByRole('link').should('not.exist')
|
||||
})
|
||||
|
||||
it('renders placeholder', function () {
|
||||
cy.mount(<OutlineRoot outline={[]} jumpToLine={cy.stub()} />)
|
||||
|
||||
cy.findByRole('tree').should('not.exist')
|
||||
cy.findByRole('link')
|
||||
})
|
||||
})
|
|
@ -1,30 +0,0 @@
|
|||
import { expect } from 'chai'
|
||||
import { screen, render } from '@testing-library/react'
|
||||
|
||||
import OutlineRoot from '../../../../../frontend/js/features/outline/components/outline-root'
|
||||
|
||||
describe('<OutlineRoot />', function () {
|
||||
const jumpToLine = () => {}
|
||||
|
||||
it('renders outline', function () {
|
||||
const outline = [
|
||||
{
|
||||
title: 'Section',
|
||||
line: 1,
|
||||
level: 10,
|
||||
},
|
||||
]
|
||||
render(<OutlineRoot outline={outline} jumpToLine={jumpToLine} />)
|
||||
|
||||
screen.getByRole('tree')
|
||||
expect(screen.queryByRole('link')).to.be.null
|
||||
})
|
||||
|
||||
it('renders placeholder', function () {
|
||||
const outline = []
|
||||
render(<OutlineRoot outline={outline} jumpToLine={jumpToLine} />)
|
||||
|
||||
expect(screen.queryByRole('tree')).to.be.null
|
||||
screen.getByRole('link')
|
||||
})
|
||||
})
|
|
@ -15,6 +15,7 @@ import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
|
|||
import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
|
||||
import { FileTreePathProvider } from '@/features/file-tree/contexts/file-tree-path'
|
||||
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
|
||||
import { OutlineProvider } from '@/features/ide-react/context/outline-context'
|
||||
|
||||
// these constants can be imported in tests instead of
|
||||
// using magic strings
|
||||
|
@ -141,6 +142,7 @@ export function EditorProviders({
|
|||
IdeAngularProvider,
|
||||
LayoutProvider,
|
||||
LocalCompileProvider,
|
||||
OutlineProvider,
|
||||
ProjectProvider,
|
||||
ProjectSettingsProvider,
|
||||
SplitTestProvider,
|
||||
|
@ -163,7 +165,9 @@ export function EditorProviders({
|
|||
<Providers.LayoutProvider>
|
||||
<Providers.LocalCompileProvider>
|
||||
<Providers.DetachCompileProvider>
|
||||
{children}
|
||||
<Providers.OutlineProvider>
|
||||
{children}
|
||||
</Providers.OutlineProvider>
|
||||
</Providers.DetachCompileProvider>
|
||||
</Providers.LocalCompileProvider>
|
||||
</Providers.LayoutProvider>
|
||||
|
|
Loading…
Reference in a new issue