From 8ca159b4b9849a26c2a2043bc4a17ba9afed7701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Alby?= Date: Mon, 15 Nov 2021 17:33:57 +0100 Subject: [PATCH] Merge pull request #5797 from overleaf/ta-pdf-detach PDF Detach GitOrigin-RevId: f69d8a87d1ba2115ad496a719106dfc7707a6ed5 --- .../src/Features/Project/ProjectController.js | 14 ++ services/web/app/src/router.js | 2 +- services/web/app/views/project/editor.pug | 58 ++----- .../web/app/views/project/editor/main.pug | 47 ++++++ .../web/frontend/extracted-translations.json | 5 + .../components/chat-toggle-button.js | 25 +-- .../editor-navigation-toolbar-root.js | 8 + .../components/history-toggle-button.js | 11 +- .../components/layout-dropdown-button.js | 151 ++++++++++++------ .../components/share-project-button.js | 11 +- .../components/toolbar-header.js | 14 +- .../components/track-changes-toggle-button.js | 17 +- .../components/pdf-orphan-refresh-button.js | 25 +++ .../components/pdf-preview-hybrid-toolbar.js | 33 +++- services/web/frontend/js/ide.js | 15 ++ .../js/shared/context/detach-context.js | 118 ++++++++++++++ .../js/shared/context/layout-context.js | 46 +++++- .../js/shared/context/root-context.js | 13 +- .../js/shared/hooks/use-callback-handlers.js | 33 ++++ .../js/shared/hooks/use-detach-layout.js | 130 +++++++++++++++ .../js/shared/hooks/use-previous-value.js | 9 ++ .../frontend/js/shared/utils/url-helper.js | 11 ++ .../frontend/stylesheets/app/editor/pdf.less | 11 +- .../stylesheets/app/editor/review-panel.less | 2 +- .../stylesheets/app/editor/toolbar.less | 36 ++++- services/web/locales/en.json | 7 +- services/web/package-lock.json | 5 + services/web/package.json | 1 + .../components/layout-dropdown-button.test.js | 43 ++++- .../components/toolbar-header.test.js | 2 + .../outline/components/outline-pane.test.js | 1 + .../frontend/helpers/render-with-context.js | 5 +- 32 files changed, 762 insertions(+), 147 deletions(-) create mode 100644 services/web/app/views/project/editor/main.pug create mode 100644 services/web/frontend/js/features/pdf-preview/components/pdf-orphan-refresh-button.js create mode 100644 services/web/frontend/js/shared/context/detach-context.js create mode 100644 services/web/frontend/js/shared/hooks/use-callback-handlers.js create mode 100644 services/web/frontend/js/shared/hooks/use-detach-layout.js create mode 100644 services/web/frontend/js/shared/hooks/use-previous-value.js create mode 100644 services/web/frontend/js/shared/utils/url-helper.js 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')} - - - {t('layout')} - handleChangeLayout('sideBySide')}> - - - {t('editor_and_pdf')} - - handleChangeLayout('flat', 'editor')} - className="menu-item-with-svg" - > - - - , - ]} - /> - - handleChangeLayout('flat', 'pdf')} - className="menu-item-with-svg" - > - - - , - ]} - /> - - - + <> + {detachMode === 'detaching' && ( +
+ {t('layout_processing')} +
+ )} + + + {detachMode === 'detaching' ? ( + + ) : ( + + )} + {t('layout')} + + + {t('layout')} + + handleChangeLayout('sideBySide')} + > + + + {t('editor_and_pdf')} + + + handleChangeLayout('flat', 'editor')} + className="menu-item-with-svg" + > + + + , + ]} + /> + + + handleChangeLayout('flat', 'pdf')} + className="menu-item-with-svg" + > + + + , + ]} + /> + + + + + {detachRole === 'detacher' ? ( + reattach()}> + + {t('bring_pdf_back_to_tab')} + + ) : ( + detach()}> + + {t('open_pdf_in_new_tab')} + + )} + + + ) } @@ -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)", "pdf_only_hide_editor": "PDF only <0>(hide editor)", "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} +