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

PDF Detach Updates

GitOrigin-RevId: c09c4fe37a922b041cfa1376e110a264a88177c8
This commit is contained in:
Timothée Alby 2021-11-30 15:54:14 +01:00 committed by Copybot
parent 8b507427c2
commit f6fc3d468c
25 changed files with 825 additions and 334 deletions

View file

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

View file

@ -59,11 +59,6 @@ const EditorNavigationToolbarRoot = React.memo(
} = useEditorContext(editorContextPropTypes)
const {
reattach,
detach,
detachMode,
detachRole,
changeLayout,
chatIsOpen,
setChatIsOpen,
reviewPanelOpen,
@ -98,13 +93,6 @@ const EditorNavigationToolbarRoot = React.memo(
setView(view === 'pdf' ? 'editor' : 'pdf')
}, [view, setView])
const handleChangeLayout = useCallback(
(newLayout, newView) => {
changeLayout(newLayout, newView)
},
[changeLayout]
)
const openShareModal = useCallback(() => {
openShareProjectModal(isProjectOwner)
}, [openShareProjectModal, isProjectOwner])
@ -127,14 +115,9 @@ const EditorNavigationToolbarRoot = React.memo(
// `loading ? null : <ToolbarHeader/>` causes UI glitches
return (
<ToolbarHeader
reattach={reattach}
detach={detach}
detachMode={detachMode}
detachRole={detachRole}
style={loading ? { display: 'none' } : {}}
cobranding={cobranding}
onShowLeftMenuClick={onShowLeftMenuClick}
handleChangeLayout={handleChangeLayout}
chatIsOpen={chatIsOpen}
unreadMessageCount={unreadMessageCount}
toggleChatOpen={toggleChatOpen}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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