From 2b38bae54f17e8a1c4004ab5bc620dffd1826611 Mon Sep 17 00:00:00 2001 From: David <33458145+davidmcpowell@users.noreply.github.com> Date: Mon, 10 Mar 2025 09:53:19 +0000 Subject: [PATCH] Merge pull request #23747 from overleaf/dp-breadcrumbs Add file breadcrumbs to new editor GitOrigin-RevId: 54bde446ad632976503a2c4aff915c862bad710e --- .../ide-redesign/components/breadcrumbs.tsx | 104 ++++++++++++++++++ .../outline/components/outline-container.tsx | 68 +----------- .../outline/components/outline-list.tsx | 9 +- .../outline/hooks/use-nested-outline.ts | 70 ++++++++++++ .../outline/util/get-children-lines.ts | 11 ++ .../components/codemirror-editor.tsx | 3 + .../extensions/breadcrumbs-panel.ts | 19 ++++ .../source-editor/extensions/index.ts | 2 + .../bootstrap-5/components/all.scss | 1 + .../bootstrap-5/components/breadcrumbs.scss | 25 +++++ .../bootstrap-5/pages/editor/toolbar.scss | 8 ++ 11 files changed, 248 insertions(+), 72 deletions(-) create mode 100644 services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx create mode 100644 services/web/frontend/js/features/outline/hooks/use-nested-outline.ts create mode 100644 services/web/frontend/js/features/outline/util/get-children-lines.ts create mode 100644 services/web/frontend/js/features/source-editor/extensions/breadcrumbs-panel.ts create mode 100644 services/web/frontend/stylesheets/bootstrap-5/components/breadcrumbs.scss diff --git a/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx new file mode 100644 index 0000000000..18b7567f41 --- /dev/null +++ b/services/web/frontend/js/features/ide-redesign/components/breadcrumbs.tsx @@ -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(, 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 ( +
+ {folderHierarchy.map(folder => ( + +
{folder.name}
+ +
+ ))} + +
{openEntity.entity.name}
+ {numOutlineItems > 0 && } + {outlineHierarchy.map((section, idx) => ( + +
{section.title}
+ {idx < numOutlineItems - 1 && } +
+ ))} +
+ ) +} + +const Chevron = () => ( + +) diff --git a/services/web/frontend/js/features/outline/components/outline-container.tsx b/services/web/frontend/js/features/outline/components/outline-container.tsx index d4b833ee8f..71a27db753 100644 --- a/services/web/frontend/js/features/outline/components/outline-container.tsx +++ b/services/web/frontend/js/features/outline/components/outline-container.tsx @@ -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(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 (
@@ -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 -} diff --git a/services/web/frontend/js/features/outline/components/outline-list.tsx b/services/web/frontend/js/features/outline/components/outline-list.tsx index fc8117c5ca..92282fba59 100644 --- a/services/web/frontend/js/features/outline/components/outline-list.tsx +++ b/services/web/frontend/js/features/outline/components/outline-list.tsx @@ -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, diff --git a/services/web/frontend/js/features/outline/hooks/use-nested-outline.ts b/services/web/frontend/js/features/outline/hooks/use-nested-outline.ts new file mode 100644 index 0000000000..22ade8f8d6 --- /dev/null +++ b/services/web/frontend/js/features/outline/hooks/use-nested-outline.ts @@ -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(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 +} diff --git a/services/web/frontend/js/features/outline/util/get-children-lines.ts b/services/web/frontend/js/features/outline/util/get-children-lines.ts new file mode 100644 index 0000000000..3b272ee763 --- /dev/null +++ b/services/web/frontend/js/features/outline/util/get-children-lines.ts @@ -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() +} diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx index 527acb56d0..6441a0358c 100644 --- a/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx +++ b/services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx @@ -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(null) @@ -72,6 +74,7 @@ function CodeMirrorEditor() { ) )} + {newEditor && } diff --git a/services/web/frontend/js/features/source-editor/extensions/breadcrumbs-panel.ts b/services/web/frontend/js/features/source-editor/extensions/breadcrumbs-panel.ts new file mode 100644 index 0000000000..a6756987ec --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/breadcrumbs-panel.ts @@ -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)] +} diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts index cd75b21b9a..ab63755802 100644 --- a/services/web/frontend/js/features/source-editor/extensions/index.ts +++ b/services/web/frontend/js/features/source-editor/extensions/index.ts @@ -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): 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. diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss index 57c823fd52..e4f4b6193c 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/components/all.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/components/all.scss @@ -44,3 +44,4 @@ @import 'upgrade-prompt'; @import 'integrations-panel'; @import 'group-members'; +@import 'breadcrumbs'; diff --git a/services/web/frontend/stylesheets/bootstrap-5/components/breadcrumbs.scss b/services/web/frontend/stylesheets/bootstrap-5/components/breadcrumbs.scss new file mode 100644 index 0000000000..5df335d7de --- /dev/null +++ b/services/web/frontend/stylesheets/bootstrap-5/components/breadcrumbs.scss @@ -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); +} diff --git a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss index bc94934ec6..4eed3c633f 100644 --- a/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss +++ b/services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss @@ -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 {