Browse Source

Merge pull request #5797 from overleaf/ta-pdf-detach

PDF Detach

GitOrigin-RevId: f69d8a87d1ba2115ad496a719106dfc7707a6ed5
Timothée Alby 4 years ago
parent
commit
8ca159b4b9
32 changed files with 762 additions and 147 deletions
  1. 14 0
      services/web/app/src/Features/Project/ProjectController.js
  2. 1 1
      services/web/app/src/router.js
  3. 11 47
      services/web/app/views/project/editor.pug
  4. 47 0
      services/web/app/views/project/editor/main.pug
  5. 5 0
      services/web/frontend/extracted-translations.json
  6. 13 12
      services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.js
  7. 8 0
      services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js
  8. 6 5
      services/web/frontend/js/features/editor-navigation-toolbar/components/history-toggle-button.js
  9. 102 49
      services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js
  10. 6 5
      services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.js
  11. 13 1
      services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js
  12. 6 11
      services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.js
  13. 25 0
      services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js
  14. 31 2
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js
  15. 15 0
      services/web/frontend/js/ide.js
  16. 118 0
      services/web/frontend/js/shared/context/detach-context.js
  17. 45 1
      services/web/frontend/js/shared/context/layout-context.js
  18. 8 5
      services/web/frontend/js/shared/context/root-context.js
  19. 33 0
      services/web/frontend/js/shared/hooks/use-callback-handlers.js
  20. 130 0
      services/web/frontend/js/shared/hooks/use-detach-layout.js
  21. 9 0
      services/web/frontend/js/shared/hooks/use-previous-value.js
  22. 11 0
      services/web/frontend/js/shared/utils/url-helper.js
  23. 10 1
      services/web/frontend/stylesheets/app/editor/pdf.less
  24. 1 1
      services/web/frontend/stylesheets/app/editor/review-panel.less
  25. 35 1
      services/web/frontend/stylesheets/app/editor/toolbar.less
  26. 6 1
      services/web/locales/en.json
  27. 5 0
      services/web/package-lock.json
  28. 1 0
      services/web/package.json
  29. 40 3
      services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js
  30. 2 0
      services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.js
  31. 1 0
      services/web/test/frontend/features/outline/components/outline-pane.test.js
  32. 4 1
      services/web/test/frontend/helpers/render-with-context.js

+ 14 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -843,13 +843,24 @@ const ProjectController = {
               newPdfPreviewAssignment.variant === 'react-pdf-preview'
             )
 
+            let disableAngularRouter = shouldDisplayFeature(
+              'disable_angular_router',
+              user.alphaProgram
+            )
+
             const showPdfDetach = shouldDisplayFeature(
               'pdf_detach',
               user.alphaProgram
             )
 
+            const debugPdfDetach = shouldDisplayFeature('debug_pdf_detach')
+
+            let detachRole = null
+
             if (showPdfDetach) {
+              disableAngularRouter = true
               showNewPdfPreview = true
+              detachRole = req.params.detachRole
             }
 
             res.render('project/editor', {
@@ -911,7 +922,9 @@ const ProjectController = {
               ),
               logsUISubvariant: logsUIVariant.subvariant,
               showPdfDetach,
+              debugPdfDetach,
               showNewPdfPreview,
+              disableAngularRouter,
               showNewSourceEditor: shouldDisplayFeature(
                 'new_source_editor',
                 false
@@ -925,6 +938,7 @@ const ProjectController = {
               resetServiceWorker:
                 Boolean(Settings.resetServiceWorker) &&
                 !shouldDisplayFeature('enable_pdf_caching', false),
+              detachRole,
             })
             timer.done()
           }

+ 1 - 1
services/web/app/src/router.js

@@ -295,7 +295,7 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
   )
 
   webRouter.get(
-    '/Project/:Project_id',
+    '/Project/:Project_id/:detachRole(detacher|detached)?',
     RateLimiterMiddleware.rateLimit({
       endpointName: 'open-project',
       params: ['Project_id'],

+ 11 - 47
services/web/app/views/project/editor.pug

@@ -65,53 +65,14 @@ block content
 						span.sr-only #{translate("close")}
 					.system-message-content(ng-bind-html="htmlContent")
 
-		include ./editor/left-menu
-
-		#chat-wrapper.full-size(
-			layout="chat",
-			spacing-open="{{ui.chatResizerSizeOpen}}",
-			spacing-closed="{{ui.chatResizerSizeClosed}}",
-			initial-size-east="250",
-			init-closed-east="true",
-			open-east="ui.chatOpen",
-			ng-hide="state.loading",
-			ng-cloak
-		)
-			.ui-layout-center
-				include ./editor/header-react
-
-				include ./editor/history/toolbarV2.pug
-
-				main#ide-body(
-					ng-cloak,
-					role="main",
-					ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2) }",
-					layout="main",
-					ng-hide="state.loading",
-					resize-on="layout:chat:resize,history:toggle,layout:flat-screen:toggle,symbol-palette-toggled",
-					minimum-restore-size-west="130"
-					custom-toggler-pane=hasFeature('custom-togglers') ? "west" : false
-					custom-toggler-msg-when-open=hasFeature('custom-togglers') ? translate("tooltip_hide_filetree") : false
-					custom-toggler-msg-when-closed=hasFeature('custom-togglers') ? translate("tooltip_show_filetree") : false
-					ng-keydown="handleKeyDown($event)"
-					tabindex="0"
-				)
-					.ui-layout-west
-						include ./editor/file-tree-react
-						include ./editor/file-tree-history
-						include ./editor/history/fileTreeV2
-
-					.ui-layout-center
-						include ./editor/editor
-
-						include ./editor/file-view
-
-						include ./editor/history
-
-			if !isRestrictedTokenMember
-				.ui-layout-east
-					aside.chat
-						chat()
+		if detachRole === 'detached'
+			div.full-size
+				if showNewPdfPreview
+					pdf-preview()
+				else
+					include ./editor/pdf
+		else
+			include ./editor/main
 
 	script(type="text/ng-template", id="genericMessageModalTemplate")
 		.modal-header
