Merge pull request #23747 from overleaf/dp-breadcrumbs

Add file breadcrumbs to new editor

GitOrigin-RevId: 54bde446ad632976503a2c4aff915c862bad710e
This commit is contained in:
David
2025-03-10 09:53:19 +00:00
committed by Copybot
parent 3a98940324
commit 2b38bae54f
11 changed files with 248 additions and 72 deletions
@@ -0,0 +1,104 @@
import { findInTreeOrThrow } from '@/features/file-tree/util/find-in-tree'
import { useFileTreeOpenContext } from '@/features/ide-react/context/file-tree-open-context'
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
import useNestedOutline from '@/features/outline/hooks/use-nested-outline'
import getChildrenLines from '@/features/outline/util/get-children-lines'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
import MaterialIcon from '@/shared/components/material-icon'
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { getPanel } from '@codemirror/view'
import { Fragment, useMemo } from 'react'
import { Outline } from '@/features/source-editor/utils/tree-operations/outline'
import { createPortal } from 'react-dom'
import { createBreadcrumbsPanel } from '@/features/source-editor/extensions/breadcrumbs-panel'
const constructOutlineHierarchy = (
items: Outline[],
highlightedLine: number,
outlineHierarchy: Outline[] = []
) => {
for (const item of items) {
if (item.line === highlightedLine) {
outlineHierarchy.push(item)
return outlineHierarchy
}
const childLines = getChildrenLines(item.children)
if (childLines.includes(highlightedLine)) {
outlineHierarchy.push(item)
return constructOutlineHierarchy(
item.children as Outline[],
highlightedLine,
outlineHierarchy
)
}
}
return outlineHierarchy
}
export default function Breadcrumbs() {
const view = useCodeMirrorViewContext()
const panel = getPanel(view, createBreadcrumbsPanel)
if (!panel) {
return null
}
return createPortal(<BreadcrumbsContent />, panel.dom)
}
function BreadcrumbsContent() {
const { openEntity } = useFileTreeOpenContext()
const { fileTreeData } = useFileTreeData()
const outline = useNestedOutline()
const { highlightedLine, canShowOutline } = useOutlineContext()
const folderHierarchy = useMemo(() => {
if (!openEntity || !fileTreeData) {
return []
}
return openEntity.path
.filter(id => id !== fileTreeData._id) // Filter out the root folder
.map(id => {
return findInTreeOrThrow(fileTreeData, id)?.entity
})
}, [openEntity, fileTreeData])
const outlineHierarchy = useMemo(() => {
if (!canShowOutline || !outline) {
return []
}
return constructOutlineHierarchy(outline.items, highlightedLine)
}, [outline, highlightedLine, canShowOutline])
if (!openEntity || !fileTreeData) {
return null
}
const numOutlineItems = outlineHierarchy.length
return (
<div className="ol-cm-breadcrumbs">
{folderHierarchy.map(folder => (
<Fragment key={folder._id}>
<div>{folder.name}</div>
<Chevron />
</Fragment>
))}
<MaterialIcon unfilled type="description" />
<div>{openEntity.entity.name}</div>
{numOutlineItems > 0 && <Chevron />}
{outlineHierarchy.map((section, idx) => (
<Fragment key={section.line}>
<div>{section.title}</div>
{idx < numOutlineItems - 1 && <Chevron />}
</Fragment>
))}
</div>
)
}
const Chevron = () => (
<MaterialIcon className="ol-cm-breadcrumb-chevron" type="chevron_right" />
)
@@ -1,20 +1,11 @@
import { FC, memo, useEffect, useRef, useState } from 'react'
import { FC, memo } 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 { useOutlineContext } from '@/features/ide-react/context/outline-context'
import useScopeEventEmitter from '@/shared/hooks/use-scope-event-emitter'
import { debugConsole } from '@/utils/debugging'
import useNestedOutline from '../hooks/use-nested-outline'
export const OutlineContainer: FC = memo(() => {
const {
flatOutline,
highlightedLine,
canShowOutline,
jumpToLine,
@@ -24,31 +15,7 @@ export const OutlineContainer: FC = memo(() => {
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])
const outline = useNestedOutline()
return (
<div className="outline-container">
@@ -66,30 +33,3 @@ export const OutlineContainer: FC = memo(() => {
)
})
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
}
@@ -2,14 +2,7 @@ import classNames from 'classnames'
import OutlineItem from './outline-item'
import { memo } from 'react'
import { OutlineItemData } from '@/features/ide-react/types/outline'
function getChildrenLines(children?: OutlineItemData[]): number[] {
return (children || [])
.map(child => {
return getChildrenLines(child.children).concat(child.line)
})
.flat()
}
import getChildrenLines from '../util/get-children-lines'
const OutlineList = memo(function OutlineList({
outline,
@@ -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
}
@@ -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()
}
@@ -18,6 +18,7 @@ import {
CodeMirrorViewContext,
} from './codemirror-context'
import MathPreviewTooltip from './math-preview-tooltip'
import Breadcrumbs from '@/features/ide-redesign/components/breadcrumbs'
// TODO: remove this when definitely no longer used
export * from './codemirror-context'
@@ -39,6 +40,7 @@ function CodeMirrorEditor() {
const isMounted = useIsMounted()
const newReviewPanel = useFeatureFlag('review-panel-redesign')
const newEditor = useFeatureFlag('editor-redesign')
// create the view using the initial state and intercept transactions
const viewRef = useRef<EditorView | null>(null)
@@ -72,6 +74,7 @@ function CodeMirrorEditor() {
<Component key={path} />
)
)}
{newEditor && <Breadcrumbs />}
<CodeMirrorCommandTooltip />
<MathPreviewTooltip />
@@ -0,0 +1,19 @@
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { showPanel } from '@codemirror/view'
export function createBreadcrumbsPanel() {
const dom = document.createElement('div')
dom.classList.add('ol-cm-breadcrumbs-portal')
return { dom, top: true }
}
/**
* A panel which contains the editor breadcrumbs
*/
export const breadcrumbPanel = () => {
if (!isSplitTestEnabled('editor-redesign')) {
return []
}
return [showPanel.of(createBreadcrumbsPanel)]
}
@@ -45,6 +45,7 @@ import { shortcuts } from './shortcuts'
import { effectListeners } from './effect-listeners'
import { highlightSpecialChars } from './highlight-special-chars'
import { toolbarPanel } from './toolbar/toolbar-panel'
import { breadcrumbPanel } from './breadcrumbs-panel'
import { geometryChangeEvent } from './geometry-change-event'
import { docName } from './doc-name'
import { fileTreeItemDrop } from './file-tree-item-drop'
@@ -151,6 +152,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
mathPreview(options.settings.mathPreview),
reviewTooltip(),
toolbarPanel(),
breadcrumbPanel(),
verticalOverflow(),
highlightActiveLine(options.visual.visual),
// The built-in extension that highlights the active line in the gutter.
@@ -44,3 +44,4 @@
@import 'upgrade-prompt';
@import 'integrations-panel';
@import 'group-members';
@import 'breadcrumbs';
@@ -0,0 +1,25 @@
:root {
--breadcrumb-bg-color: var(--white);
--breadcrumb-color: var(--content-secondary);
--breadcrumb-chevron-color: var(--neutral-30);
}
.ol-cm-breadcrumbs {
display: flex;
align-items: center;
gap: var(--spacing-01);
color: var(--breadcrumb-color);
background-color: var(--breadcrumb-bg-color);
font-size: var(--font-size-01);
padding: var(--spacing-02);
overflow: auto;
scrollbar-width: thin;
& > * {
flex-shrink: 0;
}
}
.ol-cm-breadcrumb-chevron {
color: var(--breadcrumb-chevron-color);
}
@@ -65,6 +65,14 @@
--editor-toolbar-bg: var(--white);
--toolbar-filetree-bg-color: var(--white);
}
.cm-panels-top {
border-bottom: none;
.ol-cm-toolbar-portal {
border-bottom: 1px solid #ddd;
}
}
}
.toolbar {