first commit

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

View File

@@ -0,0 +1,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'

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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'

View File

@@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from 'react'
import {
FlatOutlineState,
PartialFlatOutline,
useOutlineContext,
} from '@/features/ide-react/context/outline-context'
import {
nestOutline,
Outline,
} from '@/features/source-editor/utils/tree-operations/outline'
import { debugConsole } from '@/utils/debugging'
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
}
export default function useNestedOutline() {
const { flatOutline } = useOutlineContext()
const [nestedOutline, setNestedOutline] = 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')
setNestedOutline({
items: nestOutline(flatOutline.items),
partial: flatOutline.partial,
})
}
} else {
setNestedOutline({ items: [], partial: false })
}
}, [flatOutline])
return nestedOutline
}

View File

@@ -0,0 +1,11 @@
import { OutlineItemData } from '@/features/ide-react/types/outline'
export default function getChildrenLines(
children?: OutlineItemData[]
): number[] {
return (children || [])
.map(child => {
return getChildrenLines(child.children).concat(child.line)
})
.flat()
}