@@ -183,10 +144,13 @@ block append meta
 	meta(name="ol-logsUISubvariant" content=logsUISubvariant)
 	meta(name="ol-showSymbolPalette" data-type="boolean" content=showSymbolPalette)
 	meta(name="ol-showPdfDetach" data-type="boolean" content=showPdfDetach)
+	meta(name="ol-debugPdfDetach" data-type="boolean" content=debugPdfDetach)
+	meta(name="ol-disableAngularRouter" data-type="boolean" content=disableAngularRouter)
 	meta(name="ol-showNewPdfPreview" data-type="boolean" content=showNewPdfPreview)
 	meta(name="ol-enablePdfCaching" data-type="boolean" content=enablePdfCaching)
 	meta(name="ol-trackPdfDownload" data-type="boolean" content=trackPdfDownload)
 	meta(name="ol-resetServiceWorker" data-type="boolean" content=resetServiceWorker)
+	meta(name="ol-detachRole" data-type="string" content=detachRole)
 
 	- 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)

+ 47 - 0
services/web/app/views/project/editor/main.pug

@@ -0,0 +1,47 @@
+include ./left-menu
+
+#chat-wrapper.full-size(
+	layout="chat",
+	spacing-open="{{ui.chatResizerSizeOpen}}",
+	spacing-closed="{{ui.chatResizerSizeClosed}}",
+	initial-size-east="250",
+	init-closed-east="true",
+	open-east="ui.chatOpen",
+	ng-hide="state.loading",
+	ng-cloak
+)
+	.ui-layout-center
+		include ./header-react
+
+		include ./history/toolbarV2.pug
+
+		main#ide-body(
+			ng-cloak,
+			role="main",
+			ng-class="{ 'ide-history-open' : (ui.view == 'history' && history.isV2) }",
+			layout="main",
+			ng-hide="state.loading",
+			resize-on="layout:chat:resize,history:toggle,layout:flat-screen:toggle,symbol-palette-toggled",
+			minimum-restore-size-west="130"
+			custom-toggler-pane=hasFeature('custom-togglers') ? "west" : false
+			custom-toggler-msg-when-open=hasFeature('custom-togglers') ? translate("tooltip_hide_filetree") : false
+			custom-toggler-msg-when-closed=hasFeature('custom-togglers') ? translate("tooltip_show_filetree") : false
+			ng-keydown="handleKeyDown($event)"
+			tabindex="0"
+		)
+			.ui-layout-west
+				include ./file-tree-react
+				include ./file-tree-history
+				include ./history/fileTreeV2
+
+			.ui-layout-center
+				include ./editor
+
+				include ./file-view
+
+				include ./history
+
+	if !isRestrictedTokenMember
+		.ui-layout-east
+			aside.chat
+				chat()

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

@@ -15,6 +15,7 @@
   "autocomplete_references": "",
   "back_to_your_projects": "",
   "blocked_filename": "",
+  "bring_pdf_back_to_tab": "",
   "can_edit": "",
   "cancel": "",
   "cannot_invite_non_user": "",
@@ -168,6 +169,7 @@
   "invalid_request": "",
   "invite_not_accepted": "",
   "layout": "",
+  "layout_processing": "",
   "learn_how_to_make_documents_compile_quickly": "",
   "learn_more_about_link_sharing": "",
   "learn_more_about_the_symbol_palette": "",
@@ -231,6 +233,7 @@
   "official": "",
   "ok": "",
   "on": "",
+  "open_pdf_in_new_tab": "",
   "optional": "",
   "or": "",
   "other_logs_and_files": "",
@@ -280,6 +283,7 @@
   "recompile_from_scratch": "",
   "recompile_pdf": "",
   "reconnect": "",
+  "redirect_to_editor": "",
   "reference_error_relink_hint": "",
   "refresh": "",
   "refresh_page_after_linking_dropbox": "",
@@ -329,6 +333,7 @@
   "sync_project_to_github_explanation": "",
   "sync_to_dropbox": "",
   "sync_to_github": "",
+  "tab_no_longer_connected": "",
   "tags": "",
   "template_approved_by_publisher": "",
   "terminated": "",

+ 13 - 12
services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.js

@@ -15,18 +15,19 @@ function ChatToggleButton({ chatIsOpen, unreadMessageCount, onClick }) {
   const hasUnreadMessages = unreadMessageCount > 0
 
   return (
-    // eslint-disable-next-line jsx-a11y/anchor-is-valid
-    <a role="button" className={classes} href="#" onClick={onClick}>
-      <Icon
-        type="fw"
-        modifier="comment"
-        classes={{ icon: hasUnreadMessages ? 'bounce' : undefined }}
-      />
-      {hasUnreadMessages ? (
-        <span className="label label-info">{unreadMessageCount}</span>
-      ) : null}
-      <p className="toolbar-label">{t('chat')}</p>
-    </a>
+    <div className="toolbar-item">
+      <button className={classes} onClick={onClick}>
+        <Icon
+          type="fw"
+          modifier="comment"
+          classes={{ icon: hasUnreadMessages ? 'bounce' : undefined }}
+        />
+        {hasUnreadMessages ? (
+          <span className="label label-info">{unreadMessageCount}</span>
+        ) : null}
+        <p className="toolbar-label">{t('chat')}</p>
+      </button>
+    </div>
   )
 }
 

