瀏覽代碼

Merge pull request #5854 from overleaf/ta-pdf-detach-v2

PDF Detach Updates

GitOrigin-RevId: c09c4fe37a922b041cfa1376e110a264a88177c8
Timothée Alby 4 年之前
父節點
當前提交
f6fc3d468c
共有 25 個文件被更改,包括 824 次插入333 次删除
  1. 3 2
      services/web/frontend/extracted-translations.json
  2. 0 17
      services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js
  3. 81 36
      services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js
  4. 16 33
      services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js
  5. 55 0
      services/web/frontend/js/features/pdf-preview/components/detach-compile-button.js
  6. 28 0
      services/web/frontend/js/features/pdf-preview/components/detach-synctex-control.js
  7. 50 0
      services/web/frontend/js/features/pdf-preview/components/pdf-compile-button-inner.js
  8. 6 33
      services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js
  9. 6 2
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js
  10. 2 0
      services/web/frontend/js/features/pdf-preview/components/pdf-preview.js
  11. 222 89
      services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js
  12. 5 0
      services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js
  13. 69 0
      services/web/frontend/js/features/pdf-preview/hooks/use-compile-triggers.js
  14. 1 0
      services/web/frontend/js/ide/editor/EditorManager.js
  15. 9 0
      services/web/frontend/js/ide/editor/controllers/CompileButton.js
  16. 11 31
      services/web/frontend/js/shared/context/compile-context.js
  17. 41 22
      services/web/frontend/js/shared/context/layout-context.js
  18. 55 0
      services/web/frontend/js/shared/hooks/use-detach-action.js
  19. 60 22
      services/web/frontend/js/shared/hooks/use-detach-layout.js
  20. 52 0
      services/web/frontend/js/shared/hooks/use-detach-state.js
  21. 15 0
      services/web/frontend/stylesheets/app/editor.less
  22. 5 3
      services/web/frontend/stylesheets/app/editor/pdf.less
  23. 4 3
      services/web/locales/en.json
  24. 21 31
      services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js
  25. 7 9
      services/web/test/frontend/helpers/render-with-context.js

+ 3 - 2
services/web/frontend/extracted-translations.json

@@ -15,7 +15,6 @@
   "autocomplete_references": "",
   "back_to_your_projects": "",
   "blocked_filename": "",
-  "bring_pdf_back_to_tab": "",
   "can_edit": "",
   "cancel": "",
   "cannot_invite_non_user": "",
@@ -234,7 +233,6 @@
   "official": "",
   "ok": "",
   "on": "",
-  "open_pdf_in_new_tab": "",
   "optional": "",
   "or": "",
   "other_logs_and_files": "",
@@ -245,6 +243,7 @@
   "pdf_compile_in_progress_error": "",
   "pdf_compile_rate_limit_hit": "",
   "pdf_compile_try_again": "",
+  "pdf_in_separate_tab": "",
   "pdf_only_hide_editor": "",
   "pdf_preview_error": "",
   "pdf_rendering_error": "",
@@ -315,6 +314,8 @@
   "share": "",
   "share_project": "",
   "share_with_your_collabs": "",
+  "show_in_code": "",
+  "show_in_pdf": "",
   "show_outline": "",
   "showing_1_result": "",
   "showing_1_result_of_total": "",

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

@@ -59,11 +59,6 @@ const EditorNavigationToolbarRoot = React.memo(
     } = useEditorContext(editorContextPropTypes)
 
     const {
-      reattach,
-      detach,
-      detachMode,
-      detachRole,
-      changeLayout,
       chatIsOpen,
       setChatIsOpen,
       reviewPanelOpen,
@@ -98,13 +93,6 @@ const EditorNavigationToolbarRoot = React.memo(
       setView(view === 'pdf' ? 'editor' : 'pdf')
     }, [view, setView])
 
-    const handleChangeLayout = useCallback(
-      (newLayout, newView) => {
-        changeLayout(newLayout, newView)
-      },
-      [changeLayout]
-    )
-
     const openShareModal = useCallback(() => {
       openShareProjectModal(isProjectOwner)
     }, [openShareProjectModal, isProjectOwner])
@@ -127,14 +115,9 @@ 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}
-        handleChangeLayout={handleChangeLayout}
         chatIsOpen={chatIsOpen}
         unreadMessageCount={unreadMessageCount}
         toggleChatOpen={toggleChatOpen}

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

@@ -1,3 +1,4 @@
+import { useCallback } from 'react'
 import PropTypes from 'prop-types'
 import { Dropdown, MenuItem } from 'react-bootstrap'
 import { Trans, useTranslation } from 'react-i18next'
@@ -6,8 +7,29 @@ import IconChecked from '../../../shared/components/icon-checked'
 import ControlledDropdown from '../../../shared/components/controlled-dropdown'
 import IconEditorOnly from './icon-editor-only'
 import IconPdfOnly from './icon-pdf-only'
+import { useLayoutContext } from '../../../shared/context/layout-context'
+import * as eventTracking from '../../../infrastructure/event-tracking'
 
