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

PDF Detach

GitOrigin-RevId: f69d8a87d1ba2115ad496a719106dfc7707a6ed5
This commit is contained in:
Timothée Alby 2021-11-15 17:33:57 +01:00 committed by Copybot
parent c73894e56b
commit 8ca159b4b9
32 changed files with 762 additions and 147 deletions

View file

@ -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()
}

View file

@ -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'],

View file

@ -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)

View file

@ -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()

View file

@ -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": "",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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,

View file

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

View file

@ -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 }
}

View file

@ -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,
}
}

View file

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

View file

@ -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
}

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}
}
}
}

View file

@ -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"
}

View file

@ -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",

View file

@ -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",

View file

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

View file

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

View file

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

View file

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