+ 8 - 0
services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js

@@ -59,6 +59,10 @@ const EditorNavigationToolbarRoot = React.memo(
     } = useEditorContext(editorContextPropTypes)
 
     const {
+      reattach,
+      detach,
+      detachMode,
+      detachRole,
       changeLayout,
       chatIsOpen,
       setChatIsOpen,
@@ -123,6 +127,10 @@ const EditorNavigationToolbarRoot = React.memo(
     // `loading ? null : <ToolbarHeader/>` causes UI glitches
     return (
       <ToolbarHeader
+        reattach={reattach}
+        detach={detach}
+        detachMode={detachMode}
+        detachRole={detachRole}
         style={loading ? { display: 'none' } : {}}
         cobranding={cobranding}
         onShowLeftMenuClick={onShowLeftMenuClick}

+ 6 - 5
services/web/frontend/js/features/editor-navigation-toolbar/components/history-toggle-button.js

@@ -11,11 +11,12 @@ function HistoryToggleButton({ historyIsOpen, onClick }) {
   })
 
   return (
-    // eslint-disable-next-line jsx-a11y/anchor-is-valid
-    <a role="button" className={classes} href="#" onClick={onClick}>
-      <Icon type="fw" modifier="history" />
-      <p className="toolbar-label">{t('history')}</p>
-    </a>
+    <div className="toolbar-item">
+      <button className={classes} onClick={onClick}>
+        <Icon type="fw" modifier="history" />
+        <p className="toolbar-label">{t('history')}</p>
+      </button>
+    </div>
   )
 }
 

+ 102 - 49
services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js

@@ -19,58 +19,107 @@ function IconCheckmark({ iconFor, pdfLayout, view }) {
   return <Icon type="" modifier="fw" />
 }
 
-function LayoutDropdownButton({ handleChangeLayout, pdfLayout, view }) {
+function LayoutDropdownButton({
+  reattach,
+  detach,
+  handleChangeLayout,
+  detachMode,
+  detachRole,
+  pdfLayout,
+  view,
+}) {
   const { t } = useTranslation()
+
   // bsStyle is required for Dropdown.Toggle, but we will override style
   return (
-    <ControlledDropdown id="layout-dropdown" className="toolbar-item">
-      <Dropdown.Toggle className="btn-full-height" bsStyle="link">
-        <Icon type="columns" modifier="fw" />
-        <span className="toolbar-label">{t('layout')}</span>
-      </Dropdown.Toggle>
-      <Dropdown.Menu id="layout-dropdown-list">
-        <MenuItem header>{t('layout')}</MenuItem>
-        <MenuItem onSelect={() => handleChangeLayout('sideBySide')}>
-          <IconCheckmark
-            iconFor="sideBySide"
-            pdfLayout={pdfLayout}
-            view={view}
-          />
-          <Icon type="columns" />
-          {t('editor_and_pdf')}
-        </MenuItem>
-        <MenuItem
-          onSelect={() => handleChangeLayout('flat', 'editor')}
-          className="menu-item-with-svg"
-        >
-          <IconCheckmark
-            iconFor="editorOnly"
-            pdfLayout={pdfLayout}
-            view={view}
-          />
-          <IconEditorOnly />
-          <Trans
-            i18nKey="editor_only_hide_pdf"
-            components={[
-              <span key="editor_only_hide_pdf" className="subdued" />,
-            ]}
-          />
-        </MenuItem>
-        <MenuItem
-          onSelect={() => handleChangeLayout('flat', 'pdf')}
-          className="menu-item-with-svg"
-        >
-          <IconCheckmark iconFor="pdfOnly" pdfLayout={pdfLayout} view={view} />
-          <IconPdfOnly />
-          <Trans
-            i18nKey="pdf_only_hide_editor"
-            components={[
-              <span key="pdf_only_hide_editor" className="subdued" />,
-            ]}
-          />
-        </MenuItem>
-      </Dropdown.Menu>
-    </ControlledDropdown>
+    <>
+      {detachMode === 'detaching' && (
+        <div aria-live="assertive" className="sr-only">
+          {t('layout_processing')}
+        </div>
+      )}
+      <ControlledDropdown
+        id="layout-dropdown"
+        className="toolbar-item"
+        disabled={detachMode === 'detaching'}
+      >
+        <Dropdown.Toggle className="btn-full-height" bsStyle="link">
+          {detachMode === 'detaching' ? (
+            <Icon type="refresh" modifier="fw" spin />
+          ) : (
+            <Icon type="columns" modifier="fw" />
+          )}
+          <span className="toolbar-label">{t('layout')}</span>
+        </Dropdown.Toggle>
+        <Dropdown.Menu id="layout-dropdown-list">
+          <MenuItem header>{t('layout')}</MenuItem>
+
+          <MenuItem
+            disabled={detachRole === 'detacher'}
+            onSelect={() => handleChangeLayout('sideBySide')}
+          >
+            <IconCheckmark
+              iconFor="sideBySide"
+              pdfLayout={pdfLayout}
+              view={view}
+            />
+            <Icon type="columns" />
+            {t('editor_and_pdf')}
+          </MenuItem>
+
+          <MenuItem
+            onSelect={() => handleChangeLayout('flat', 'editor')}
+            className="menu-item-with-svg"
+          >
+            <IconCheckmark
+              iconFor="editorOnly"
+              pdfLayout={pdfLayout}
+              view={view}
+            />
+            <IconEditorOnly />
+            <Trans
+              i18nKey="editor_only_hide_pdf"
+              components={[
+                <span key="editor_only_hide_pdf" className="subdued" />,
+              ]}
+            />
+          </MenuItem>
+
+          <MenuItem
+            disabled={detachRole === 'detacher'}
+            onSelect={() => handleChangeLayout('flat', 'pdf')}
+            className="menu-item-with-svg"
+          >
+            <IconCheckmark
+              iconFor="pdfOnly"
+              pdfLayout={pdfLayout}
+              view={view}
+            />
+            <IconPdfOnly />
+            <Trans
+              i18nKey="pdf_only_hide_editor"
+              components={[
+                <span key="pdf_only_hide_editor" className="subdued" />,
+              ]}
+            />
+          </MenuItem>
+
+          <MenuItem divider />
+
+          {detachRole === 'detacher' ? (
+            <MenuItem onSelect={() => reattach()}>
+              <Icon type="window-restore" modifier="fw" />
+              {t('bring_pdf_back_to_tab')}
+            </MenuItem>
+          ) : (
+            <MenuItem onSelect={() => detach()}>
+              <Icon type="window-restore" modifier="fw" />
+              {t('open_pdf_in_new_tab')}
+            </MenuItem>
+          )}
+        </Dropdown.Menu>
+      </ControlledDropdown>
+    </>
   )
 }
 
@@ -83,7 +132,11 @@ IconCheckmark.propTypes = {
 }
 
 LayoutDropdownButton.propTypes = {
+  reattach: PropTypes.func.isRequired,
+  detach: PropTypes.func.isRequired,
   handleChangeLayout: PropTypes.func.isRequired,
+  detachMode: PropTypes.string,
+  detachRole: PropTypes.string,
   pdfLayout: PropTypes.string.isRequired,
   view: PropTypes.string,
 }

+ 6 - 5
services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.js

@@ -6,11 +6,12 @@ function ShareProjectButton({ onClick }) {
   const { t } = useTranslation()
 
   return (
-    // eslint-disable-next-line jsx-a11y/anchor-is-valid,jsx-a11y/click-events-have-key-events,jsx-a11y/interactive-supports-focus
-    <a role="button" className="btn btn-full-height" onClick={onClick}>
-      <Icon type="fw" modifier="group" />
-      <p className="toolbar-label">{t('share')}</p>
-    </a>
+    <div className="toolbar-item">
+      <button className="btn btn-full-height" onClick={onClick}>
+        <Icon type="fw" modifier="group" />
+        <p className="toolbar-label">{t('share')}</p>
+      </button>
+    </div>
   )
 }
 

+ 13 - 1
services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js

@@ -18,6 +18,10 @@ const [publishModalModules] = importOverleafModules('publishModal')
 const PublishButton = publishModalModules?.import.default
 
 const ToolbarHeader = React.memo(function ToolbarHeader({
+  reattach,
+  detach,
+  detachMode,
+  detachRole,
   cobranding,
   onShowLeftMenuClick,
   handleChangeLayout,
@@ -61,7 +65,7 @@ const ToolbarHeader = React.memo(function ToolbarHeader({
         )}
         <BackToProjectsButton />
       </div>
-      {pdfButtonIsVisible && (
+      {!window.showPdfDetach && pdfButtonIsVisible && (
         <PdfToggleButton
           onClick={togglePdfView}
           pdfViewIsOpen={pdfViewIsOpen}
@@ -79,7 +83,11 @@ const ToolbarHeader = React.memo(function ToolbarHeader({
 
         {window.showPdfDetach && (
           <LayoutDropdownButton
+            reattach={reattach}
+            detach={detach}
             handleChangeLayout={handleChangeLayout}
+            detachMode={detachMode}
+            detachRole={detachRole}
             pdfLayout={pdfLayout}
             view={view}
           />
@@ -115,6 +123,10 @@ const ToolbarHeader = React.memo(function ToolbarHeader({
 })
 
 ToolbarHeader.propTypes = {
+  reattach: PropTypes.func.isRequired,
+  detach: PropTypes.func.isRequired,
+  detachMode: PropTypes.string,
+  detachRole: PropTypes.string,
   onShowLeftMenuClick: PropTypes.func.isRequired,
   handleChangeLayout: PropTypes.func.isRequired,
   cobranding: PropTypes.object,

+ 6 - 11
services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.js

@@ -10,17 +10,12 @@ function TrackChangesToggleButton({ trackChangesIsOpen, disabled, onClick }) {
   })
 
   return (
-    // eslint-disable-next-line jsx-a11y/anchor-is-valid
-    <a
-      role="button"
-      disabled={disabled}
-      className={classes}
-      href="#"
-      onClick={onClick}
-    >
-      <i className="review-icon" />
-      <p className="toolbar-label">{t('review')}</p>
-    </a>
+    <div className="toolbar-item">
+      <button disabled={disabled} className={classes} onClick={onClick}>
+        <i className="review-icon" />
+        <p className="toolbar-label">{t('review')}</p>
+      </button>
+    </div>
   )
 }
 

+ 25 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js

@@ -0,0 +1,25 @@
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { memo } from 'react'
+import { buildUrlWithDetachRole } from '../../../shared/utils/url-helper'
+
+const redirect = function () {
+  window.location = buildUrlWithDetachRole(null)
+}
+
+function PdfOrphanRefreshButton() {
+  const { t } = useTranslation()
+
+  return (
+    <Button
+      onClick={redirect}
+      className="btn-orphan"
+      bsStyle="primary"
+      bsSize="small"
+    >
+      {t('redirect_to_editor')}
+    </Button>
+  )
+}
+
+export default memo(PdfOrphanRefreshButton)

+ 31 - 2
services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js

@@ -1,14 +1,31 @@
 import { memo } from 'react'
 import { ButtonToolbar } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { useLayoutContext } from '../../../shared/context/layout-context'
 import PdfCompileButton from './pdf-compile-button'
 import PdfExpandButton from './pdf-expand-button'
 import PdfHybridLogsButton from './pdf-hybrid-logs-button'
 import PdfHybridDownloadButton from './pdf-hybrid-download-button'
 import PdfHybridCodeCheckButton from './pdf-hybrid-code-check-button'
+import PdfOrphanRefreshButton from './pdf-orphan-refresh-button'
 
 function PdfPreviewHybridToolbar() {
+  const { detachMode } = useLayoutContext()
+
   return (
     <ButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
+      {detachMode === 'orphan' ? (
+        <PdfPreviewHybridToolbarOrphanInner />
+      ) : (
+        <PdfPreviewHybridToolbarInner />
+      )}
+    </ButtonToolbar>
+  )
+}
+
+function PdfPreviewHybridToolbarInner() {
+  return (
+    <>
       <div className="toolbar-pdf-left">
         <PdfCompileButton />
         <PdfHybridLogsButton />
@@ -16,9 +33,21 @@ function PdfPreviewHybridToolbar() {
       </div>
       <div className="toolbar-pdf-right">
         <PdfHybridCodeCheckButton />
-        <PdfExpandButton />
+        {!window.showPdfDetach && <PdfExpandButton />}
       </div>
-    </ButtonToolbar>
+    </>
+  )
+}
+
+function PdfPreviewHybridToolbarOrphanInner() {
+  const { t } = useTranslation()
+  return (
+    <>
+      <div className="toolbar-pdf-orphan">
+        {t('tab_no_longer_connected')}
+        <PdfOrphanRefreshButton />
+      </div>
+    </>
   )
 }
 

+ 15 - 0
services/web/frontend/js/ide.js

@@ -401,6 +401,21 @@ if (getMeta('ol-resetServiceWorker')) {
   loadServiceWorker()
 }
 
+if (getMeta('ol-disableAngularRouter')) {
+  angular.module('SharelatexApp').config(function ($provide) {
+    $provide.decorator('$browser', [
+      '$delegate',
+      function ($delegate) {
+        $delegate.onUrlChange = function () {}
+        $delegate.url = function () {
+          return ''
+        }
+        return $delegate
+      },
+    ])
+  })
+}
+
 export default angular.bootstrap(document.body, ['SharelatexApp'])
 
 function __guard__(value, transform) {

+ 118 - 0
services/web/frontend/js/shared/context/detach-context.js

@@ -0,0 +1,118 @@
+import {
+  createContext,
+  useContext,
+  useCallback,
+  useMemo,
+  useEffect,
+  useState,
+} from 'react'
+import PropTypes from 'prop-types'
+import sysend from 'sysend'
+import getMeta from '../../utils/meta'
+import { buildUrlWithDetachRole } from '../utils/url-helper'
+import useCallbackHandlers from '../hooks/use-callback-handlers'
+
+export const DetachContext = createContext()
+
+DetachContext.Provider.propTypes = {
+  value: PropTypes.shape({
+    role: PropTypes.oneOf(['detacher', 'detached', null]),
+    setRole: PropTypes.func.isRequired,
+    broadcastEvent: PropTypes.func.isRequired,
+    addEventHandler: PropTypes.func.isRequired,
+    deleteEventHandler: PropTypes.func.isRequired,
+  }).isRequired,
+}
+
+const debugPdfDetach = getMeta('ol-debugPdfDetach')
+
+const SYSEND_CHANNEL = `detach-${getMeta('ol-project_id')}`
+
+export function DetachProvider({ children }) {
+  const [role, setRole] = useState(() => getMeta('ol-detachRole') || null)
+  const {
+    addHandler: addEventHandler,
+    deleteHandler: deleteEventHandler,
+    callHandlers: callEventHandlers,
+  } = useCallbackHandlers()
+
+  useEffect(() => {
+    if (debugPdfDetach) {
+      console.log('Effect', { role })
+    }
+    window.history.replaceState({}, '', buildUrlWithDetachRole(role))
+  }, [role])
+
+  useEffect(() => {
+    sysend.on(SYSEND_CHANNEL, message => {
+      if (debugPdfDetach) {
+        console.log(`Receiving:`, message)
+      }
+      callEventHandlers(message)
+    })
+    return () => sysend.off(SYSEND_CHANNEL)
+  }, [callEventHandlers])
+
+  const broadcastEvent = useCallback(
+    (event, data) => {
+      if (!role) {
+        if (debugPdfDetach) {
+          console.log('Not Broadcasting (no role)', {
+            role,
+            event,
+            data,
+          })
+        }
+        return
+      }
+      if (debugPdfDetach) {
+        console.log('Broadcasting', {
+          role,
+          event,
+          data,
+        })
+      }
+      sysend.broadcast(SYSEND_CHANNEL, {
+        role,
+        event,
+        data,
+      })
+    },
+    [role]
+  )
+
+  useEffect(() => {
+    broadcastEvent('connected')
+  }, [broadcastEvent])
+
+  useEffect(() => {
+    const onBeforeUnload = () => broadcastEvent('closed')
+    window.addEventListener('beforeunload', onBeforeUnload)
+    return () => window.removeEventListener('beforeunload', onBeforeUnload)
+  }, [broadcastEvent])
+
+  const value = useMemo(
+    () => ({
+      role,
+      setRole,
+      broadcastEvent,
+      addEventHandler,
+      deleteEventHandler,
+    }),
+    [role, setRole, broadcastEvent, addEventHandler, deleteEventHandler]
+  )
+
+  return (
+    <DetachContext.Provider value={value}>{children}</DetachContext.Provider>
+  )
+}
+
+DetachProvider.propTypes = {
+  children: PropTypes.any,
+}
+
+export function useDetachContext(propTypes) {
+  const data = useContext(DetachContext)
+  PropTypes.checkPropTypes(propTypes, data, 'data', 'DetachContext.Provider')
+  return data
+}

+ 45 - 1
services/web/frontend/js/shared/context/layout-context.js

@@ -1,6 +1,14 @@
-import { createContext, useContext, useCallback, useMemo } from 'react'
+import {
+  createContext,
+  useContext,
+  useCallback,
+  useMemo,
+  useEffect,
+} from 'react'
 import PropTypes from 'prop-types'
 import useScopeValue from '../hooks/use-scope-value'
+import usePreviousValue from '../hooks/use-previous-value'
+import useDetachLayout from '../hooks/use-detach-layout'
 import { useIdeContext } from './ide-context'
 import localStorage from '../../infrastructure/local-storage'
 
@@ -73,8 +81,40 @@ export function LayoutProvider({ children }) {
     [setPdfLayout, setView]
   )
 
+  const {
+    reattach,
+    detach,
+    mode: detachMode,
+    role: detachRole,
+  } = useDetachLayout()
+  const previousDetachMode = usePreviousValue(detachMode)
+
+  useEffect(() => {
+    switch (detachMode) {
+      case 'detacher':
+        changeLayout('flat', 'editor')
+        break
+      case 'detaching':
+        changeLayout('flat', 'editor')
+        break
+      case 'detached':
+        break
+      case 'orphan':
+        break
+      case null:
+        if (previousDetachMode) {
+          changeLayout('sideBySide')
+        }
+        break
+    }
+  }, [detachMode, previousDetachMode, changeLayout])
+
   const value = useMemo(
     () => ({
+      reattach,
+      detach,
+      detachMode,
+      detachRole,
       changeLayout,
       chatIsOpen,
       leftMenuShown,
@@ -89,6 +129,10 @@ export function LayoutProvider({ children }) {
       view,
     }),
     [
+      reattach,
+      detach,
+      detachMode,
+      detachRole,
       changeLayout,
       chatIsOpen,
       leftMenuShown,

+ 8 - 5
services/web/frontend/js/shared/context/root-context.js

@@ -6,6 +6,7 @@ import { IdeProvider } from './ide-context'
 import { EditorProvider } from './editor-context'
 import { CompileProvider } from './compile-context'
 import { LayoutProvider } from './layout-context'
+import { DetachProvider } from './detach-context'
 import { ChatProvider } from '../../features/chat/context/chat-context'
 import { ProjectProvider } from './project-context'
 import { SplitTestProvider } from './split-test-context'
@@ -17,11 +18,13 @@ export function ContextRoot({ children, ide, settings }) {
         <UserProvider>
           <ProjectProvider>
             <EditorProvider settings={settings}>
-              <LayoutProvider>
-                <CompileProvider>
-                  <ChatProvider>{children}</ChatProvider>
-                </CompileProvider>
-              </LayoutProvider>
+              <DetachProvider>
+                <LayoutProvider>
+                  <CompileProvider>
+                    <ChatProvider>{children}</ChatProvider>
+                  </CompileProvider>
+                </LayoutProvider>
+              </DetachProvider>
             </EditorProvider>
           </ProjectProvider>
         </UserProvider>

+ 33 - 0
services/web/frontend/js/shared/hooks/use-callback-handlers.js

@@ -0,0 +1,33 @@
+import { useCallback, useState } from 'react'
+
+export default function useCallbackHandlers() {
+  const [handlers, setHandlers] = useState(new Set())
+
+  const addHandler = useCallback(
+    handler => {
+      setHandlers(prev => new Set(prev.add(handler)))
+    },
+    [setHandlers]
+  )
+
+  const deleteHandler = useCallback(
+    handler => {
+      setHandlers(prev => {
+        prev.delete(handler)
+        return new Set(prev)
+      })
+    },
+    [setHandlers]
+  )
+
+  const callHandlers = useCallback(
+    (...args) => {
+      for (const handler of handlers) {
+        handler(...args)
+      }
+    },
+    [handlers]
+  )
+
+  return { addHandler, deleteHandler, callHandlers }
+}

+ 130 - 0
services/web/frontend/js/shared/hooks/use-detach-layout.js

@@ -0,0 +1,130 @@
+import { useCallback, useState, useEffect } from 'react'
+import { useDetachContext } from '../context/detach-context'
+import getMeta from '../../utils/meta'
+import { buildUrlWithDetachRole } from '../utils/url-helper'
+
+const debugPdfDetach = getMeta('ol-debugPdfDetach')
+
+export default function useDetachLayout() {
+  const {
+    role,
+    setRole,
+    broadcastEvent,
+    addEventHandler,
+    deleteEventHandler,
+  } = useDetachContext()
+
+  const [mode, setMode] = useState(() => {
+    if (role === 'detacher') {
+      return 'detaching'
+    }
+    if (role === 'detached') {
+      return 'orphan'
+    }
+  })
+
+  useEffect(() => {
+    if (debugPdfDetach) {
+      console.log('Effect', { mode })
+    }
+  }, [mode])
+
+  const reattach = useCallback(() => {
+    broadcastEvent('reattach')
+    setRole(null)
+    setMode(null)
+  }, [setRole, setMode, broadcastEvent])
+
+  const detach = useCallback(() => {
+    setRole('detacher')
+    setMode('detaching')
+
+    window.open(buildUrlWithDetachRole('detached'), '_blank')
+  }, [setRole, setMode])
+
+  const handleEventForDetacherFromDetached = useCallback(
+    message => {
+      switch (message.event) {
+        case 'connected':
+          broadcastEvent('up')
+          setMode('detacher')
+          break
+        case 'up':
+          setMode('detacher')
+          break
+        case 'closed':
+          setMode(null)
+          break
+      }
+    },
+    [setMode, broadcastEvent]
+  )
+
+  const handleEventForDetachedFromDetacher = useCallback(
+    message => {
+      switch (message.event) {
+        case 'connected':
+          broadcastEvent('up')
+          setMode('detached')
+          break
+        case 'up':
+          setMode('detached')
+          break
+        case 'closed':
+          setMode('orphan')
+          break
+        case 'reattach':
+          window.close()
+          break
+      }
+    },
+    [setMode, broadcastEvent]
+  )
+
+  const handleEventFromSelf = useCallback(
+    message => {
+      switch (message.event) {
+        case 'closed':
+          broadcastEvent('up')
+          break
+      }
+    },
+    [broadcastEvent]
+  )
+
+  const handleEvent = useCallback(
+    message => {
+      if (role === 'detacher') {
+        if (message.role === 'detacher') {
+          handleEventFromSelf(message)
+        } else if (message.role === 'detached') {
+          handleEventForDetacherFromDetached(message)
+        }
+      } else if (role === 'detached') {
+        if (message.role === 'detacher') {
+          handleEventForDetachedFromDetacher(message)
+        } else if (message.role === 'detached') {
+          handleEventFromSelf(message)
+        }
+      }
+    },
+    [
+      role,
+      handleEventForDetacherFromDetached,
+      handleEventForDetachedFromDetacher,
+      handleEventFromSelf,
+    ]
+  )
+
+  useEffect(() => {
+    addEventHandler(handleEvent)
+    return () => deleteEventHandler(handleEvent)
+  }, [addEventHandler, deleteEventHandler, handleEvent])
+
+  return {
+    reattach,
+    detach,
+    mode,
+    role,
+  }
+}

+ 9 - 0
services/web/frontend/js/shared/hooks/use-previous-value.js

@@ -0,0 +1,9 @@
+import { useEffect, useRef } from 'react'
+
+export default function usePreviousValue(value) {
+  const ref = useRef()
+  useEffect(() => {
+    ref.current = value
+  })
+  return ref.current
+}

+ 11 - 0
services/web/frontend/js/shared/utils/url-helper.js

@@ -0,0 +1,11 @@
+export function buildUrlWithDetachRole(mode) {
+  const url = new URL(window.location)
+  const cleanPathname = url.pathname
+    .replace(/\/(detached|detacher)\/?$/, '')
+    .replace(/\/$/, '')
+  url.pathname = cleanPathname
+  if (mode) {
+    url.pathname += `/${mode}`
+  }
+  return url
+}

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

@@ -35,6 +35,7 @@
   border-bottom: 0;
 }
 
+.toolbar-pdf-orphan,
 .toolbar-pdf-left,
 .toolbar-pdf-right {
   display: flex;
@@ -47,6 +48,14 @@
   flex: 1 0 auto;
 }
 
+.toolbar-pdf-orphan {
+  justify-content: center;
+  color: white;
+  .btn {
+    margin-left: @margin-xs;
+  }
+}
+
 .btn-toggle-logs {
   &:focus,
   &:active:focus {
@@ -58,7 +67,7 @@
 }
 
 .toolbar-pdf-hybrid {
-  .btn:not(.btn-recompile) {
+  .btn:not(.btn-recompile):not(.btn-orphan) {
     display: inline-block;
     color: @toolbar-btn-color;
     background-color: transparent;

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

@@ -951,7 +951,7 @@
   }
 }
 
-a when (@is-overleaf-light = true) {
+button when (@is-overleaf-light = true) {
   .review-icon {
     background: url('/img/ol-icons/review-icon-light-theme.svg') top/30px
       no-repeat;

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

@@ -27,7 +27,8 @@
     }
   }
 
-  > a:focus {
+  > a:focus,
+  button:focus {
     outline: none;
   }
 
@@ -429,4 +430,37 @@
       }
     }
   }
+
+  &.disabled {
+    .subdued {
+      color: @dropdown-link-disabled-color;
+    }
+
+    svg {
+      line,
+      rect {
+        stroke: @dropdown-link-disabled-color;
+      }
+      path {
+        fill: @dropdown-link-disabled-color;
+      }
+    }
+
+    a:hover,
+    a:focus {
+      .subdued {
+        color: @dropdown-link-disabled-color;
+      }
+
+      svg {
+        line,
+        rect {
+          stroke: @dropdown-link-disabled-color;
+        }
+        path {
+          fill: @dropdown-link-disabled-color;
+        }
+      }
+    }
+  }
 }

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

@@ -1530,5 +1530,10 @@
   "editor_only_hide_pdf": "Editor only <0>(hide PDF)</0>",
   "pdf_only_hide_editor": "PDF only <0>(hide editor)</0>",
   "selected": "Selected",
-  "project_layout_sharing_submission": "Project Layout, Sharing, and Submission"
+  "project_layout_sharing_submission": "Project Layout, Sharing, and Submission",
+  "open_pdf_in_new_tab": "Open PDF in new tab",
+  "bring_pdf_back_to_tab": "Bring PDF back to this tab",
+  "tab_no_longer_connected": "This tab is no longer connected with the editor",
+  "redirect_to_editor": "Redirect to editor",
+  "layout_processing": "Layout processing"
 }

+ 5 - 0
services/web/package-lock.json

@@ -35661,6 +35661,11 @@
         }
       }
     },
+    "sysend": {
+      "version": "1.7.1",
+      "resolved": "https://registry.npmjs.org/sysend/-/sysend-1.7.1.tgz",
+      "integrity": "sha512-RCbx0drkadsUAIKYSmIwf0gK4t/YAs4d7UIYa455CAAZVL2sg8eFV3Hf9QBJMCACNqD08mT5eG4v9GpNGszndA=="
+    },
     "table": {
       "version": "6.0.7",
       "resolved": "https://registry.npmjs.org/table/-/table-6.0.7.tgz",

+ 1 - 0
services/web/package.json

@@ -187,6 +187,7 @@
     "rolling-rate-limiter": "^0.2.10",
     "sanitize-html": "^1.27.1",
     "scroll-into-view-if-needed": "^2.2.25",
+    "sysend": "^1.7.1",
     "underscore": "^1.13.1",
     "unzipper": "^0.10.11",
     "url-parse": "^1.4.7",

+ 40 - 3
services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js

@@ -3,21 +3,58 @@ import LayoutDropdownButton from '../../../../../frontend/js/features/editor-nav
 
 describe('<LayoutDropdownButton />', function () {
   const defaultProps = {
+    reattach: () => {},
+    detach: () => {},
     handleChangeLayout: () => {},
+    detachMode: undefined,
+    detachRole: undefined,
     pdfLayout: 'flat',
-    view: 'editor',
+    view: 'pdf',
   }
 
-  it('should mark current layout option as selected (visually by checkmark, and aria-label for accessibility)', function () {
+  it('should mark current layout option as selected', function () {
+    // Selected is aria-label, visually we show a checkmark
     render(<LayoutDropdownButton {...defaultProps} />)
     screen.getByRole('menuitem', {
       name: 'Editor & PDF',
     })
     screen.getByRole('menuitem', {
-      name: 'PDF only (hide editor)',
+      name: 'Selected PDF only (hide editor)',
     })
+    screen.getByRole('menuitem', {
+      name: 'Editor only (hide PDF)',
+    })
+    screen.getByRole('menuitem', {
+      name: 'Open PDF in new tab',
+    })
+  })
+
+  it('should select Editor Only when detached and show option to reattach', function () {
+    const detachedProps = Object.assign({}, defaultProps, {
+      detachMode: 'detacher',
+      detachRole: 'detacher',
+      view: 'editor',
+    })
+
+    render(<LayoutDropdownButton {...detachedProps} />)
+
     screen.getByRole('menuitem', {
       name: 'Selected Editor only (hide PDF)',
     })
+    screen.getByRole('menuitem', {
+      name: 'Bring PDF back to this tab',
+    })
+  })
+
+  it('should show processing when detaching', function () {
+    const detachedProps = Object.assign({}, defaultProps, {
+      detachMode: 'detaching',
+      detachRole: 'detacher',
+      view: 'editor',
+    })
+
+    render(<LayoutDropdownButton {...detachedProps} />)
+
+    screen.getByText('Layout processing')
   })
 })

+ 2 - 0
services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.js

@@ -21,6 +21,8 @@ describe('<ToolbarHeader />', function () {
     handleChangeLayout: () => {},
     pdfLayout: '',
     view: '',
+    reattach: () => {},
+    detach: () => {},
   }
 
   describe('cobranding logo', function () {

+ 1 - 0
services/web/test/frontend/features/outline/components/outline-pane.test.js

@@ -22,6 +22,7 @@ describe('<OutlinePane />', function () {
       value: {
         getItem: sinon.stub().returns(null),
         setItem: sinon.stub(),
+        removeItem: sinon.stub(),
       },
     })
   })

+ 4 - 1
services/web/test/frontend/helpers/render-with-context.js

@@ -6,6 +6,7 @@ import sinon from 'sinon'
 import { UserProvider } from '../../../frontend/js/shared/context/user-context'
 import { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
 import { LayoutProvider } from '../../../frontend/js/shared/context/layout-context'
+import { DetachProvider } from '../../../frontend/js/shared/context/detach-context'
 import { ChatProvider } from '../../../frontend/js/features/chat/context/chat-context'
 import { IdeProvider } from '../../../frontend/js/shared/context/ide-context'
 import { get } from 'lodash'
@@ -91,7 +92,9 @@ export function EditorProviders({
           <ProjectProvider>
             <EditorProvider settings={{}}>
               <CompileProvider>
-                <LayoutProvider>{children}</LayoutProvider>
+                <DetachProvider>
+                  <LayoutProvider>{children}</LayoutProvider>
+                </DetachProvider>
               </CompileProvider>
             </EditorProvider>
           </ProjectProvider>