Ver código fonte

React IDE page shell (#14988)

* React IDE page shell

* Set the maximum height of the symbol palette to 336px

* Tidy export

* Remove unnecessary destructuring

* Update comment

* Optimize toggle

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>

* Change snap-to-collapse threshold to 5%

* Synchronize left column width between history and editor views and remove duplication in ide-page

* Replace resizer dots with SVG

* Rermove unnecessary import and comment the remaining ones

* Use block prepend to avoid duplication

* Improve vertical content divider styling

* Implement fixed width during container resize on left column

* Change IDE page file extension

* Refactor fixed-size panel into a hook and use for chat panel

---------

Co-authored-by: Alf Eaton <alf.eaton@overleaf.com>
GitOrigin-RevId: aa881e48a2838a192b6f8f9e16e561f5cd706bd3
Tim Down 2 anos atrás
pai
commit
ea1fc5f74e
27 arquivos alterados com 819 adições e 1 exclusões
  1. 19 0
      package-lock.json
  2. 6 1
      services/web/app/src/Features/Project/ProjectController.js
  3. 1 0
      services/web/app/views/project/editor/meta.pug
  4. 21 0
      services/web/app/views/project/ide-react.pug
  5. 5 0
      services/web/frontend/extracted-translations.json
  6. 22 0
      services/web/frontend/js/features/ide-react/components/ide-root.tsx
  7. 11 0
      services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
  8. 49 0
      services/web/frontend/js/features/ide-react/components/layout/layout-with-placeholders.tsx
  9. 66 0
      services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx
  10. 7 0
      services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-chat.tsx
  11. 90 0
      services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-and-pdf.tsx
  12. 38 0
      services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-main-content.tsx
  13. 26 0
      services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-sidebar.tsx
  14. 42 0
      services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx
  15. 38 0
      services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-history.tsx
  16. 80 0
      services/web/frontend/js/features/ide-react/components/layout/two-column-main-content.tsx
  17. 19 0
      services/web/frontend/js/features/ide-react/components/resize/horizontal-resize-handle.tsx
  18. 48 0
      services/web/frontend/js/features/ide-react/components/resize/horizontal-toggler.tsx
  19. 13 0
      services/web/frontend/js/features/ide-react/components/resize/vertical-resize-handle.tsx
  20. 19 0
      services/web/frontend/js/features/ide-react/hooks/use-collapsible-panel.ts
  21. 66 0
      services/web/frontend/js/features/ide-react/hooks/use-fixed-size-column.ts
  22. 13 0
      services/web/frontend/js/pages/ide.jsx
  23. 27 0
      services/web/frontend/stories/ide-page/layout.stories.tsx
  24. 1 0
      services/web/frontend/stylesheets/app/editor.less
  25. 90 0
      services/web/frontend/stylesheets/app/editor/ide-react.less
  26. 1 0
      services/web/locales/en.json
  27. 1 0
      services/web/package.json

+ 19 - 0
package-lock.json

@@ -39625,6 +39625,16 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/react-resizable-panels": {
+      "version": "0.0.55",
+      "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-0.0.55.tgz",
+      "integrity": "sha512-J/LTFzUEjJiqwSjVh8gjUXkQDA8MRPjARASfn++d2+KOgA+9UcRYUfE3QBJixer2vkk+ffQ4cq3QzWzzHgqYpQ==",
+      "dev": true,
+      "peerDependencies": {
+        "react": "^16.14.0 || ^17.0.0 || ^18.0.0",
+        "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0"
+      }
+    },
     "node_modules/react-resize-detector": {
       "version": "7.1.2",
       "resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-7.1.2.tgz",
@@ -48067,6 +48077,7 @@
         "react-i18next": "^11.18.6",
         "react-linkify": "^1.0.0-alpha",
         "react-refresh": "^0.14.0",
+        "react-resizable-panels": "^0.0.55",
         "react2angular": "^4.0.6",
         "react2angular-shared-context": "^1.1.0",
         "requirejs": "^2.3.6",
@@ -56670,6 +56681,7 @@
         "react-i18next": "^11.18.6",
         "react-linkify": "^1.0.0-alpha",
         "react-refresh": "^0.14.0",
+        "react-resizable-panels": "^0.0.55",
         "react2angular": "^4.0.6",
         "react2angular-shared-context": "^1.1.0",
         "recurly": "^4.0.0",
@@ -81001,6 +81013,13 @@
       "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
       "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ=="
     },
