diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index e9ac35c36c..e1d7e9fa9a 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/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()
}
diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js
index f0115526f8..ffd4910747 100644
--- a/services/web/app/src/router.js
+++ b/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'],
diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug
index 5e79e1a1c1..19c345e152 100644
--- a/services/web/app/views/project/editor.pug
+++ b/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)
diff --git a/services/web/app/views/project/editor/main.pug b/services/web/app/views/project/editor/main.pug
new file mode 100644
index 0000000000..7e3f33bfda
--- /dev/null
+++ b/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()
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 68c258d4b7..4ceaa46720 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/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": "",
diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.js
index 70523485c6..7fe6d51fa3 100644
--- a/services/web/frontend/js/features/editor-navigation-toolbar/components/chat-toggle-button.js
+++ b/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
-
-
- {hasUnreadMessages ? (
- {unreadMessageCount}
- ) : null}
- {t('chat')}
-
+
+
+
)
}
diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js
index 729cbb4fc6..734c2113e1 100644
--- a/services/web/frontend/js/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root.js
+++ b/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 : ` causes UI glitches
return (
-
- {t('history')}
-
+
)
}
diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js
index a971a98be2..64404de7de 100644
--- a/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js
+++ b/services/web/frontend/js/features/editor-navigation-toolbar/components/layout-dropdown-button.js
@@ -19,58 +19,107 @@ function IconCheckmark({ iconFor, pdfLayout, view }) {
return
}
-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 (
-
-
-
- {t('layout')}
-
-
-
-
-
-
-
-
+ <>
+ {detachMode === 'detaching' && (
+
+ {t('layout_processing')}
+
+ )}
+
+
+ {detachMode === 'detaching' ? (
+
+ ) : (
+
+ )}
+ {t('layout')}
+
+
+
+
+
+
+
+
+
+
+
+
+ {detachRole === 'detacher' ? (
+
+ ) : (
+
+ )}
+
+
+ >
)
}
@@ -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,
}
diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.js
index 39e03a033a..16b9d449aa 100644
--- a/services/web/frontend/js/features/editor-navigation-toolbar/components/share-project-button.js
+++ b/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
-
-
- {t('share')}
-
+
)
}
diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js
index 7397ea0d83..a94dd0c820 100644
--- a/services/web/frontend/js/features/editor-navigation-toolbar/components/toolbar-header.js
+++ b/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({
)}
- {pdfButtonIsVisible && (
+ {!window.showPdfDetach && pdfButtonIsVisible && (
@@ -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,
diff --git a/services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.js b/services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.js
index 3cbdb4916a..a1d5111c70 100644
--- a/services/web/frontend/js/features/editor-navigation-toolbar/components/track-changes-toggle-button.js
+++ b/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
-
-
- {t('review')}
-
+
)
}
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js
new file mode 100644
index 0000000000..3e545aed2b
--- /dev/null
+++ b/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 (
+
+ )
+}
+
+export default memo(PdfOrphanRefreshButton)
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js
index f9ded9f3e4..2855ea6043 100644
--- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar.js
+++ b/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 (
+ {detachMode === 'orphan' ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+function PdfPreviewHybridToolbarInner() {
+ return (
+ <>
@@ -16,9 +33,21 @@ function PdfPreviewHybridToolbar() {
-
+ {!window.showPdfDetach &&
}
-
+ >
+ )
+}
+
+function PdfPreviewHybridToolbarOrphanInner() {
+ const { t } = useTranslation()
+ return (
+ <>
+
+ {t('tab_no_longer_connected')}
+
+
+ >
)
}
diff --git a/services/web/frontend/js/ide.js b/services/web/frontend/js/ide.js
index 71e5626fc9..7b00faf8dc 100644
--- a/services/web/frontend/js/ide.js
+++ b/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) {
diff --git a/services/web/frontend/js/shared/context/detach-context.js b/services/web/frontend/js/shared/context/detach-context.js
new file mode 100644
index 0000000000..fdb951fbf1
--- /dev/null
+++ b/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 (
+ {children}
+ )
+}
+
+DetachProvider.propTypes = {
+ children: PropTypes.any,
+}
+
+export function useDetachContext(propTypes) {
+ const data = useContext(DetachContext)
+ PropTypes.checkPropTypes(propTypes, data, 'data', 'DetachContext.Provider')
+ return data
+}
diff --git a/services/web/frontend/js/shared/context/layout-context.js b/services/web/frontend/js/shared/context/layout-context.js
index 5a76550b6c..69390822a5 100644
--- a/services/web/frontend/js/shared/context/layout-context.js
+++ b/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,
diff --git a/services/web/frontend/js/shared/context/root-context.js b/services/web/frontend/js/shared/context/root-context.js
index eb35c981d7..d47f78fb37 100644
--- a/services/web/frontend/js/shared/context/root-context.js
+++ b/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 }) {
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
diff --git a/services/web/frontend/js/shared/hooks/use-callback-handlers.js b/services/web/frontend/js/shared/hooks/use-callback-handlers.js
new file mode 100644
index 0000000000..954ffbdb84
--- /dev/null
+++ b/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 }
+}
diff --git a/services/web/frontend/js/shared/hooks/use-detach-layout.js b/services/web/frontend/js/shared/hooks/use-detach-layout.js
new file mode 100644
index 0000000000..8a18ed88d6
--- /dev/null
+++ b/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,
+ }
+}
diff --git a/services/web/frontend/js/shared/hooks/use-previous-value.js b/services/web/frontend/js/shared/hooks/use-previous-value.js
new file mode 100644
index 0000000000..251829a466
--- /dev/null
+++ b/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
+}
diff --git a/services/web/frontend/js/shared/utils/url-helper.js b/services/web/frontend/js/shared/utils/url-helper.js
new file mode 100644
index 0000000000..4a654896f8
--- /dev/null
+++ b/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
+}
diff --git a/services/web/frontend/stylesheets/app/editor/pdf.less b/services/web/frontend/stylesheets/app/editor/pdf.less
index 919094b3ee..99adf74aaa 100644
--- a/services/web/frontend/stylesheets/app/editor/pdf.less
+++ b/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;
diff --git a/services/web/frontend/stylesheets/app/editor/review-panel.less b/services/web/frontend/stylesheets/app/editor/review-panel.less
index 405d383a91..a01f095f7f 100644
--- a/services/web/frontend/stylesheets/app/editor/review-panel.less
+++ b/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;
diff --git a/services/web/frontend/stylesheets/app/editor/toolbar.less b/services/web/frontend/stylesheets/app/editor/toolbar.less
index 92651b7103..073962f7ba 100644
--- a/services/web/frontend/stylesheets/app/editor/toolbar.less
+++ b/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;
+ }
+ }
+ }
+ }
}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index ac351c3782..d7cb346db9 100644
--- a/services/web/locales/en.json
+++ b/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"
}
diff --git a/services/web/package-lock.json b/services/web/package-lock.json
index 7c63fc506b..a8b35a2c91 100644
--- a/services/web/package-lock.json
+++ b/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",
diff --git a/services/web/package.json b/services/web/package.json
index 4ceb8eb392..7707359ba6 100644
--- a/services/web/package.json
+++ b/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",
diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js b/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js
index 502318dcf6..d229c68776 100644
--- a/services/web/test/frontend/features/editor-navigation-toolbar/components/layout-dropdown-button.test.js
+++ b/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('', 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()
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()
+
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()
+
+ screen.getByText('Layout processing')
})
})
diff --git a/services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.js b/services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.js
index 69decc31c8..40cb16b85d 100644
--- a/services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.js
+++ b/services/web/test/frontend/features/editor-navigation-toolbar/components/toolbar-header.test.js
@@ -21,6 +21,8 @@ describe('', function () {
handleChangeLayout: () => {},
pdfLayout: '',
view: '',
+ reattach: () => {},
+ detach: () => {},
}
describe('cobranding logo', function () {
diff --git a/services/web/test/frontend/features/outline/components/outline-pane.test.js b/services/web/test/frontend/features/outline/components/outline-pane.test.js
index 42faa99e7d..66b44f8f4c 100644
--- a/services/web/test/frontend/features/outline/components/outline-pane.test.js
+++ b/services/web/test/frontend/features/outline/components/outline-pane.test.js
@@ -22,6 +22,7 @@ describe('', function () {
value: {
getItem: sinon.stub().returns(null),
setItem: sinon.stub(),
+ removeItem: sinon.stub(),
},
})
})
diff --git a/services/web/test/frontend/helpers/render-with-context.js b/services/web/test/frontend/helpers/render-with-context.js
index 16225701cd..aaac38df0a 100644
--- a/services/web/test/frontend/helpers/render-with-context.js
+++ b/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({
- {children}
+
+ {children}
+