-function IconCheckmark({ iconFor, pdfLayout, view }) {
+function IconPlaceholder() {
+  return <Icon type="" modifier="fw" />
+}
+
+function IconRefresh() {
+  return <Icon type="refresh" modifier="fw" spin />
+}
+
+function IconLayout() {
+  return <Icon type="columns" modifier="fw" />
+}
+
+function IconDetach() {
+  return <Icon type="window-restore" modifier="fw" />
+}
+
+function IconCheckmark({ iconFor, pdfLayout, view, detachRole }) {
+  if (detachRole === 'detacher') {
+    return <IconPlaceholder />
+  }
   if (iconFor === 'editorOnly' && pdfLayout === 'flat' && view === 'editor') {
     return <IconChecked />
   } else if (iconFor === 'pdfOnly' && pdfLayout === 'flat' && view === 'pdf') {
@@ -16,24 +38,53 @@ function IconCheckmark({ iconFor, pdfLayout, view }) {
     return <IconChecked />
   }
   // return empty icon for placeholder
-  return <Icon type="" modifier="fw" />
+  return <IconPlaceholder />
 }
 
-function LayoutDropdownButton({
-  reattach,
-  detach,
-  handleChangeLayout,
-  detachMode,
-  detachRole,
-  pdfLayout,
-  view,
-}) {
+function LayoutDropdownButton() {
   const { t } = useTranslation()
 
+  const {
+    reattach,
+    detach,
+    detachIsLinked,
+    detachRole,
+    changeLayout,
+    view,
+    pdfLayout,
+  } = useLayoutContext(layoutContextPropTypes)
+
+  const handleDetach = useCallback(() => {
+    detach()
+    eventTracking.sendMB('project-layout-detach')
+  }, [detach])
+
+  const handleReattach = useCallback(() => {
+    if (detachRole !== 'detacher') {
+      return
+    }
+    reattach()
+    eventTracking.sendMB('project-layout-reattach')
+  }, [detachRole, reattach])
+
+  const handleChangeLayout = useCallback(
+    (newLayout, newView) => {
+      handleReattach()
+      changeLayout(newLayout, newView)
+      eventTracking.sendMB('project-layout-change', {
+        layout: newLayout,
+        view: newView,
+      })
+    },
+    [changeLayout, handleReattach]
+  )
+
+  const processing = !detachIsLinked && detachRole === 'detacher'
+
   // bsStyle is required for Dropdown.Toggle, but we will override style
   return (
     <>
-      {detachMode === 'detaching' && (
+      {processing && (
         <div aria-live="assertive" className="sr-only">
           {t('layout_processing')}
         </div>
@@ -41,27 +92,19 @@ function LayoutDropdownButton({
       <ControlledDropdown
         id="layout-dropdown"
         className="toolbar-item"
-        disabled={detachMode === 'detaching'}
+        pullRight
       >
         <Dropdown.Toggle className="btn-full-height" bsStyle="link">
-          {detachMode === 'detaching' ? (
-            <Icon type="refresh" modifier="fw" spin />
-          ) : (
-            <Icon type="columns" modifier="fw" />
-          )}
+          {processing ? <IconRefresh /> : <IconLayout />}
           <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')}
-          >
+          <MenuItem onSelect={() => handleChangeLayout('sideBySide')}>
             <IconCheckmark
               iconFor="sideBySide"
               pdfLayout={pdfLayout}
               view={view}
+              detachRole={detachRole}
             />
             <Icon type="columns" />
             {t('editor_and_pdf')}
@@ -75,6 +118,7 @@ function LayoutDropdownButton({
               iconFor="editorOnly"
               pdfLayout={pdfLayout}
               view={view}
+              detachRole={detachRole}
             />
             <IconEditorOnly />
             <Trans
@@ -86,7 +130,6 @@ function LayoutDropdownButton({
           </MenuItem>
 
           <MenuItem
-            disabled={detachRole === 'detacher'}
             onSelect={() => handleChangeLayout('flat', 'pdf')}
             className="menu-item-with-svg"
           >
@@ -94,6 +137,7 @@ function LayoutDropdownButton({
               iconFor="pdfOnly"
               pdfLayout={pdfLayout}
               view={view}
+              detachRole={detachRole}
             />
             <IconPdfOnly />
             <Trans
@@ -104,17 +148,17 @@ function LayoutDropdownButton({
             />
           </MenuItem>
 
-          <MenuItem divider />
-
           {detachRole === 'detacher' ? (
-            <MenuItem onSelect={() => reattach()}>
-              <Icon type="window-restore" modifier="fw" />
-              {t('bring_pdf_back_to_tab')}
+            <MenuItem>
+              {detachIsLinked ? <IconChecked /> : <IconRefresh />}
+              <IconDetach />
+              {t('pdf_in_separate_tab')}
             </MenuItem>
           ) : (
-            <MenuItem onSelect={() => detach()}>
-              <Icon type="window-restore" modifier="fw" />
-              {t('open_pdf_in_new_tab')}
+            <MenuItem onSelect={handleDetach}>
+              <IconPlaceholder />
+              <IconDetach />
+              {t('pdf_in_separate_tab')}
             </MenuItem>
           )}
         </Dropdown.Menu>
@@ -129,13 +173,14 @@ IconCheckmark.propTypes = {
   iconFor: PropTypes.string.isRequired,
   pdfLayout: PropTypes.string.isRequired,
   view: PropTypes.string,
+  detachRole: PropTypes.string,
 }
 
-LayoutDropdownButton.propTypes = {
+const layoutContextPropTypes = {
   reattach: PropTypes.func.isRequired,
   detach: PropTypes.func.isRequired,
-  handleChangeLayout: PropTypes.func.isRequired,
-  detachMode: PropTypes.string,
+  changeLayout: PropTypes.func.isRequired,
+  detachIsLinked: PropTypes.bool,
   detachRole: PropTypes.string,
   pdfLayout: PropTypes.string.isRequired,
   view: PropTypes.string,

+ 16 - 33
services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js

@@ -18,13 +18,8 @@ const [publishModalModules] = importOverleafModules('publishModal')
 const PublishButton = publishModalModules?.import.default
 
 const ToolbarHeader = React.memo(function ToolbarHeader({
-  reattach,
-  detach,
-  detachMode,
-  detachRole,
   cobranding,
   onShowLeftMenuClick,
-  handleChangeLayout,
   chatIsOpen,
   toggleChatOpen,
   reviewPanelOpen,
@@ -81,18 +76,6 @@ const ToolbarHeader = React.memo(function ToolbarHeader({
       <div className="toolbar-right">
         <OnlineUsersWidget onlineUsers={onlineUsers} goToUser={goToUser} />
 
-        {window.showPdfDetach && (
-          <LayoutDropdownButton
-            reattach={reattach}
-            detach={detach}
-            handleChangeLayout={handleChangeLayout}
-            detachMode={detachMode}
-            detachRole={detachRole}
-            pdfLayout={pdfLayout}
-            view={view}
-          />
-        )}
-
         {shouldDisplayTrackChangesButton && (
           <TrackChangesToggleButton
             onClick={toggleReviewPanelOpen}
@@ -100,22 +83,27 @@ const ToolbarHeader = React.memo(function ToolbarHeader({
             trackChangesIsOpen={reviewPanelOpen}
           />
         )}
+
         <ShareProjectButton onClick={openShareModal} />
         {shouldDisplayPublishButton && (
           <PublishButton cobranding={cobranding} />
         )}
+
         {!isRestrictedTokenMember && (
-          <>
-            <HistoryToggleButton
-              historyIsOpen={historyIsOpen}
-              onClick={toggleHistoryOpen}
-            />
-            <ChatToggleButton
-              chatIsOpen={chatIsOpen}
-              onClick={toggleChatOpen}
-              unreadMessageCount={unreadMessageCount}
-            />
-          </>
+          <HistoryToggleButton
+            historyIsOpen={historyIsOpen}
+            onClick={toggleHistoryOpen}
+          />
+        )}
+
+        {window.showPdfDetach && <LayoutDropdownButton />}
+
+        {!isRestrictedTokenMember && (
+          <ChatToggleButton
+            chatIsOpen={chatIsOpen}
+            onClick={toggleChatOpen}
+            unreadMessageCount={unreadMessageCount}
+          />
         )}
       </div>
     </header>
@@ -123,12 +111,7 @@ 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,
   chatIsOpen: PropTypes.bool,
   toggleChatOpen: PropTypes.func.isRequired,

+ 55 - 0
services/web/frontend/js/features/pdf-preview/components/detach-compile-button.js

@@ -0,0 +1,55 @@
+import { memo, useCallback } from 'react'
+import PropTypes from 'prop-types'
+import classnames from 'classnames'
+import { useLayoutContext } from '../../../shared/context/layout-context'
+import { useCompileContext } from '../../../shared/context/compile-context'
+import useDetachAction from '../../../shared/hooks/use-detach-action'
+import PdfCompileButtonInner from './pdf-compile-button-inner'
+
+export function DetachCompileButton() {
+  const { compiling, hasChanges, startCompile } = useCompileContext()
+
+  const startOrTriggerCompile = useDetachAction(
+    'start-compile',
+    startCompile,
+    'detacher',
+    'detached'
+  )
+
+  const handleStartCompile = useCallback(() => startOrTriggerCompile(), [
+    startOrTriggerCompile,
+  ])
+
+  return (
+    <div
+      className={classnames({
+        'btn-recompile-group': true,
+        'btn-recompile-group-has-changes': hasChanges,
+      })}
+    >
+      <PdfCompileButtonInner
+        startCompile={handleStartCompile}
+        compiling={compiling}
+      />
+    </div>
+  )
+}
+
+export function DetachCompileButtonWrapper() {
+  const { detachRole, detachIsLinked } = useLayoutContext(
+    layoutContextPropTypes
+  )
+
+  if (detachRole !== 'detacher' || !detachIsLinked) {
+    return null
+  }
+
+  return <DetachCompileButton />
+}
+
+const layoutContextPropTypes = {
+  detachRole: PropTypes.string,
+  detachIsLinked: PropTypes.bool,
+}
+
+export default memo(DetachCompileButtonWrapper)

+ 28 - 0
services/web/frontend/js/features/pdf-preview/components/detach-synctex-control.js

@@ -0,0 +1,28 @@
+import PropTypes from 'prop-types'
+import { useLayoutContext } from '../../../shared/context/layout-context'
+import PdfSynctexControls from './pdf-synctex-controls'
+
+export function DetacherSynctexControl() {
+  const { detachRole, detachIsLinked } = useLayoutContext(
+    layoutContextPropTypes
+  )
+  if (detachRole === 'detacher' && detachIsLinked) {
+    return <PdfSynctexControls />
+  }
+  return null
+}
+
+export function DetachedSynctexControl() {
+  const { detachRole, detachIsLinked } = useLayoutContext(
+    layoutContextPropTypes
+  )
+  if (detachRole === 'detached' && detachIsLinked) {
+    return <PdfSynctexControls />
+  }
+  return null
+}
+
+const layoutContextPropTypes = {
+  detachRole: PropTypes.string,
+  detachIsLinked: PropTypes.bool,
+}

+ 50 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-compile-button-inner.js

@@ -0,0 +1,50 @@
+import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
+import PropTypes from 'prop-types'
+import Icon from '../../../shared/components/icon'
+import { useTranslation } from 'react-i18next'
+import { memo } from 'react'
+import { useLayoutContext } from '../../../shared/context/layout-context'
+
+const modifierKey = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'
+
+function PdfCompileButtonInner({ startCompile, compiling }) {
+  const { detachRole } = useLayoutContext()
+
+  const { t } = useTranslation()
+
+  const compileButtonLabel = compiling ? t('compiling') + '…' : t('recompile')
+
+  return (
+    <OverlayTrigger
+      placement="bottom"
+      delayShow={500}
+      overlay={
+        <Tooltip id="tooltip-logs-toggle" className="keyboard-tooltip">
+          {t('recompile_pdf')}{' '}
+          {detachRole !== 'detached' && (
+            <span className="keyboard-shortcut">({modifierKey} + Enter)</span>
+          )}
+        </Tooltip>
+      }
+    >
+      <Button
+        className="btn-recompile"
+        bsStyle="success"
+        onClick={() => startCompile()}
+        aria-label={compileButtonLabel}
+      >
+        <Icon type="refresh" spin={compiling} />
+        <span className="toolbar-hide-medium toolbar-hide-small btn-recompile-label">
+          {compileButtonLabel}
+        </span>
+      </Button>
+    </OverlayTrigger>
+  )
+}
+
+PdfCompileButtonInner.propTypes = {
+  compiling: PropTypes.bool.isRequired,
+  startCompile: PropTypes.func.isRequired,
+}
+
+export default memo(PdfCompileButtonInner)

+ 6 - 33
services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js

@@ -1,18 +1,11 @@
-import {
-  Button,
-  Dropdown,
-  MenuItem,
-  OverlayTrigger,
-  Tooltip,
-} from 'react-bootstrap'
+import { Dropdown, MenuItem } from 'react-bootstrap'
 import Icon from '../../../shared/components/icon'
 import ControlledDropdown from '../../../shared/components/controlled-dropdown'
 import { useTranslation } from 'react-i18next'
 import { memo } from 'react'
 import classnames from 'classnames'
 import { useCompileContext } from '../../../shared/context/compile-context'
-
-const modifierKey = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'
+import PdfCompileButtonInner from './pdf-compile-button-inner'
 
 function PdfCompileButton() {
   const {
@@ -31,8 +24,6 @@ function PdfCompileButton() {
 
   const { t } = useTranslation()
 
-  const compileButtonLabel = compiling ? t('compiling') + '…' : t('recompile')
-
   return (
     <ControlledDropdown
       className={classnames({
@@ -42,28 +33,10 @@ function PdfCompileButton() {
       })}
       id="pdf-recompile-dropdown"
     >
-      <OverlayTrigger
-        placement="bottom"
-        delayShow={500}
-        overlay={
-          <Tooltip id="tooltip-logs-toggle" className="keyboard-tooltip">
-            {t('recompile_pdf')}{' '}
-            <span className="keyboard-shortcut">({modifierKey} + Enter)</span>
-          </Tooltip>
-        }
-      >
-        <Button
-          className="btn-recompile"
-          bsStyle="success"
-          onClick={startCompile}
-          aria-label={compileButtonLabel}
-        >
-          <Icon type="refresh" spin={compiling} />
-          <span className="toolbar-hide-medium toolbar-hide-small btn-recompile-label">
-            {compileButtonLabel}
-          </span>
-        </Button>
-      </OverlayTrigger>
+      <PdfCompileButtonInner
+        startCompile={startCompile}
+        compiling={compiling}
+      />
 
       <Dropdown.Toggle
         aria-label={t('toggle_compile_options_menu')}

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

@@ -8,13 +8,16 @@ 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'
+import { DetachedSynctexControl } from './detach-synctex-control'
 
 function PdfPreviewHybridToolbar() {
-  const { detachMode } = useLayoutContext()
+  const { detachRole, detachIsLinked } = useLayoutContext()
+
+  const orphanPdfTab = !detachIsLinked && detachRole === 'detached'
 
   return (
     <ButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
-      {detachMode === 'orphan' ? (
+      {orphanPdfTab ? (
         <PdfPreviewHybridToolbarOrphanInner />
       ) : (
         <PdfPreviewHybridToolbarInner />
@@ -34,6 +37,7 @@ function PdfPreviewHybridToolbarInner() {
       <div className="toolbar-pdf-right">
         <PdfHybridCodeCheckButton />
         {!window.showPdfDetach && <PdfExpandButton />}
+        <DetachedSynctexControl />
       </div>
     </>
   )

+ 2 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-preview.js

@@ -1,9 +1,11 @@
 import PdfPreviewPane from './pdf-preview-pane'
+import useCompileTriggers from '../hooks/use-compile-triggers'
 import { memo } from 'react'
 import withErrorBoundary from '../../../infrastructure/error-boundary'
 import ErrorBoundaryFallback from './error-boundary-fallback'
 
 function PdfPreview() {
+  useCompileTriggers()
   return <PdfPreviewPane />
 }
 

+ 222 - 89
services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js

@@ -1,20 +1,107 @@
+import classNames from 'classnames'
 import { memo, useCallback, useEffect, useState } from 'react'
+import PropTypes from 'prop-types'
 import { useIdeContext } from '../../../shared/context/ide-context'
 import { useProjectContext } from '../../../shared/context/project-context'
 import { getJSON } from '../../../infrastructure/fetch-json'
 import { useCompileContext } from '../../../shared/context/compile-context'
+import { useLayoutContext } from '../../../shared/context/layout-context'
 import useScopeValue from '../../../shared/hooks/use-scope-value'
 import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
 import Icon from '../../../shared/components/icon'
 import { useTranslation } from 'react-i18next'
 import useIsMounted from '../../../shared/hooks/use-is-mounted'
 import useAbortController from '../../../shared/hooks/use-abort-controller'
+import useDetachState from '../../../shared/hooks/use-detach-state'
+import useDetachAction from '../../../shared/hooks/use-detach-action'
+
+function GoToCodeButton({
+  position,
+  syncToCode,
+  syncToCodeInFlight,
+  isDetachLayout,
+}) {
+  const { t } = useTranslation()
+  const tooltipPlacement = isDetachLayout ? 'bottom' : 'right'
+  const buttonClasses = classNames('synctex-control', {
+    'detach-synctex-control': !!isDetachLayout,
+  })
+
+  return (
+    <OverlayTrigger
+      placement={tooltipPlacement}
+      overlay={
+        <Tooltip id="sync-to-code-tooltip">
+          {t('go_to_pdf_location_in_code')}
+        </Tooltip>
+      }
+    >
+      <Button
+        bsStyle="default"
+        bsSize="xs"
+        onClick={() => syncToCode(position, 72)}
+        disabled={syncToCodeInFlight}
+        className={buttonClasses}
+        aria-label={t('go_to_pdf_location_in_code')}
+      >
+        {syncToCodeInFlight ? (
+          <Icon type="refresh" spin classes={{ icon: 'synctex-spin-icon' }} />
+        ) : (
+          <Icon type="arrow-left" classes={{ icon: 'synctex-control-icon' }} />
+        )}
+        {isDetachLayout ? <span>&nbsp;{t('show_in_code')}</span> : ''}
+      </Button>
+    </OverlayTrigger>
+  )
+}
+
+function GoToPdfButton({
+  cursorPosition,
+  syncToPdf,
+  syncToPdfInFlight,
+  isDetachLayout,
+}) {
+  const { t } = useTranslation()
+  const tooltipPlacement = isDetachLayout ? 'bottom' : 'right'
+  const buttonClasses = classNames('synctex-control', {
+    'detach-synctex-control': !!isDetachLayout,
+  })
+
+  return (
+    <OverlayTrigger
+      placement={tooltipPlacement}
+      overlay={
+        <Tooltip id="sync-to-pdf-tooltip">
+          {t('go_to_code_location_in_pdf')}
+        </Tooltip>
+      }
+    >
+      <Button
+        bsStyle="default"
+        bsSize="xs"
+        onClick={() => syncToPdf(cursorPosition)}
+        disabled={syncToPdfInFlight}
+        className={buttonClasses}
+        aria-label={t('go_to_code_location_in_pdf')}
+      >
+        {syncToPdfInFlight ? (
+          <Icon type="refresh" spin classes={{ icon: 'synctex-spin-icon' }} />
+        ) : (
+          <Icon type="arrow-right" classes={{ icon: 'synctex-control-icon' }} />
+        )}
+        {isDetachLayout ? <span>&nbsp;{t('show_in_pdf')}</span> : ''}
+      </Button>
+    </OverlayTrigger>
+  )
+}
 
 function PdfSynctexControls() {
   const ide = useIdeContext()
 
   const { _id: projectId } = useProjectContext()
 
+  const { detachRole } = useLayoutContext()
+
   const {
     clsiServerId,
     pdfUrl,
@@ -29,19 +116,35 @@ function PdfSynctexControls() {
 
   const { signal } = useAbortController()
 
+  // for detacher editor tab, which cannot access pdfUrl in a scope value in
+  // detached state
+  const [pdfExists, setPdfExists] = useDetachState(
+    'pdf-exists',
+    !!pdfUrl,
+    'detached',
+    'detacher'
+  )
+
+  useEffect(() => {
+    setPdfExists(!!pdfUrl)
+  }, [pdfUrl, setPdfExists])
+
   useEffect(() => {
     const listener = event => setCursorPosition(event.detail)
     window.addEventListener('cursor:editor:update', listener)
     return () => window.removeEventListener('cursor:editor:update', listener)
   }, [ide])
 
-  const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false)
+  const [syncToPdfInFlight, setSyncToPdfInFlight] = useDetachState(
+    'sync-to-pdf-inflight',
+    false,
+    'detached',
+    'detacher'
+  )
   const [syncToCodeInFlight, setSyncToCodeInFlight] = useState(false)
 
   const [, setSynctexError] = useScopeValue('sync_tex_error')
 
-  const { t } = useTranslation()
-
   const getCurrentFilePath = useCallback(() => {
     const docId = ide.editorManager.getCurrentDocId()
     const doc = ide.fileTreeManager.findEntityById(docId)
@@ -58,15 +161,37 @@ function PdfSynctexControls() {
     return path
   }, [ide])
 
-  const syncToPdf = useCallback(
-    cursorPosition => {
-      setSyncToPdfInFlight(true)
+  const _goToCodeLine = useCallback(
+    (file, line) => {
+      if (file) {
+        const doc = ide.fileTreeManager.findEntityByPath(file)
 
-      const params = new URLSearchParams({
-        file: getCurrentFilePath(),
-        line: cursorPosition.row + 1,
-        column: cursorPosition.column,
-      })
+        ide.editorManager.openDoc(doc, {
+          gotoLine: line,
+        })
+      } else {
+        setSynctexError(true)
+
+        window.setTimeout(() => {
+          if (isMounted.current) {
+            setSynctexError(false)
+          }
+        }, 4000)
+      }
+    },
+    [ide, isMounted, setSynctexError]
+  )
+
+  const goToCodeLine = useDetachAction(
+    'go-to-code-line',
+    _goToCodeLine,
+    'detached',
+    'detacher'
+  )
+
+  const _goToPdfLocation = useCallback(
+    params => {
+      setSyncToPdfInFlight(true)
 
       if (clsiServerId) {
         params.set('clsiserverid', clsiServerId)
@@ -87,16 +212,37 @@ function PdfSynctexControls() {
     },
     [
       clsiServerId,
+      isMounted,
       projectId,
       setHighlights,
-      getCurrentFilePath,
+      setSyncToPdfInFlight,
       signal,
-      isMounted,
     ]
   )
 
+  const goToPdfLocation = useDetachAction(
+    'go-to-pdf-location',
+    _goToPdfLocation,
+    'detacher',
+    'detached'
+  )
+
+  const syncToPdf = useCallback(
+    cursorPosition => {
+      const params = new URLSearchParams({
+        file: getCurrentFilePath(),
+        line: cursorPosition.row + 1,
+        column: cursorPosition.column,
+      })
+
+      goToPdfLocation(params)
+    },
+    [getCurrentFilePath, goToPdfLocation]
+  )
+
   const syncToCode = useCallback(
     (position, visualOffset = 0) => {
+      setSyncToCodeInFlight(true)
       // FIXME: this actually works better if it's halfway across the
       // page (or the visible part of the page). Synctex doesn't
       // always find the right place in the file when the point is at
@@ -117,8 +263,6 @@ function PdfSynctexControls() {
       }
       v += visualOffset
 
-      setSyncToCodeInFlight(true)
-
       const params = new URLSearchParams({
         page: position.page + 1,
         h: h.toFixed(2),
@@ -132,21 +276,7 @@ function PdfSynctexControls() {
       getJSON(`/project/${projectId}/sync/pdf?${params}`, { signal })
         .then(data => {
           const [{ file, line }] = data.code
-          if (file) {
-            const doc = ide.fileTreeManager.findEntityByPath(file)
-
-            ide.editorManager.openDoc(doc, {
-              gotoLine: line,
-            })
-          } else {
-            setSynctexError(true)
-
-            window.setTimeout(() => {
-              if (isMounted.current) {
-                setSynctexError(false)
-              }
-            }, 4000)
-          }
+          goToCodeLine(file, line)
         })
         .catch(error => {
           console.error(error)
@@ -157,7 +287,14 @@ function PdfSynctexControls() {
           }
         })
     },
-    [clsiServerId, ide, projectId, setSynctexError, signal, isMounted]
+    [
+      clsiServerId,
+      projectId,
+      signal,
+      isMounted,
+      setSyncToCodeInFlight,
+      goToCodeLine,
+    ]
   )
 
   useEffect(() => {
@@ -168,67 +305,63 @@ function PdfSynctexControls() {
     }
   }, [syncToCode])
 
-  if (!pdfUrl || pdfViewer === 'native') {
+  if (!pdfExists || pdfViewer === 'native') {
     return null
   }
 
-  return (
-    <>
-      <OverlayTrigger
-        placement="right"
-        overlay={
-          <Tooltip id="sync-to-pdf-tooltip">
-            {t('go_to_code_location_in_pdf')}
-          </Tooltip>
-        }
-      >
-        <Button
-          bsStyle="default"
-          bsSize="xs"
-          onClick={() => syncToPdf(cursorPosition)}
-          disabled={syncToPdfInFlight}
-          className="synctex-control"
-          aria-label={t('go_to_code_location_in_pdf')}
-        >
-          {syncToPdfInFlight ? (
-            <Icon type="refresh" spin classes={{ icon: 'synctex-spin-icon' }} />
-          ) : (
-            <Icon
-              type="arrow-right"
-              classes={{ icon: 'synctex-control-icon' }}
-            />
-          )}
-        </Button>
-      </OverlayTrigger>
-
-      <OverlayTrigger
-        placement="right"
-        overlay={
-          <Tooltip id="sync-to-code-tooltip">
-            {t('go_to_pdf_location_in_code')}
-          </Tooltip>
-        }
-      >
-        <Button
-          bsStyle="default"
-          bsSize="xs"
-          onClick={() => syncToCode(position, 72)}
-          disabled={syncToCodeInFlight}
-          className="synctex-control"
-          aria-label={t('go_to_pdf_location_in_code')}
-        >
-          {syncToCodeInFlight ? (
-            <Icon type="refresh" spin classes={{ icon: 'synctex-spin-icon' }} />
-          ) : (
-            <Icon
-              type="arrow-left"
-              classes={{ icon: 'synctex-control-icon' }}
-            />
-          )}
-        </Button>
-      </OverlayTrigger>
-    </>
-  )
+  if (detachRole === 'detacher') {
+    return (
+      <>
+        <GoToPdfButton
+          cursorPosition={cursorPosition}
+          syncToPdf={syncToPdf}
+          syncToPdfInFlight={syncToPdfInFlight}
+          isDetachLayout
+        />
+      </>
+    )
+  } else if (detachRole === 'detached') {
+    return (
+      <>
+        <GoToCodeButton
+          position={position}
+          syncToCode={syncToCode}
+          syncToCodeInFlight={syncToCodeInFlight}
+          isDetachLayout
+        />
+      </>
+    )
+  } else {
+    return (
+      <>
+        <GoToPdfButton
+          cursorPosition={cursorPosition}
+          syncToPdf={syncToPdf}
+          syncToPdfInFlight={syncToPdfInFlight}
+        />
+
+        <GoToCodeButton
+          position={position}
+          syncToCode={syncToCode}
+          syncToCodeInFlight={syncToCodeInFlight}
+        />
+      </>
+    )
+  }
 }
 
 export default memo(PdfSynctexControls)
+
+GoToCodeButton.propTypes = {
+  isDetachLayout: PropTypes.bool,
+  position: PropTypes.object.isRequired,
+  syncToCode: PropTypes.func.isRequired,
+  syncToCodeInFlight: PropTypes.bool.isRequired,
+}
+
+GoToPdfButton.propTypes = {
+  cursorPosition: PropTypes.object,
+  isDetachLayout: PropTypes.bool,
+  syncToPdf: PropTypes.func.isRequired,
+  syncToPdfInFlight: PropTypes.bool.isRequired,
+}

+ 5 - 0
services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js

@@ -4,9 +4,14 @@ import { react2angular } from 'react2angular'
 import PdfPreview from '../components/pdf-preview'
 import { rootContext } from '../../../shared/context/root-context'
 import PdfSynctexControls from '../components/pdf-synctex-controls'
+import { DetacherSynctexControl } from '../components/detach-synctex-control'
 
 App.component('pdfPreview', react2angular(rootContext.use(PdfPreview), []))
 App.component(
   'pdfSynctexControls',
   react2angular(rootContext.use(PdfSynctexControls), [])
 )
+App.component(
+  'detacherSynctexControl',
+  react2angular(rootContext.use(DetacherSynctexControl), [])
+)

+ 69 - 0
services/web/frontend/js/features/pdf-preview/hooks/use-compile-triggers.js

@@ -0,0 +1,69 @@
+import { useCallback, useEffect } from 'react'
+import getMeta from '../../../utils/meta'
+import { useCompileContext } from '../../../shared/context/compile-context'
+import { useDetachContext } from '../../../shared/context/detach-context'
+import useEventListener from '../../../shared/hooks/use-event-listener'
+import useDetachAction from '../../../shared/hooks/use-detach-action'
+import usePreviousValue from '../../../shared/hooks/use-previous-value'
+
+const showPdfDetach = getMeta('ol-showPdfDetach')
+const debugPdfDetach = getMeta('ol-debugPdfDetach')
+
+export default function useCompileTriggers() {
+  const {
+    startCompile,
+    setChangedAt,
+    cleanupCompileResult,
+    setError,
+  } = useCompileContext()
+  const { role: detachRole } = useDetachContext()
+
+  // recompile on key press
+  const startOrTriggerCompile = useDetachAction(
+    'start-compile',
+    startCompile,
+    'detacher',
+    'detached'
+  )
+  const compileHandler = useCallback(
+    event => {
+      showPdfDetach
+        ? startOrTriggerCompile(event.detail)
+        : startCompile(event.detail)
+    },
+    [startOrTriggerCompile, startCompile]
+  )
+  useEventListener('pdf:recompile', compileHandler)
+
+  // record doc changes when notified by the editor
+  const setOrTriggerChangedAt = useDetachAction(
+    'set-changed-at',
+    setChangedAt,
+    'detacher',
+    'detached'
+  )
+  const setChangedAtHandler = useCallback(() => {
+    showPdfDetach ? setOrTriggerChangedAt(Date.now()) : setChangedAt(Date.now())
+  }, [setOrTriggerChangedAt, setChangedAt])
+  useEventListener('doc:changed', setChangedAtHandler)
+  useEventListener('doc:saved', setChangedAtHandler)
+
+  // clear preview and recompile when the detach role is reset
+  const previousDetachRole = usePreviousValue(detachRole)
+  useEffect(() => {
+    if (previousDetachRole && !detachRole) {
+      if (debugPdfDetach) {
+        console.log('Recompile on reattach', { previousDetachRole, detachRole })
+      }
+      cleanupCompileResult()
+      setError()
+      startCompile()
+    }
+  }, [
+    cleanupCompileResult,
+    setError,
+    startCompile,
+    previousDetachRole,
+    detachRole,
+  ])
+}

+ 1 - 0
services/web/frontend/js/ide/editor/EditorManager.js

@@ -19,6 +19,7 @@ import './components/spellMenu'
 import './directives/aceEditor'
 import './directives/toggleSwitch'
 import './controllers/SavingNotificationController'
+import './controllers/CompileButton'
 let EditorManager
 
 export default EditorManager = (function () {

+ 9 - 0
services/web/frontend/js/ide/editor/controllers/CompileButton.js

@@ -0,0 +1,9 @@
+import App from '../../../base'
+import { react2angular } from 'react2angular'
+import { rootContext } from '../../../shared/context/root-context'
+import DetachCompileButtonWrapper from '../../../features/pdf-preview/components/detach-compile-button'
+
+App.component(
+  'editorCompileButton',
+  react2angular(rootContext.use(DetachCompileButtonWrapper))
+)

+ 11 - 31
services/web/frontend/js/shared/context/compile-context.js

@@ -59,6 +59,7 @@ CompileContext.Provider.propTypes = {
     uncompiled: PropTypes.bool,
     validationIssues: PropTypes.object,
     firstRenderDone: PropTypes.func,
+    cleanupCompileResult: PropTypes.func,
   }),
 }
 
@@ -331,19 +332,6 @@ export function CompileProvider({ children }) {
     }
   }, [error])
 
-  // recompile on key press
-  useEffect(() => {
-    const listener = event => {
-      compiler.compile(event.detail)
-    }
-
-    window.addEventListener('pdf:recompile', listener)
-
-    return () => {
-      window.removeEventListener('pdf:recompile', listener)
-    }
-  }, [compiler])
-
   // whether there has been an autocompile linting error, if syntax validation is switched on
   const autoCompileLintingError = Boolean(
     autoCompile && syntaxValidation && hasLintingError
@@ -375,25 +363,13 @@ export function CompileProvider({ children }) {
     }
   }, [compiler])
 
-  // record doc changes when notified by the editor
-  useEffect(() => {
-    const listener = event => {
-      setChangedAt(Date.now())
-    }
-
-    window.addEventListener('doc:changed', listener)
-    window.addEventListener('doc:saved', listener)
-
-    return () => {
-      window.removeEventListener('doc:changed', listener)
-      window.removeEventListener('doc:saved', listener)
-    }
-  }, [])
-
   // start a compile manually
-  const startCompile = useCallback(() => {
-    compiler.compile()
-  }, [compiler])
+  const startCompile = useCallback(
+    options => {
+      compiler.compile(options)
+    },
+    [compiler]
+  )
 
   // stop a compile manually
   const stopCompile = useCallback(() => {
@@ -453,6 +429,8 @@ export function CompileProvider({ children }) {
       uncompiled,
       validationIssues,
       firstRenderDone,
+      setChangedAt,
+      cleanupCompileResult,
     }),
     [
       autoCompile,
@@ -488,6 +466,8 @@ export function CompileProvider({ children }) {
       uncompiled,
       validationIssues,
       firstRenderDone,
+      setChangedAt,
+      cleanupCompileResult,
     ]
   )
 

+ 41 - 22
services/web/frontend/js/shared/context/layout-context.js

@@ -4,18 +4,27 @@ import {
   useCallback,
   useMemo,
   useEffect,
+  useRef,
 } from 'react'
 import PropTypes from 'prop-types'
+import { debounce } from 'lodash'
 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'
+import getMeta from '../../utils/meta'
+
+const debugPdfDetach = getMeta('ol-debugPdfDetach')
 
 export const LayoutContext = createContext()
 
 LayoutContext.Provider.propTypes = {
   value: PropTypes.shape({
+    reattach: PropTypes.func.isRequired,
+    detach: PropTypes.func.isRequired,
+    detachIsLinked: PropTypes.bool,
+    detachRole: PropTypes.string,
+    changeLayout: PropTypes.func.isRequired,
     view: PropTypes.string,
     setView: PropTypes.func.isRequired,
     chatIsOpen: PropTypes.bool,
@@ -88,39 +97,49 @@ export function LayoutProvider({ children }) {
     [setPdfLayout, setView]
   )
 
+  // helper to avoid changing layout multiple times in rapid succession. This is
+  // especially useful for calling `changeLayout` as a side-effect. Calling
+  // `changeLayout` multiple times on page load cause layout rendering issues do
+  // to timming clash with Angular.
+  const debouncedChangeLayout = useRef(
+    debounce((newLayout, newView) => changeLayout(newLayout, newView), 1000, {
+      leading: true,
+    })
+  ).current
+
   const {
     reattach,
     detach,
-    mode: detachMode,
+    isLinking: detachIsLinking,
+    isLinked: detachIsLinked,
     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
+    if (debugPdfDetach) {
+      console.log('Layout Effect', {
+        detachRole,
+        detachIsLinking,
+        detachIsLinked,
+      })
+    }
+
+    if (detachRole !== 'detacher') return // not in a PDF detacher layout
+
+    if (detachIsLinking || detachIsLinked) {
+      // the tab is linked to a detached tab (or about to be linked); show
+      // editor only
+      debouncedChangeLayout('flat', 'editor')
+    } else {
+      debouncedChangeLayout('sideBySide')
     }
-  }, [detachMode, previousDetachMode, changeLayout])
+  }, [detachRole, detachIsLinking, detachIsLinked, debouncedChangeLayout])
 
   const value = useMemo(
     () => ({
       reattach,
       detach,
-      detachMode,
+      detachIsLinked,
       detachRole,
       changeLayout,
       chatIsOpen,
@@ -138,7 +157,7 @@ export function LayoutProvider({ children }) {
     [
       reattach,
       detach,
-      detachMode,
+      detachIsLinked,
       detachRole,
       changeLayout,
       chatIsOpen,

+ 55 - 0
services/web/frontend/js/shared/hooks/use-detach-action.js

@@ -0,0 +1,55 @@
+import { useCallback, useEffect } from 'react'
+import { useDetachContext } from '../context/detach-context'
+import getMeta from '../../utils/meta'
+
+const debugPdfDetach = getMeta('ol-debugPdfDetach')
+
+export default function useDetachAction(
+  actionName,
+  actionFunction,
+  senderRole,
+  targetRole
+) {
+  const {
+    role,
+    broadcastEvent,
+    addEventHandler,
+    deleteEventHandler,
+  } = useDetachContext()
+
+  const eventName = `action-${actionName}`
+
+  const triggerFn = useCallback(
+    (...args) => {
+      if (role === senderRole) {
+        broadcastEvent(eventName, { args })
+      } else {
+        actionFunction(...args)
+      }
+    },
+    [role, senderRole, eventName, actionFunction, broadcastEvent]
+  )
+
+  const handleActionEvent = useCallback(
+    message => {
+      if (message.event !== eventName) {
+        return
+      }
+      if (role !== targetRole) {
+        return
+      }
+      if (debugPdfDetach) {
+        console.log(`Do ${actionFunction.name} on event ${eventName}`)
+      }
+      actionFunction(...message.data.args)
+    },
+    [role, targetRole, eventName, actionFunction]
+  )
+
+  useEffect(() => {
+    addEventHandler(handleActionEvent)
+    return () => deleteEventHandler(handleActionEvent)
+  }, [addEventHandler, deleteEventHandler, handleActionEvent])
+
+  return triggerFn
+}

+ 60 - 22
services/web/frontend/js/shared/hooks/use-detach-layout.js

@@ -1,10 +1,15 @@
-import { useCallback, useState, useEffect } from 'react'
+import { useCallback, useState, useEffect, useRef } from 'react'
 import { useDetachContext } from '../context/detach-context'
 import getMeta from '../../utils/meta'
 import { buildUrlWithDetachRole } from '../utils/url-helper'
+import * as eventTracking from '../../infrastructure/event-tracking'
+import usePreviousValue from './use-previous-value'
 
 const debugPdfDetach = getMeta('ol-debugPdfDetach')
 
+const LINKING_TIMEOUT = 60000
+const RELINK_TIMEOUT = 10000
+
 export default function useDetachLayout() {
   const {
     role,
@@ -14,50 +19,81 @@ export default function useDetachLayout() {
     deleteEventHandler,
   } = useDetachContext()
 
-  const [mode, setMode] = useState(() => {
-    if (role === 'detacher') {
-      return 'detaching'
+  // isLinking: when the tab expects to be linked soon (e.g. on detach)
+  const [isLinking, setIsLinking] = useState(false)
+
+  // isLinked: when the tab is linked to another tab (of different role)
+  const [isLinked, setIsLinked] = useState(false)
+
+  const uiTimeoutRef = useRef()
+
+  useEffect(() => {
+    if (debugPdfDetach) {
+      console.log('Effect', { isLinked })
+    }
+    setIsLinking(false)
+  }, [isLinked, setIsLinking])
+
+  useEffect(() => {
+    if (uiTimeoutRef.current) {
+      clearTimeout(uiTimeoutRef.current)
     }
-    if (role === 'detached') {
-      return 'orphan'
+    if (role === 'detacher' && isLinked === false) {
+      // the detacher tab either a) disconnected from its detached tab(s), b)is
+      // loading and no detached tab(s) is connected yet or c) is detaching and
+      // waiting for the detached tab to connect.  Start a timeout to put
+      // the tab back in non-detacher role in case no detached tab are connected
+      uiTimeoutRef.current = setTimeout(
+        () => {
+          setRole(null)
+        },
+        isLinking ? LINKING_TIMEOUT : RELINK_TIMEOUT
+      )
     }
-  })
+  }, [role, isLinking, isLinked, setRole])
 
   useEffect(() => {
     if (debugPdfDetach) {
-      console.log('Effect', { mode })
+      console.log('Effect', { isLinking })
+    }
+  }, [isLinking])
+
+  const previousRole = usePreviousValue(role)
+  useEffect(() => {
+    if (previousRole && !role) {
+      eventTracking.sendMB('project-layout-reattached')
     }
-  }, [mode])
+  }, [previousRole, role])
 
   const reattach = useCallback(() => {
     broadcastEvent('reattach')
     setRole(null)
-    setMode(null)
-  }, [setRole, setMode, broadcastEvent])
+    setIsLinked(false)
+  }, [setRole, setIsLinked, broadcastEvent])
 
   const detach = useCallback(() => {
     setRole('detacher')
-    setMode('detaching')
+    setIsLinking(true)
 
     window.open(buildUrlWithDetachRole('detached'), '_blank')
-  }, [setRole, setMode])
+  }, [setRole, setIsLinking])
 
   const handleEventForDetacherFromDetached = useCallback(
     message => {
       switch (message.event) {
         case 'connected':
           broadcastEvent('up')
-          setMode('detacher')
+          setIsLinked(true)
           break
         case 'up':
-          setMode('detacher')
+          setIsLinked(true)
           break
         case 'closed':
-          setMode(null)
+          setIsLinked(false)
           break
       }
     },
-    [setMode, broadcastEvent]
+    [setIsLinked, broadcastEvent]
   )
 
   const handleEventForDetachedFromDetacher = useCallback(
@@ -65,20 +101,21 @@ export default function useDetachLayout() {
       switch (message.event) {
         case 'connected':
           broadcastEvent('up')
-          setMode('detached')
+          setIsLinked(true)
           break
         case 'up':
-          setMode('detached')
+          setIsLinked(true)
           break
         case 'closed':
-          setMode('orphan')
+          setIsLinked(false)
           break
         case 'reattach':
+          setIsLinked(false) // set as unlinked, in case closing is not allowed
           window.close()
           break
       }
     },
-    [setMode, broadcastEvent]
+    [setIsLinked, broadcastEvent]
   )
 
   const handleEventFromSelf = useCallback(
@@ -124,7 +161,8 @@ export default function useDetachLayout() {
   return {
     reattach,
     detach,
-    mode,
+    isLinked,
+    isLinking,
     role,
   }
 }

+ 52 - 0
services/web/frontend/js/shared/hooks/use-detach-state.js

@@ -0,0 +1,52 @@
+import { useEffect, useState, useCallback } from 'react'
+import { useDetachContext } from '../context/detach-context'
+import getMeta from '../../utils/meta'
+
+const debugPdfDetach = getMeta('ol-debugPdfDetach')
+
+export default function useDetachState(
+  key,
+  defaultValue,
+  senderRole,
+  targetRole
+) {
+  const [value, setValue] = useState(defaultValue)
+
+  const {
+    role,
+    broadcastEvent,
+    addEventHandler,
+    deleteEventHandler,
+  } = useDetachContext()
+
+  const eventName = `state-${key}`
+
+  useEffect(() => {
+    if (role === senderRole) {
+      broadcastEvent(eventName, { value })
+    }
+  }, [role, senderRole, eventName, value, broadcastEvent])
+
+  const handleStateEvent = useCallback(
+    message => {
+      if (message.event !== eventName) {
+        return
+      }
+      if (role !== targetRole) {
+        return
+      }
+      if (debugPdfDetach) {
+        console.log(`Set ${message.data.value} for ${eventName}`)
+      }
+      setValue(message.data.value)
+    },
+    [role, targetRole, eventName, setValue]
+  )
+
+  useEffect(() => {
+    addEventHandler(handleStateEvent)
+    return () => deleteEventHandler(handleStateEvent)
+  }, [addEventHandler, deleteEventHandler, handleStateEvent])
+
+  return [value, setValue]
+}

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

@@ -122,6 +122,21 @@
   overflow: hidden;
   position: relative;
   z-index: 10; // Prevent track changes showing over toolbar
+
+  .btn-recompile-group {
+    margin-right: -5px;
+    border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base;
+    margin-left: 6px;
+    &.btn-recompile-group-has-changes {
+      // prettier-ignore
+      #gradient > .striped(@color: rgba(255, 255, 255, 0.2), @angle: -45deg);
+      background-size: @stripe-width @stripe-width;
+      .animation(pdf-toolbar-stripes 2s linear infinite);
+    }
+    .btn-recompile {
+      border-radius: @btn-border-radius-base 0 0 @btn-border-radius-base;
+    }
+  }
 }
 
 .loading-screen {

+ 5 - 3
services/web/frontend/stylesheets/app/editor/pdf.less

@@ -50,7 +50,7 @@
 
 .toolbar-pdf-orphan {
   justify-content: center;
-  color: white;
+  color: @toolbar-btn-color;
   .btn {
     margin-left: @margin-xs;
   }
@@ -67,7 +67,7 @@
 }
 
 .toolbar-pdf-hybrid {
-  .btn:not(.btn-recompile):not(.btn-orphan) {
+  .btn:not(.btn-recompile):not(.btn-orphan):not(.detach-synctex-control) {
     display: inline-block;
     color: @toolbar-btn-color;
     background-color: transparent;
@@ -357,7 +357,7 @@
   top: 68px;
 }
 
-.synctex-control {
+.synctex-control:not(.detach-synctex-control) {
   @ol-synctex-control-size: 24px;
   align-items: center;
   display: flex;
@@ -379,7 +379,9 @@
     opacity: 1;
     background-color: fade(@btn-default-bg, 60%);
   }
+}
 
+.synctex-control {
   > .synctex-control-icon {
     display: inline-block;
     font: normal normal normal 14px/1 FontAwesome;

+ 4 - 3
services/web/locales/en.json

@@ -1531,9 +1531,10 @@
   "pdf_only_hide_editor": "PDF only <0>(hide editor)</0>",
   "selected": "Selected",
   "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",
+  "pdf_in_separate_tab": "PDF in separate tab",
   "tab_no_longer_connected": "This tab is no longer connected with the editor",
   "redirect_to_editor": "Redirect to editor",
-  "layout_processing": "Layout processing"
+  "layout_processing": "Layout processing",
+  "show_in_code": "Show in code",
+  "show_in_pdf": "Show in PDF"
 }

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

@@ -1,20 +1,26 @@
-import { render, screen } from '@testing-library/react'
+import sinon from 'sinon'
+import { fireEvent, screen } from '@testing-library/react'
 import LayoutDropdownButton from '../../../../../frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button'
+import { renderWithEditorContext } from '../../../helpers/render-with-context'
 
 describe('<LayoutDropdownButton />', function () {
-  const defaultProps = {
-    reattach: () => {},
-    detach: () => {},
-    handleChangeLayout: () => {},
-    detachMode: undefined,
-    detachRole: undefined,
+  let openStub
+  const defaultUi = {
     pdfLayout: 'flat',
     view: 'pdf',
   }
 
+  beforeEach(function () {
+    openStub = sinon.stub(window, 'open')
+  })
+
+  afterEach(function () {
+    openStub.restore()
+  })
+
   it('should mark current layout option as selected', function () {
     // Selected is aria-label, visually we show a checkmark
-    render(<LayoutDropdownButton {...defaultProps} />)
+    renderWithEditorContext(<LayoutDropdownButton />, { ui: defaultUi })
     screen.getByRole('menuitem', {
       name: 'Editor & PDF',
     })
@@ -25,35 +31,19 @@ describe('<LayoutDropdownButton />', function () {
       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',
+      name: 'PDF in separate tab',
     })
   })
 
   it('should show processing when detaching', function () {
-    const detachedProps = Object.assign({}, defaultProps, {
-      detachMode: 'detaching',
-      detachRole: 'detacher',
-      view: 'editor',
+    renderWithEditorContext(<LayoutDropdownButton />, {
+      ui: { ...defaultUi, view: 'editor' },
     })
 
-    render(<LayoutDropdownButton {...detachedProps} />)
+    const menuItem = screen.getByRole('menuitem', {
+      name: 'PDF in separate tab',
+    })
+    fireEvent.click(menuItem)
 
     screen.getByText('Layout processing')
   })

+ 7 - 9
services/web/test/frontend/helpers/render-with-context.js

@@ -31,6 +31,7 @@ export function EditorProviders({
   scope,
   children,
   rootFolder,
+  ui = { view: null, pdfLayout: 'flat', chatOpen: true },
 }) {
   window.user = user || window.user
   window.gitBridgePublicBaseUrl = 'git.overleaf.test'
@@ -51,10 +52,7 @@ export function EditorProviders({
     rootFolder: rootFolder || {
       children: [],
     },
-    ui: {
-      chatOpen: true,
-      pdfLayout: 'flat',
-    },
+    ui,
     $watch: (path, callback) => {
       callback(get($scope, path))
       return () => null
@@ -100,11 +98,11 @@ export function EditorProviders({
         <UserProvider>
           <ProjectProvider>
             <EditorProvider settings={{}}>
-              <CompileProvider>
-                <DetachProvider>
-                  <LayoutProvider>{children}</LayoutProvider>
-                </DetachProvider>
-              </CompileProvider>
+              <DetachProvider>
+                <LayoutProvider>
+                  <CompileProvider>{children}</CompileProvider>
+                </LayoutProvider>
+              </DetachProvider>
             </EditorProvider>
           </ProjectProvider>
         </UserProvider>