mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
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
This commit is contained in:
parent
afcac7af86
commit
ea1fc5f74e
27 changed files with 819 additions and 1 deletions
19
package-lock.json
generated
19
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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
services/web/app/views/project/ide-react.pug
Normal file
21
services/web/app/views/project/ide-react.pug
Normal file
|
@ -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')
|
|
@ -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": "",
|
||||
|
|
|
@ -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)
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import React from 'react'
|
||||
|
||||
export default function PlaceholderChat() {
|
||||
return (
|
||||
<aside className="chat ide-react-placeholder-chat">Chat placeholder</aside>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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
services/web/frontend/js/pages/ide.jsx
Normal file
13
services/web/frontend/js/pages/ide.jsx
Normal file
|
@ -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
services/web/frontend/stories/ide-page/layout.stories.tsx
Normal file
27
services/web/frontend/stories/ide-page/layout.stories.tsx
Normal file
|
@ -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,
|
||||
},
|
||||
}
|
|
@ -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
services/web/frontend/stylesheets/app/editor/ide-react.less
Normal file
90
services/web/frontend/stylesheets/app/editor/ide-react.less
Normal file
|
@ -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);
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue