first commit
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { FC, memo } from 'react'
|
||||
import OutlinePane from '@/features/outline/components/outline-pane'
|
||||
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
|
||||
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
|
||||
import useNestedOutline from '../hooks/use-nested-outline'
|
||||
|
||||
export const OutlineContainer: FC = memo(() => {
|
||||
const {
|
||||
highlightedLine,
|
||||
canShowOutline,
|
||||
jumpToLine,
|
||||
outlineExpanded,
|
||||
toggleOutlineExpanded,
|
||||
} = useOutlineContext()
|
||||
|
||||
const outlineToggledEmitter = useScopeEventEmitter('outline-toggled')
|
||||
|
||||
const outline = useNestedOutline()
|
||||
|
||||
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'
|
||||
@@ -0,0 +1,24 @@
|
||||
import { memo, type Dispatch, type SetStateAction } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
export const OutlineItemToggleButton = memo<{
|
||||
expanded: boolean
|
||||
setExpanded: Dispatch<SetStateAction<boolean>>
|
||||
}>(({ expanded, setExpanded }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<button
|
||||
className="outline-item-expand-collapse-btn"
|
||||
onClick={() => setExpanded(value => !value)}
|
||||
aria-label={expanded ? t('collapse') : t('expand')}
|
||||
>
|
||||
<MaterialIcon
|
||||
type={expanded ? 'keyboard_arrow_down' : 'keyboard_arrow_right'}
|
||||
className="outline-caret-icon"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
OutlineItemToggleButton.displayName = 'OutlineItemToggleButton'
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, useRef, memo } from 'react'
|
||||
import scrollIntoViewIfNeeded from 'scroll-into-view-if-needed'
|
||||
import classNames from 'classnames'
|
||||
import OutlineList from './outline-list'
|
||||
import { OutlineItemToggleButton } from '@/features/outline/components/outline-item-toggle-button'
|
||||
import { OutlineItemData } from '@/features/ide-react/types/outline'
|
||||
|
||||
const OutlineItem = memo(function OutlineItem({
|
||||
outlineItem,
|
||||
jumpToLine,
|
||||
highlightedLine,
|
||||
matchesHighlightedLine,
|
||||
containsHighlightedLine,
|
||||
}: {
|
||||
outlineItem: OutlineItemData
|
||||
jumpToLine: (line: number, syncToPdf: boolean) => void
|
||||
highlightedLine?: number | null
|
||||
matchesHighlightedLine?: boolean
|
||||
containsHighlightedLine?: boolean
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const titleElementRef = useRef<HTMLButtonElement>(null)
|
||||
const isHighlightedRef = useRef<boolean>(false)
|
||||
|
||||
const mainItemClasses = classNames('outline-item', {
|
||||
'outline-item-no-children': !outlineItem.children,
|
||||
})
|
||||
|
||||
const hasHighlightedChild = !expanded && containsHighlightedLine
|
||||
const isHighlighted = matchesHighlightedLine || hasHighlightedChild
|
||||
|
||||
const itemLinkClasses = classNames('outline-item-link', {
|
||||
'outline-item-link-highlight': isHighlighted,
|
||||
})
|
||||
|
||||
function handleOutlineItemLinkClick(event: React.MouseEvent) {
|
||||
const syncToPdf = event.detail === 2 // double-click = sync to PDF
|
||||
jumpToLine(outlineItem.line, syncToPdf)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const wasHighlighted = isHighlightedRef.current
|
||||
isHighlightedRef.current = !!isHighlighted
|
||||
|
||||
if (!wasHighlighted && isHighlighted && titleElementRef.current) {
|
||||
scrollIntoViewIfNeeded(titleElementRef.current, {
|
||||
scrollMode: 'if-needed',
|
||||
block: 'center',
|
||||
})
|
||||
}
|
||||
}, [isHighlighted, titleElementRef, isHighlightedRef])
|
||||
|
||||
// don't set the aria-expanded attribute when there are no children
|
||||
const ariaExpandedValue = outlineItem.children ? expanded : undefined
|
||||
|
||||
return (
|
||||
<li
|
||||
className={mainItemClasses}
|
||||
aria-expanded={ariaExpandedValue}
|
||||
// FIXME
|
||||
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
|
||||
role="treeitem"
|
||||
aria-current={isHighlighted}
|
||||
aria-label={outlineItem.title}
|
||||
>
|
||||
<div className="outline-item-row">
|
||||
{!!outlineItem.children && (
|
||||
<OutlineItemToggleButton
|
||||
expanded={expanded}
|
||||
setExpanded={setExpanded}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={itemLinkClasses}
|
||||
onClick={handleOutlineItemLinkClick}
|
||||
ref={titleElementRef}
|
||||
>
|
||||
{outlineItem.title}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && outlineItem.children ? (
|
||||
// highlightedLine is only provided to this list if the list contains
|
||||
// the highlighted line. This means that whenever the list does not
|
||||
// contain the highlighted line, the props provided to it are the same
|
||||
// and the component can be memoized.
|
||||
<OutlineList
|
||||
outline={outlineItem.children}
|
||||
jumpToLine={jumpToLine}
|
||||
isRoot={false}
|
||||
highlightedLine={containsHighlightedLine ? highlightedLine : null}
|
||||
containsHighlightedLine={containsHighlightedLine}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})
|
||||
|
||||
export default OutlineItem
|
||||
@@ -0,0 +1,57 @@
|
||||
import classNames from 'classnames'
|
||||
import OutlineItem from './outline-item'
|
||||
import { memo } from 'react'
|
||||
import { OutlineItemData } from '@/features/ide-react/types/outline'
|
||||
import getChildrenLines from '../util/get-children-lines'
|
||||
|
||||
const OutlineList = memo(function OutlineList({
|
||||
outline,
|
||||
jumpToLine,
|
||||
isRoot,
|
||||
highlightedLine,
|
||||
containsHighlightedLine,
|
||||
}: {
|
||||
outline: OutlineItemData[]
|
||||
jumpToLine: (line: number, syncToPdf: boolean) => void
|
||||
isRoot?: boolean
|
||||
highlightedLine?: number | null
|
||||
containsHighlightedLine?: boolean
|
||||
}) {
|
||||
const listClasses = classNames('outline-item-list', {
|
||||
'outline-item-list-root': isRoot,
|
||||
})
|
||||
return (
|
||||
<ul className={listClasses} role={isRoot ? 'tree' : 'group'}>
|
||||
{outline.map((outlineItem, idx) => {
|
||||
const matchesHighlightedLine =
|
||||
containsHighlightedLine && highlightedLine === outlineItem.line
|
||||
const itemContainsHighlightedLine =
|
||||
highlightedLine !== undefined &&
|
||||
highlightedLine !== null &&
|
||||
containsHighlightedLine &&
|
||||
getChildrenLines(outlineItem.children).includes(highlightedLine)
|
||||
|
||||
// highlightedLine is only provided to the item if the item matches or
|
||||
// contains the highlighted line. This means that whenever the item does
|
||||
// not contain the highlighted line, the props provided to it are the
|
||||
// same and the component can be memoized.
|
||||
return (
|
||||
<OutlineItem
|
||||
key={`${outlineItem.level}-${idx}`}
|
||||
outlineItem={outlineItem}
|
||||
jumpToLine={jumpToLine}
|
||||
highlightedLine={
|
||||
matchesHighlightedLine || itemContainsHighlightedLine
|
||||
? highlightedLine
|
||||
: null
|
||||
}
|
||||
matchesHighlightedLine={matchesHighlightedLine}
|
||||
containsHighlightedLine={itemContainsHighlightedLine}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
})
|
||||
|
||||
export default OutlineList
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import classNames from 'classnames'
|
||||
|
||||
import OutlineRoot from './outline-root'
|
||||
import withErrorBoundary from '../../../infrastructure/error-boundary'
|
||||
import { OutlineToggleButton } from '@/features/outline/components/outline-toggle-button'
|
||||
import { OutlineItemData } from '@/features/ide-react/types/outline'
|
||||
|
||||
const OutlinePane = React.memo<{
|
||||
isTexFile: boolean
|
||||
outline: OutlineItemData[]
|
||||
jumpToLine(line: number): void
|
||||
onToggle(value: boolean): void
|
||||
eventTracking: any
|
||||
highlightedLine?: number
|
||||
show: boolean
|
||||
isPartial?: boolean
|
||||
expanded?: boolean
|
||||
toggleExpanded: () => void
|
||||
}>(function OutlinePane({
|
||||
isTexFile,
|
||||
outline,
|
||||
jumpToLine,
|
||||
onToggle,
|
||||
highlightedLine,
|
||||
isPartial = false,
|
||||
expanded,
|
||||
toggleExpanded,
|
||||
}) {
|
||||
const isOpen = Boolean(isTexFile && expanded)
|
||||
|
||||
useEffect(() => {
|
||||
onToggle(isOpen)
|
||||
}, [isOpen, onToggle])
|
||||
|
||||
const headerClasses = classNames('outline-pane', {
|
||||
'outline-pane-disabled': !isTexFile,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={headerClasses}>
|
||||
<header className="outline-header">
|
||||
<OutlineToggleButton
|
||||
toggleExpanded={toggleExpanded}
|
||||
expanded={expanded}
|
||||
isOpen={isOpen}
|
||||
isPartial={isPartial}
|
||||
isTexFile={isTexFile}
|
||||
/>
|
||||
</header>
|
||||
{isOpen && (
|
||||
<div className="outline-body">
|
||||
<OutlineRoot
|
||||
outline={outline}
|
||||
jumpToLine={jumpToLine}
|
||||
highlightedLine={highlightedLine}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default withErrorBoundary(OutlinePane)
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OutlineList from './outline-list'
|
||||
import { OutlineItemData } from '@/features/ide-react/types/outline'
|
||||
|
||||
function OutlineRoot({
|
||||
outline,
|
||||
jumpToLine,
|
||||
highlightedLine,
|
||||
}: {
|
||||
outline: OutlineItemData[]
|
||||
jumpToLine: (line: number, syncToPdf: boolean) => void
|
||||
highlightedLine?: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{outline.length ? (
|
||||
<OutlineList
|
||||
outline={outline}
|
||||
jumpToLine={jumpToLine}
|
||||
isRoot
|
||||
highlightedLine={highlightedLine}
|
||||
containsHighlightedLine
|
||||
/>
|
||||
) : (
|
||||
<div className="outline-body-no-elements">
|
||||
{t('we_cant_find_any_sections_or_subsections_in_this_file')}.{' '}
|
||||
<a
|
||||
href="/learn/how-to/Using_the_File_Outline_feature"
|
||||
className="outline-body-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('find_out_more_about_the_file_outline')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OutlineRoot
|
||||
@@ -0,0 +1,44 @@
|
||||
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import React, { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const OutlineToggleButton = memo<{
|
||||
isTexFile: boolean
|
||||
toggleExpanded: () => void
|
||||
expanded?: boolean
|
||||
isOpen: boolean
|
||||
isPartial: boolean
|
||||
}>(({ isTexFile, toggleExpanded, expanded, isOpen, isPartial }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<button
|
||||
className="outline-header-expand-collapse-btn"
|
||||
disabled={!isTexFile}
|
||||
onClick={toggleExpanded}
|
||||
aria-label={expanded ? t('hide_outline') : t('show_outline')}
|
||||
>
|
||||
<MaterialIcon
|
||||
type={isOpen ? 'keyboard_arrow_down' : 'keyboard_arrow_right'}
|
||||
className="outline-caret-icon"
|
||||
/>
|
||||
<h4 className="outline-header-name">{t('file_outline')}</h4>
|
||||
{isPartial && (
|
||||
<OLTooltip
|
||||
id="partial-outline"
|
||||
description={t('partial_outline_warning')}
|
||||
overlayProps={{ placement: 'top' }}
|
||||
>
|
||||
<span role="status" style={{ display: 'flex' }}>
|
||||
<MaterialIcon
|
||||
type="warning"
|
||||
accessibilityLabel={t('partial_outline_warning')}
|
||||
/>
|
||||
</span>
|
||||
</OLTooltip>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
OutlineToggleButton.displayName = 'OutlineToggleButton'
|
||||
Reference in New Issue
Block a user