ソースを参照

Merge pull request #23747 from overleaf/dp-breadcrumbs

Add file breadcrumbs to new editor

GitOrigin-RevId: 54bde446ad632976503a2c4aff915c862bad710e
David 1 年間 前
コミット
d7ad742ba3

+ 104 - 0
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(<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" />
+)

+ 4 - 64
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 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 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(() => {
 export const OutlineContainer: FC = memo(() => {
   const {
   const {
-    flatOutline,
     highlightedLine,
     highlightedLine,
     canShowOutline,
     canShowOutline,
     jumpToLine,
     jumpToLine,
@@ -24,31 +15,7 @@ export const OutlineContainer: FC = memo(() => {
 
 
   const outlineToggledEmitter = useScopeEventEmitter('outline-toggled')
   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 (
   return (
     <div className="outline-container">
     <div className="outline-container">
@@ -66,30 +33,3 @@ export const OutlineContainer: FC = memo(() => {
   )
   )
 })
 })
 OutlineContainer.displayName = 'OutlineContainer'
 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 - 8
services/web/frontend/js/features/outline/components/outline-list.tsx

@@ -2,14 +2,7 @@ import classNames from 'classnames'
 import OutlineItem from './outline-item'
 import OutlineItem from './outline-item'
 import { memo } from 'react'
 import { memo } from 'react'
 import { OutlineItemData } from '@/features/ide-react/types/outline'
 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({
 const OutlineList = memo(function OutlineList({
   outline,
   outline,

+ 70 - 0
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<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
+}

+ 11 - 0
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()
+}

+ 3 - 0
services/web/frontend/js/features/source-editor/components/codemirror-editor.tsx

@@ -18,6 +18,7 @@ import {
   CodeMirrorViewContext,
   CodeMirrorViewContext,
 } from './codemirror-context'
 } from './codemirror-context'
 import MathPreviewTooltip from './math-preview-tooltip'
 import MathPreviewTooltip from './math-preview-tooltip'
+import Breadcrumbs from '@/features/ide-redesign/components/breadcrumbs'
 
 
 // TODO: remove this when definitely no longer used
 // TODO: remove this when definitely no longer used
 export * from './codemirror-context'
 export * from './codemirror-context'
@@ -39,6 +40,7 @@ function CodeMirrorEditor() {
   const isMounted = useIsMounted()
   const isMounted = useIsMounted()
 
 
   const newReviewPanel = useFeatureFlag('review-panel-redesign')
   const newReviewPanel = useFeatureFlag('review-panel-redesign')
+  const newEditor = useFeatureFlag('editor-redesign')
 
 
   // create the view using the initial state and intercept transactions
   // create the view using the initial state and intercept transactions
   const viewRef = useRef<EditorView | null>(null)
   const viewRef = useRef<EditorView | null>(null)
@@ -72,6 +74,7 @@ function CodeMirrorEditor() {
               <Component key={path} />
               <Component key={path} />
             )
             )
           )}
           )}
+          {newEditor && <Breadcrumbs />}
           <CodeMirrorCommandTooltip />
           <CodeMirrorCommandTooltip />
 
 
           <MathPreviewTooltip />
           <MathPreviewTooltip />

+ 19 - 0
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)]
+}

+ 2 - 0
services/web/frontend/js/features/source-editor/extensions/index.ts

@@ -45,6 +45,7 @@ import { shortcuts } from './shortcuts'
 import { effectListeners } from './effect-listeners'
 import { effectListeners } from './effect-listeners'
 import { highlightSpecialChars } from './highlight-special-chars'
 import { highlightSpecialChars } from './highlight-special-chars'
 import { toolbarPanel } from './toolbar/toolbar-panel'
 import { toolbarPanel } from './toolbar/toolbar-panel'
+import { breadcrumbPanel } from './breadcrumbs-panel'
 import { geometryChangeEvent } from './geometry-change-event'
 import { geometryChangeEvent } from './geometry-change-event'
 import { docName } from './doc-name'
 import { docName } from './doc-name'
 import { fileTreeItemDrop } from './file-tree-item-drop'
 import { fileTreeItemDrop } from './file-tree-item-drop'
@@ -151,6 +152,7 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
   mathPreview(options.settings.mathPreview),
   mathPreview(options.settings.mathPreview),
   reviewTooltip(),
   reviewTooltip(),
   toolbarPanel(),
   toolbarPanel(),
+  breadcrumbPanel(),
   verticalOverflow(),
   verticalOverflow(),
   highlightActiveLine(options.visual.visual),
   highlightActiveLine(options.visual.visual),
   // The built-in extension that highlights the active line in the gutter.
   // The built-in extension that highlights the active line in the gutter.

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/components/all.scss

@@ -44,3 +44,4 @@
 @import 'upgrade-prompt';
 @import 'upgrade-prompt';
 @import 'integrations-panel';
 @import 'integrations-panel';
 @import 'group-members';
 @import 'group-members';
+@import 'breadcrumbs';

+ 25 - 0
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);
+}

+ 8 - 0
services/web/frontend/stylesheets/bootstrap-5/pages/editor/toolbar.scss

@@ -65,6 +65,14 @@
     --editor-toolbar-bg: var(--white);
     --editor-toolbar-bg: var(--white);
     --toolbar-filetree-bg-color: var(--white);
     --toolbar-filetree-bg-color: var(--white);
   }
   }
+
+  .cm-panels-top {
+    border-bottom: none;
+
+    .ol-cm-toolbar-portal {
+      border-bottom: 1px solid #ddd;
+    }
+  }
 }
 }
 
 
 .toolbar {
 .toolbar {