+    "react-resizable-panels": {
+      "version": "0.0.55",
+      "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-0.0.55.tgz",
+      "integrity": "sha512-J/LTFzUEjJiqwSjVh8gjUXkQDA8MRPjARASfn++d2+KOgA+9UcRYUfE3QBJixer2vkk+ffQ4cq3QzWzzHgqYpQ==",
+      "dev": true,
+      "requires": {}
+    },
     "react-resize-detector": {
       "version": "7.1.2",
       "resolved": "https://registry.npmjs.org/react-resize-detector/-/react-resize-detector-7.1.2.tgz",

+ 6 - 1
services/web/app/src/Features/Project/ProjectController.js

@@ -873,9 +873,14 @@ const ProjectController = {
               !Features.hasFeature('saas') ||
               req.query?.personal_access_token === 'true'
 
+            const idePageReact = req.query?.['ide-page'] === 'react'
+
             const template =
               detachRole === 'detached'
-                ? 'project/editor_detached'
+                ? // TODO: Create React version of detached page
+                  'project/editor_detached'
+                : idePageReact
+                ? 'project/ide-react'
                 : 'project/editor'
 
             res.render(template, {

+ 1 - 0
services/web/app/views/project/editor/meta.pug

@@ -41,6 +41,7 @@ meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChang
 meta(name="ol-mathJax3Path" content=mathJax3Path)
 meta(name="ol-completedTutorials", data-type="json" content=user.completedTutorials)
 meta(name="ol-projectTags" data-type="json" content=projectTags)
+meta(name="ol-idePageReact", data-type="boolean" content=idePageReact)
 
 - var fileActionI18n = ['edited', 'renamed', 'created', 'deleted'].reduce((acc, i) => {acc[i] = translate('file_action_' + i); return acc}, {})
 meta(name="ol-fileActionI18n" data-type="json" content=fileActionI18n)

+ 21 - 0
services/web/app/views/project/ide-react.pug

@@ -0,0 +1,21 @@
+extends ../layout
+
+block vars
+	- var suppressNavbar = true
+	- var suppressFooter = true
+	- var suppressSkipToContent = true
+	- metadata.robotsNoindexNofollow = true
+
+block entrypointVar
+	- entrypoint = 'pages/ide'
+
+block content
+	main#ide-root
+
+block append meta
+	include ./editor/meta
+
+block prepend foot-scripts
+	each file in (useOpenTelemetry ? entrypointScripts("tracing") : [])
+		script(type="text/javascript", nonce=scriptNonce, src=file)
+	script(type="text/javascript", nonce=scriptNonce, src=(wsUrl || '/socket.io') + '/socket.io.js')

+ 5 - 0
services/web/frontend/extracted-translations.json

@@ -926,6 +926,7 @@
   "resend_group_invite": "",
   "resend_managed_user_invite": "",
   "resending_confirmation_email": "",
+  "resize": "",
   "resolve": "",
   "resolved_comments": "",
   "restore_file": "",
@@ -1178,6 +1179,10 @@
   "toolbar_table_insert_table_lowercase": "",
   "toolbar_toggle_symbol_palette": "",
   "toolbar_undo": "",
+  "tooltip_hide_filetree": "",
+  "tooltip_hide_pdf": "",
+  "tooltip_show_filetree": "",
+  "tooltip_show_pdf": "",
   "total_per_month": "",
   "total_per_year": "",
   "total_with_subtotal_and_tax": "",

+ 22 - 0
services/web/frontend/js/features/ide-react/components/ide-root.tsx

@@ -0,0 +1,22 @@
+import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
+import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
+import withErrorBoundary from '@/infrastructure/error-boundary'
+import IdePage from '@/features/ide-react/components/layout/ide-page'
+
+function IdeRoot() {
+  // Check that we haven't inadvertently loaded Angular
+  // TODO: Remove this before rolling out this component to any users
+  if (typeof window.angular !== 'undefined') {
+    throw new Error('Angular detected. This page must not load Angular.')
+  }
+
+  const { isReady } = useWaitForI18n()
+
+  if (!isReady) {
+    return null
+  }
+
+  return <IdePage />
+}
+
+export default withErrorBoundary(IdeRoot, GenericErrorBoundaryFallback)

+ 11 - 0
services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx

@@ -0,0 +1,11 @@
+import LayoutWithPlaceholders from '@/features/ide-react/components/layout/layout-with-placeholders'
+
+// This is filled with placeholder content while the real content is migrated
+// away from Angular
+export default function IdePage() {
+  return (
+    <div id="ide-react-page">
+      <LayoutWithPlaceholders shouldPersistLayout />
+    </div>
+  )
+}

+ 49 - 0
services/web/frontend/js/features/ide-react/components/layout/layout-with-placeholders.tsx

@@ -0,0 +1,49 @@
+import { useState } from 'react'
+import PlaceholderHeader from '@/features/ide-react/components/layout/placeholder/placeholder-header'
+import PlaceholderChat from '@/features/ide-react/components/layout/placeholder/placeholder-chat'
+import PlaceholderHistory from '@/features/ide-react/components/layout/placeholder/placeholder-history'
+import PlaceholderEditorMainContent from '@/features/ide-react/components/layout/placeholder/placeholder-editor-main-content'
+import MainLayout from '@/features/ide-react/components/layout/main-layout'
+
+export default function LayoutWithPlaceholders({
+  shouldPersistLayout,
+}: {
+  shouldPersistLayout: boolean
+}) {
+  const [chatIsOpen, setChatIsOpen] = useState(false)
+  const [historyIsOpen, setHistoryIsOpen] = useState(false)
+  const [leftColumnDefaultSize, setLeftColumnDefaultSize] = useState(20)
+
+  const headerContent = (
+    <PlaceholderHeader
+      chatIsOpen={chatIsOpen}
+      setChatIsOpen={setChatIsOpen}
+      historyIsOpen={historyIsOpen}
+      setHistoryIsOpen={setHistoryIsOpen}
+    />
+  )
+  const chatContent = <PlaceholderChat />
+  const mainContent = historyIsOpen ? (
+    <PlaceholderHistory
+      shouldPersistLayout
+      leftColumnDefaultSize={leftColumnDefaultSize}
+      setLeftColumnDefaultSize={setLeftColumnDefaultSize}
+    />
+  ) : (
+    <PlaceholderEditorMainContent
+      shouldPersistLayout
+      leftColumnDefaultSize={leftColumnDefaultSize}
+      setLeftColumnDefaultSize={setLeftColumnDefaultSize}
+    />
+  )
+
+  return (
+    <MainLayout
+      headerContent={headerContent}
+      chatContent={chatContent}
+      mainContent={mainContent}
+      chatIsOpen={chatIsOpen}
+      shouldPersistLayout={shouldPersistLayout}
+    />
+  )
+}

+ 66 - 0
services/web/frontend/js/features/ide-react/components/layout/main-layout.tsx

@@ -0,0 +1,66 @@
+import { Panel, PanelGroup } from 'react-resizable-panels'
+import { ReactNode } from 'react'
+import { HorizontalResizeHandle } from '../resize/horizontal-resize-handle'
+import useFixedSizeColumn from '@/features/ide-react/hooks/use-fixed-size-column'
+import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
+
+const CHAT_DEFAULT_SIZE = 20
+
+type PageProps = {
+  headerContent: ReactNode
+  chatContent: ReactNode
+  mainContent: ReactNode
+  chatIsOpen: boolean
+  shouldPersistLayout: boolean
+}
+
+// The main area below the header is split into two: the main content and chat.
+// The reason for not splitting the left column containing the file tree and
+// outline here is that the history view has its own file tree, so it is more
+// convenient to replace the whole of the main content when in history view.
+export default function MainLayout({
+  headerContent,
+  chatContent,
+  mainContent,
+  chatIsOpen,
+  shouldPersistLayout,
+}: PageProps) {
+  const { fixedPanelRef: chatPanelRef, handleLayout } = useFixedSizeColumn(
+    CHAT_DEFAULT_SIZE,
+    chatIsOpen
+  )
+
+  useCollapsiblePanel(chatIsOpen, chatPanelRef)
+
+  return (
+    <div className="ide-react-main">
+      {headerContent}
+      <div className="ide-react-body">
+        <PanelGroup
+          autoSaveId={shouldPersistLayout ? 'ide-react-chat-layout' : undefined}
+          direction="horizontal"
+          onLayout={handleLayout}
+        >
+          <Panel id="main" order={1}>
+            {mainContent}
+          </Panel>
+          {chatIsOpen ? (
+            <>
+              <HorizontalResizeHandle />
+              <Panel
+                ref={chatPanelRef}
+                id="chat"
+                order={2}
+                defaultSize={CHAT_DEFAULT_SIZE}
+                minSize={5}
+                collapsible
+              >
+                {chatContent}
+              </Panel>
+            </>
+          ) : null}
+        </PanelGroup>
+      </div>
+    </div>
+  )
+}

+ 7 - 0
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-chat.tsx

@@ -0,0 +1,7 @@
+import React from 'react'
+
+export default function PlaceholderChat() {
+  return (
+    <aside className="chat ide-react-placeholder-chat">Chat placeholder</aside>
+  )
+}

+ 90 - 0
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-and-pdf.tsx

@@ -0,0 +1,90 @@
+import React, { useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+  ImperativePanelHandle,
+  Panel,
+  PanelGroup,
+} from 'react-resizable-panels'
+import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
+import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
+import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
+import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
+
+type PlaceholderEditorAndPdfProps = {
+  shouldPersistLayout?: boolean
+}
+
+export default function PlaceholderEditorAndPdf({
+  shouldPersistLayout = false,
+}: PlaceholderEditorAndPdfProps) {
+  const { t } = useTranslation()
+  const [pdfIsOpen, setPdfIsOpen] = useState(false)
+  const [symbolPaletteIsOpen, setSymbolPaletteIsOpen] = useState(false)
+
+  const pdfPanelRef = useRef<ImperativePanelHandle>(null)
+  useCollapsiblePanel(pdfIsOpen, pdfPanelRef)
+
+  return (
+    <PanelGroup
+      autoSaveId={
+        shouldPersistLayout ? 'ide-react-editor-and-pdf-layout' : undefined
+      }
+      direction="horizontal"
+    >
+      <Panel defaultSize={50}>
+        <PanelGroup
+          autoSaveId={
+            shouldPersistLayout
+              ? 'ide-react-editor-and-symbol-palette-layout'
+              : undefined
+          }
+          direction="vertical"
+          units="pixels"
+        >
+          <Panel id="editor" order={1}>
+            Editor placeholder
+            <br />
+            <button onClick={() => setSymbolPaletteIsOpen(value => !value)}>
+              Toggle symbol palette
+            </button>
+          </Panel>
+          {symbolPaletteIsOpen ? (
+            <>
+              <VerticalResizeHandle id="editor-symbol-palette" />
+              <Panel
+                id="symbol-palette"
+                order={2}
+                defaultSize={250}
+                minSize={250}
+                maxSize={336}
+              >
+                <div className="ide-react-placeholder-symbol-palette ">
+                  Symbol palette placeholder
+                </div>
+              </Panel>
+            </>
+          ) : null}
+        </PanelGroup>
+      </Panel>
+      <HorizontalResizeHandle>
+        <HorizontalToggler
+          id="editor-pdf"
+          togglerType="east"
+          isOpen={pdfIsOpen}
+          setIsOpen={pdfIsOpen => setPdfIsOpen(pdfIsOpen)}
+          tooltipWhenOpen={t('tooltip_hide_pdf')}
+          tooltipWhenClosed={t('tooltip_show_pdf')}
+        />
+      </HorizontalResizeHandle>
+      <Panel
+        ref={pdfPanelRef}
+        defaultSize={50}
+        minSize={5}
+        collapsible
+        onCollapse={collapsed => setPdfIsOpen(!collapsed)}
+      >
+        PDF placeholder
+      </Panel>
+    </PanelGroup>
+  )
+}

+ 38 - 0
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-main-content.tsx

@@ -0,0 +1,38 @@
+import React, { useState } from 'react'
+import TwoColumnMainContent from '@/features/ide-react/components/layout/two-column-main-content'
+import PlaceholderEditorAndPdf from '@/features/ide-react/components/layout/placeholder/placeholder-editor-and-pdf'
+import PlaceholderEditorSidebar from '@/features/ide-react/components/layout/placeholder/placeholder-editor-sidebar'
+
+type PlaceholderEditorMainContentProps = {
+  shouldPersistLayout: boolean
+  leftColumnDefaultSize: number
+  setLeftColumnDefaultSize: React.Dispatch<React.SetStateAction<number>>
+}
+
+export default function PlaceholderEditorMainContent({
+  shouldPersistLayout,
+  leftColumnDefaultSize,
+  setLeftColumnDefaultSize,
+}: PlaceholderEditorMainContentProps) {
+  const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true)
+
+  const leftColumnContent = (
+    <PlaceholderEditorSidebar shouldPersistLayout={shouldPersistLayout} />
+  )
+  const rightColumnContent = (
+    <PlaceholderEditorAndPdf shouldPersistLayout={shouldPersistLayout} />
+  )
+
+  return (
+    <TwoColumnMainContent
+      leftColumnId="editor-left-column"
+      leftColumnContent={leftColumnContent}
+      leftColumnDefaultSize={leftColumnDefaultSize}
+      setLeftColumnDefaultSize={setLeftColumnDefaultSize}
+      rightColumnContent={rightColumnContent}
+      leftColumnIsOpen={leftColumnIsOpen}
+      setLeftColumnIsOpen={setLeftColumnIsOpen}
+      shouldPersistLayout={shouldPersistLayout}
+    />
+  )
+}

+ 26 - 0
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-editor-sidebar.tsx

@@ -0,0 +1,26 @@
+import React from 'react'
+import { Panel, PanelGroup } from 'react-resizable-panels'
+import { VerticalResizeHandle } from '@/features/ide-react/components/resize/vertical-resize-handle'
+
+type PlaceholderHeaderProps = {
+  shouldPersistLayout: boolean
+}
+
+export default function PlaceholderEditorSidebar({
+  shouldPersistLayout,
+}: PlaceholderHeaderProps) {
+  return (
+    <aside className="ide-react-placeholder-editor-sidebar">
+      <PanelGroup
+        autoSaveId={
+          shouldPersistLayout ? 'ide-react-editor-sidebar-layout' : undefined
+        }
+        direction="vertical"
+      >
+        <Panel defaultSize={75}>File tree placeholder</Panel>
+        <VerticalResizeHandle />
+        <Panel defaultSize={25}>File outline placeholder</Panel>
+      </PanelGroup>
+    </aside>
+  )
+}

+ 42 - 0
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-header.tsx

@@ -0,0 +1,42 @@
+import React from 'react'
+import ChatToggleButton from '@/features/editor-navigation-toolbar/components/chat-toggle-button'
+import HistoryToggleButton from '@/features/editor-navigation-toolbar/components/history-toggle-button'
+
+type PlaceholderHeaderProps = {
+  chatIsOpen: boolean
+  setChatIsOpen: (chatIsOpen: boolean) => void
+  historyIsOpen: boolean
+  setHistoryIsOpen: (chatIsOpen: boolean) => void
+}
+
+export default function PlaceholderHeader({
+  chatIsOpen,
+  setChatIsOpen,
+  historyIsOpen,
+  setHistoryIsOpen,
+}: PlaceholderHeaderProps) {
+  function toggleChatOpen() {
+    setChatIsOpen(!chatIsOpen)
+  }
+
+  function toggleHistoryOpen() {
+    setHistoryIsOpen(!historyIsOpen)
+  }
+
+  return (
+    <header className="toolbar toolbar-header">
+      <div className="toolbar-left">Header placeholder</div>
+      <div className="toolbar-right">
+        <HistoryToggleButton
+          historyIsOpen={historyIsOpen}
+          onClick={toggleHistoryOpen}
+        />
+        <ChatToggleButton
+          chatIsOpen={chatIsOpen}
+          onClick={toggleChatOpen}
+          unreadMessageCount={0}
+        />
+      </div>
+    </header>
+  )
+}

+ 38 - 0
services/web/frontend/js/features/ide-react/components/layout/placeholder/placeholder-history.tsx

@@ -0,0 +1,38 @@
+import React, { useState } from 'react'
+import TwoColumnMainContent from '@/features/ide-react/components/layout/two-column-main-content'
+
+type PlaceholderHistoryProps = {
+  shouldPersistLayout: boolean
+  leftColumnDefaultSize: number
+  setLeftColumnDefaultSize: React.Dispatch<React.SetStateAction<number>>
+}
+
+export default function PlaceholderHistory({
+  shouldPersistLayout,
+  leftColumnDefaultSize,
+  setLeftColumnDefaultSize,
+}: PlaceholderHistoryProps) {
+  const [leftColumnIsOpen, setLeftColumnIsOpen] = useState(true)
+
+  const leftColumnContent = (
+    <aside className="ide-react-placeholder-editor-sidebar">
+      History file tree placeholder
+    </aside>
+  )
+  const rightColumnContent = (
+    <div>History document diff viewer and versions list placeholder</div>
+  )
+
+  return (
+    <TwoColumnMainContent
+      leftColumnId="editor-left-column"
+      leftColumnContent={leftColumnContent}
+      leftColumnDefaultSize={leftColumnDefaultSize}
+      setLeftColumnDefaultSize={setLeftColumnDefaultSize}
+      rightColumnContent={rightColumnContent}
+      leftColumnIsOpen={leftColumnIsOpen}
+      setLeftColumnIsOpen={setLeftColumnIsOpen}
+      shouldPersistLayout={shouldPersistLayout}
+    />
+  )
+}

+ 80 - 0
services/web/frontend/js/features/ide-react/components/layout/two-column-main-content.tsx

@@ -0,0 +1,80 @@
+import React, { ReactNode, useEffect } from 'react'
+import { Panel, PanelGroup } from 'react-resizable-panels'
+import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
+import { useTranslation } from 'react-i18next'
+import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
+import useFixedSizeColumn from '@/features/ide-react/hooks/use-fixed-size-column'
+import useCollapsiblePanel from '@/features/ide-react/hooks/use-collapsible-panel'
+
+type TwoColumnMainContentProps = {
+  leftColumnId: string
+  leftColumnContent: ReactNode
+  leftColumnDefaultSize: number
+  setLeftColumnDefaultSize: React.Dispatch<React.SetStateAction<number>>
+  rightColumnContent: ReactNode
+  leftColumnIsOpen: boolean
+  setLeftColumnIsOpen: (
+    leftColumnIsOpen: TwoColumnMainContentProps['leftColumnIsOpen']
+  ) => void
+  shouldPersistLayout?: boolean
+}
+
+export default function TwoColumnMainContent({
+  leftColumnId,
+  leftColumnContent,
+  leftColumnDefaultSize,
+  setLeftColumnDefaultSize,
+  rightColumnContent,
+  leftColumnIsOpen,
+  setLeftColumnIsOpen,
+  shouldPersistLayout = false,
+}: TwoColumnMainContentProps) {
+  const { t } = useTranslation()
+
+  const {
+    fixedPanelRef: leftColumnPanelRef,
+    fixedPanelWidthRef: leftColumnWidthRef,
+    handleLayout,
+  } = useFixedSizeColumn(leftColumnDefaultSize, leftColumnIsOpen)
+
+  useCollapsiblePanel(leftColumnIsOpen, leftColumnPanelRef)
+
+  // Update the left column default size on unmount rather than doing it on
+  // every resize, which causes ResizeObserver errors
+  useEffect(() => {
+    if (leftColumnWidthRef.current) {
+      setLeftColumnDefaultSize(leftColumnWidthRef.current.size)
+    }
+  }, [leftColumnWidthRef, setLeftColumnDefaultSize])
+
+  return (
+    <PanelGroup
+      autoSaveId={
+        shouldPersistLayout ? 'ide-react-main-content-layout' : undefined
+      }
+      direction="horizontal"
+      onLayout={handleLayout}
+    >
+      <Panel
+        ref={leftColumnPanelRef}
+        defaultSize={leftColumnDefaultSize}
+        minSize={5}
+        collapsible
+        onCollapse={collapsed => setLeftColumnIsOpen(!collapsed)}
+      >
+        {leftColumnIsOpen ? leftColumnContent : null}
+      </Panel>
+      <HorizontalResizeHandle>
+        <HorizontalToggler
+          id={leftColumnId}
+          togglerType="west"
+          isOpen={leftColumnIsOpen}
+          setIsOpen={isOpen => setLeftColumnIsOpen(isOpen)}
+          tooltipWhenOpen={t('tooltip_hide_filetree')}
+          tooltipWhenClosed={t('tooltip_show_filetree')}
+        />
+      </HorizontalResizeHandle>
+      <Panel>{rightColumnContent}</Panel>
+    </PanelGroup>
+  )
+}

+ 19 - 0
services/web/frontend/js/features/ide-react/components/resize/horizontal-resize-handle.tsx

@@ -0,0 +1,19 @@
+import { PanelResizeHandle } from 'react-resizable-panels'
+import { FC } from 'react'
+import { useTranslation } from 'react-i18next'
+import { PanelResizeHandleProps } from 'react-resizable-panels/dist/declarations/src/PanelResizeHandle'
+
+export const HorizontalResizeHandle: FC<PanelResizeHandleProps> = ({
+  children,
+  ...props
+}) => {
+  const { t } = useTranslation()
+
+  return (
+    <PanelResizeHandle {...props}>
+      <div className="horizontal-resize-handle" title={t('resize')}>
+        {children}
+      </div>
+    </PanelResizeHandle>
+  )
+}

+ 48 - 0
services/web/frontend/js/features/ide-react/components/resize/horizontal-toggler.tsx

@@ -0,0 +1,48 @@
+import classNames from 'classnames'
+import Tooltip from '@/shared/components/tooltip'
+
+type HorizontalTogglerType = 'west' | 'east'
+
+type HorizontalTogglerProps = {
+  id: string
+  isOpen: boolean
+  setIsOpen: (isClosed: boolean) => void
+  togglerType: HorizontalTogglerType
+  tooltipWhenOpen: string
+  tooltipWhenClosed: string
+}
+
+export function HorizontalToggler({
+  id,
+  isOpen,
+  setIsOpen,
+  togglerType,
+  tooltipWhenOpen,
+  tooltipWhenClosed,
+}: HorizontalTogglerProps) {
+  const description = isOpen ? tooltipWhenOpen : tooltipWhenClosed
+
+  return (
+    <Tooltip
+      id={id}
+      description={description}
+      overlayProps={{
+        placement: togglerType === 'east' ? 'left' : 'right',
+      }}
+    >
+      <button
+        className={classNames(
+          'custom-toggler',
+          `custom-toggler-${togglerType}`,
+          {
+            'custom-toggler-open': isOpen,
+            'custom-toggler-closed': !isOpen,
+          }
+        )}
+        aria-label={description}
+        title=""
+        onClick={() => setIsOpen(!isOpen)}
+      />
+    </Tooltip>
+  )
+}

+ 13 - 0
services/web/frontend/js/features/ide-react/components/resize/vertical-resize-handle.tsx

@@ -0,0 +1,13 @@
+import { PanelResizeHandle } from 'react-resizable-panels'
+import { useTranslation } from 'react-i18next'
+import { PanelResizeHandleProps } from 'react-resizable-panels/dist/declarations/src/PanelResizeHandle'
+
+export function VerticalResizeHandle(props: PanelResizeHandleProps) {
+  const { t } = useTranslation()
+
+  return (
+    <PanelResizeHandle {...props}>
+      <div className="vertical-resize-handle" title={t('resize')} />
+    </PanelResizeHandle>
+  )
+}

+ 19 - 0
services/web/frontend/js/features/ide-react/hooks/use-collapsible-panel.ts

@@ -0,0 +1,19 @@
+import { RefObject, useEffect } from 'react'
+import { ImperativePanelHandle } from 'react-resizable-panels'
+
+export default function useCollapsiblePanel(
+  panelIsOpen: boolean,
+  panelRef: RefObject<ImperativePanelHandle>
+) {
+  useEffect(() => {
+    const panel = panelRef.current
+    if (!panel) {
+      return
+    }
+    if (panelIsOpen) {
+      panel.expand()
+    } else {
+      panel.collapse()
+    }
+  }, [panelIsOpen, panelRef])
+}

+ 66 - 0
services/web/frontend/js/features/ide-react/hooks/use-fixed-size-column.ts

@@ -0,0 +1,66 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { ImperativePanelHandle } from 'react-resizable-panels'
+import { PanelGroupOnLayout } from 'react-resizable-panels/src/types'
+
+export default function useFixedSizeColumn(
+  defaultSize: number,
+  isOpen: boolean
+) {
+  const fixedPanelRef = useRef<ImperativePanelHandle>(null)
+
+  const fixedPanelWidthRef = useRef({ size: defaultSize, pixels: 0 })
+  const [initialLayoutDone, setInitialLayoutDone] = useState(false)
+
+  const measureFixedPanelSizePixels = useCallback(() => {
+    return fixedPanelRef.current?.getSize('pixels') || 0
+  }, [fixedPanelRef])
+
+  const handleLayout = useCallback(
+    sizes => {
+      // Measure the pixel width here because it's not always up to date in the
+      // panel's onResize
+      fixedPanelWidthRef.current = {
+        size: sizes[0],
+        pixels: measureFixedPanelSizePixels(),
+      }
+      setInitialLayoutDone(true)
+    },
+    [measureFixedPanelSizePixels]
+  ) as PanelGroupOnLayout
+
+  useEffect(() => {
+    if (!isOpen) {
+      return
+    }
+
+    // Only start watching for resizes once the initial layout is done,
+    // otherwise we could measure the fixed column while it has zero width and
+    // collapse it
+    if (!initialLayoutDone || !fixedPanelRef.current) {
+      return
+    }
+
+    const fixedPanelElement = document.querySelector(
+      `[data-panel-id="${fixedPanelRef.current.getId()}"]`
+    )
+
+    const panelGroupElement = fixedPanelElement?.closest('[data-panel-group]')
+    if (!panelGroupElement || !fixedPanelElement) {
+      return
+    }
+
+    const resizeObserver = new ResizeObserver(() => {
+      fixedPanelRef.current?.resize(fixedPanelWidthRef.current.pixels, 'pixels')
+    })
+
+    resizeObserver.observe(panelGroupElement)
+
+    return () => resizeObserver.unobserve(panelGroupElement)
+  }, [fixedPanelRef, measureFixedPanelSizePixels, initialLayoutDone, isOpen])
+
+  return {
+    fixedPanelRef,
+    fixedPanelWidthRef,
+    handleLayout,
+  }
+}

+ 13 - 0
services/web/frontend/js/pages/ide.jsx

@@ -0,0 +1,13 @@
+// Configure dynamically loaded assets (via webpack) to be downloaded from CDN
+import '../utils/webpack-public-path'
+
+// Set up error reporting, including Sentry
+import '../infrastructure/error-reporter'
+
+import ReactDOM from 'react-dom'
+import IdeRoot from '../features/ide-react/components/ide-root'
+
+const element = document.getElementById('ide-root')
+if (element) {
+  ReactDOM.render(<IdeRoot />, element)
+}

+ 27 - 0
services/web/frontend/stories/ide-page/layout.stories.tsx

@@ -0,0 +1,27 @@
+import LayoutWithPlaceholders from '@/features/ide-react/components/layout/layout-with-placeholders'
+
+export default {
+  title: 'Editor / Page Layout',
+  component: LayoutWithPlaceholders,
+  decorators: [
+    (Story: any) => (
+      <div
+        style={{ position: 'absolute', inset: '1em', border: 'solid #ccc 1px' }}
+      >
+        <Story />
+      </div>
+    ),
+  ],
+}
+
+export const Persisted = {
+  args: {
+    shouldPersistLayout: true,
+  },
+}
+
+export const Unpersisted = {
+  args: {
+    shouldPersistLayout: false,
+  },
+}

+ 1 - 0
services/web/frontend/stylesheets/app/editor.less

@@ -19,6 +19,7 @@
 @import './editor/dictionary.less';
 @import './editor/compile-button.less';
 @import './editor/figure-modal.less';
+@import './editor/ide-react.less';
 
 @ui-layout-toggler-def-height: 50px;
 @ui-resizer-size: 7px;

+ 90 - 0
services/web/frontend/stylesheets/app/editor/ide-react.less

@@ -0,0 +1,90 @@
+#ide-react-page {
+  height: 100vh;
+}
+
+.ide-react-main {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+
+  .toolbar.toolbar-header {
+    position: static;
+    flex-grow: 0;
+    color: var(--neutral-20);
+  }
+}
+
+.ide-react-body {
+  flex-grow: 1;
+}
+
+.horizontal-resize-handle {
+  width: @ui-resizer-size !important;
+  height: 100%;
+
+  // Enable ::before and ::after pseudo-elements to position themselves correctly
+  position: relative;
+
+  background-color: @editor-resizer-bg-color;
+
+  .custom-toggler {
+    padding: 0;
+    border-width: 0;
+  }
+
+  &::before,
+  &::after {
+    // This SVG has the colour hard-coded to the current value of @ol-blue-gray-2, so if we changed @ol-blue-gray-2,
+    // we'd have to change this SVG too
+    content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='7' height='18' viewBox='0 0 7 18'%3E%3Cpath d='M2 0h3v3H2zM2 5h3v3H2zM2 10h3v3H2zM2 15h3v3H2z' style='fill:%239da7b7'/%3E%3C/svg%3E");
+    display: block;
+    position: absolute;
+    text-align: center;
+    left: 0;
+    width: 7px;
+    height: 18px;
+  }
+  &::before {
+    top: 25%;
+  }
+  &::after {
+    top: 75%;
+  }
+}
+
+.vertical-resize-handle {
+  height: 6px;
+  background-color: @vertical-resizable-resizer-bg;
+
+  &:hover {
+    background-color: @vertical-resizable-resizer-hover-bg;
+  }
+
+  &::after {
+    // This SVG has the colour hard-coded to the current value of @ol-blue-gray-2, so if we changed @ol-blue-gray-2,
+    // we'd have to change this SVG too
+    content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='6' viewBox='0 0 18 6'%3E%3Cpath d='M0 1.5h3v3H0zM5 1.5h3v3H5zM10 1.5h3v3h-3zM15 1.5h3v3h-3z' style='fill:%239da7b7'/%3E%3C/svg%3E");
+    display: block;
+    text-align: center;
+    line-height: 0;
+  }
+}
+
+// Styles for placeholder elements that will eventually be replaced
+.ide-react-placeholder-chat {
+  background-color: var(--editor-toolbar-bg);
+  color: var(--neutral-20);
+  height: 100%;
+}
+
+.ide-react-placeholder-editor-sidebar {
+  height: 100%;
+  background-color: @file-tree-bg;
+  color: var(--neutral-20);
+}
+
+.ide-react-placeholder-symbol-palette {
+  height: 100%;
+  background-color: @symbol-palette-bg;
+  color: var(--neutral-20);
+}

+ 1 - 0
services/web/locales/en.json

@@ -1467,6 +1467,7 @@
   "resending_confirmation_email": "Resending confirmation email",
   "reset_password": "Reset Password",
   "reset_your_password": "Reset your password",
+  "resize": "Resize",
   "resolve": "Resolve",
   "resolved_comments": "Resolved comments",
   "restore": "Restore",

+ 1 - 0
services/web/package.json

@@ -330,6 +330,7 @@
     "react-refresh": "^0.14.0",
     "react2angular": "^4.0.6",
     "react2angular-shared-context": "^1.1.0",
+    "react-resizable-panels": "^0.0.55",
     "requirejs": "^2.3.6",
     "samlp": "^7.0.2",
     "sandboxed-module": "overleaf/node-sandboxed-module#cafa2d60f17ce75cc023e6f296eb8de79d92d35d",