Add React version of the PDF preview pane (#5135)

GitOrigin-RevId: fcc88a362c3e97c9fddf85d47c3a83a0a0b89432
This commit is contained in:
Alf Eaton 2021-09-30 12:29:25 +01:00 committed by Copybot
parent 91c31b2523
commit 73bc3418a2
50 changed files with 3678 additions and 47 deletions

View file

@ -179,8 +179,9 @@ block append meta
meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl) meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl)
//- Set base path for Ace scripts loaded on demand/workers and don't use cdn //- Set base path for Ace scripts loaded on demand/workers and don't use cdn
meta(name="ol-aceBasePath" content="/js/" + lib('ace')) meta(name="ol-aceBasePath" content="/js/" + lib('ace'))
//- Set path for PDFjs CMaps //- Set path for PDFjs CMaps and images
meta(name="ol-pdfCMapsPath" content="/js/cmaps/") meta(name="ol-pdfCMapsPath" content="/js/cmaps/")
meta(name="ol-pdfImageResourcesPath" content="/images/")
//- enable doc hash checking for all projects //- enable doc hash checking for all projects
//- used in public/js/libs/sharejs.js //- used in public/js/libs/sharejs.js
meta(name="ol-useShareJsHash" data-type="boolean" content=true) meta(name="ol-useShareJsHash" data-type="boolean" content=true)

View file

@ -18,14 +18,17 @@ div.full-size(
include ./editor-no-symbol-palette include ./editor-no-symbol-palette
.ui-layout-east .ui-layout-east
div(ng-if="ui.pdfLayout == 'sideBySide'") // The pdf-preview component needs to always be rendered, even when the editor is in "full-width" mode and it's not visible.
if showNewPdfPreview // It doesn't recompile while hidden, due to the ui.pdfHidden flag, but maintains its state for when it's shown again.
pdf-preview-pane() if showNewPdfPreview
else div(ng-show="ui.pdfLayout == 'sideBySide'")
pdf-preview()
else
div(ng-if="ui.pdfLayout == 'sideBySide'")
include ./pdf include ./pdf
.ui-layout-resizer-controls.synctex-controls( .ui-layout-resizer-controls.synctex-controls(
ng-show="!!pdf.url && settings.pdfViewer == 'pdfjs'" ng-show="!!pdf.url && settings.pdfViewer !== 'native'"
ng-controller="PdfSynctexController" ng-controller="PdfSynctexController"
) )
a.btn.btn-default.btn-xs.synctex-control.synctex-control-goto-pdf( a.btn.btn-default.btn-xs.synctex-control.synctex-control-goto-pdf(
@ -52,7 +55,7 @@ div.full-size(
ng-show="ui.view == 'pdf'" ng-show="ui.view == 'pdf'"
) )
if showNewPdfPreview if showNewPdfPreview
pdf-preview-pane() pdf-preview()
else else
include ./pdf include ./pdf

View file

@ -0,0 +1,27 @@
import Icon from '../../../shared/components/icon'
import { Button } from 'react-bootstrap'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
function PdfClearCacheButton() {
const { compiling, clearCache, clearingCache } = usePdfPreviewContext()
const { t } = useTranslation()
return (
<Button
bsSize="small"
bsStyle="danger"
className="logs-pane-actions-clear-cache"
onClick={clearCache}
disabled={clearingCache || compiling}
>
{clearingCache ? <Icon type="refresh" spin /> : <Icon type="trash-o" />}
&nbsp;
<span>{t('clear_cached_files')}</span>
</Button>
)
}
export default memo(PdfClearCacheButton)

View file

@ -0,0 +1,118 @@
import { Button, Dropdown, MenuItem } from 'react-bootstrap'
import Icon from '../../../shared/components/icon'
import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import { useTranslation } from 'react-i18next'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { memo, useCallback } from 'react'
import classnames from 'classnames'
function PdfCompileButton() {
const {
autoCompile,
compiling,
draft,
hasChanges,
recompile,
setAutoCompile,
setDraft,
setStopOnValidationError,
stopCompile,
stopOnValidationError,
recompileFromScratch,
} = usePdfPreviewContext()
const { t } = useTranslation()
const compileButtonLabel = compiling ? t('compiling') + '…' : t('recompile')
const startCompile = useCallback(() => {
recompile()
}, [recompile])
return (
<ControlledDropdown
className={classnames({
'toolbar-item': true,
'btn-recompile-group': true,
'btn-recompile-group-has-changes': hasChanges,
})}
id="pdf-recompile-dropdown"
>
<Button
className="btn-recompile"
bsStyle="success"
onClick={compiling ? stopCompile : startCompile}
aria-label={compileButtonLabel}
>
<Icon type="refresh" spin={compiling} />
<span className="toolbar-text toolbar-hide-medium toolbar-hide-small">
{compileButtonLabel}
</span>
</Button>
<Dropdown.Toggle
aria-label={t('toggle_compile_options_menu')}
className="btn-recompile"
bsStyle="success"
/>
<Dropdown.Menu>
<MenuItem header>{t('auto_compile')}</MenuItem>
<MenuItem onSelect={() => setAutoCompile(true)}>
<Icon type={autoCompile ? 'check' : ''} modifier="fw" />
{t('on')}
</MenuItem>
<MenuItem onSelect={() => setAutoCompile(false)}>
<Icon type={!autoCompile ? 'check' : ''} modifier="fw" />
{t('off')}
</MenuItem>
<MenuItem header>{t('compile_mode')}</MenuItem>
<MenuItem onSelect={() => setDraft(false)}>
<Icon type={!draft ? 'check' : ''} modifier="fw" />
{t('normal')}
</MenuItem>
<MenuItem onSelect={() => setDraft(true)}>
<Icon type={draft ? 'check' : ''} modifier="fw" />
{t('fast')} <span className="subdued">[draft]</span>
</MenuItem>
<MenuItem header>Syntax Checks</MenuItem>
<MenuItem onSelect={() => setStopOnValidationError(true)}>
<Icon type={stopOnValidationError ? 'check' : ''} modifier="fw" />
{t('stop_on_validation_error')}
</MenuItem>
<MenuItem onSelect={() => setStopOnValidationError(false)}>
<Icon type={!stopOnValidationError ? 'check' : ''} modifier="fw" />
{t('ignore_validation_errors')}
</MenuItem>
<MenuItem divider />
<MenuItem
onSelect={stopCompile}
disabled={!compiling}
aria-disabled={!compiling}
>
{t('stop_compile')}
</MenuItem>
<MenuItem
onSelect={recompileFromScratch}
disabled={compiling}
aria-disabled={compiling}
>
{t('recompile_from_scratch')}
</MenuItem>
</Dropdown.Menu>
</ControlledDropdown>
)
}
export default memo(PdfCompileButton)

View file

@ -0,0 +1,50 @@
import { useTranslation } from 'react-i18next'
import { Button, Dropdown } from 'react-bootstrap'
import Icon from '../../../shared/components/icon'
import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import PdfFileList from './pdf-file-list'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { memo } from 'react'
function PdfDownloadButton() {
const { compiling, pdfDownloadUrl, fileList } = usePdfPreviewContext()
const { t } = useTranslation()
const disabled = compiling || !pdfDownloadUrl
return (
<ControlledDropdown
id="pdf-download-dropdown"
className="toolbar-item"
disabled={disabled}
>
<Button
bsSize="xsmall"
bsStyle="info"
disabled={compiling || !pdfDownloadUrl}
download
href={pdfDownloadUrl || '#'}
>
<Icon type="download" modifier="fw" />
<span className="toolbar-text toolbar-hide-medium toolbar-hide-small">
{t('download_pdf')}
</span>
</Button>
<Dropdown.Toggle
bsSize="xsmall"
bsStyle="info"
className="dropdown-toggle"
aria-label={t('toggle_output_files_list')}
disabled={!fileList}
/>
<Dropdown.Menu id="download-dropdown-list">
<PdfFileList fileList={fileList} />
</Dropdown.Menu>
</ControlledDropdown>
)
}
export default memo(PdfDownloadButton)

View file

@ -0,0 +1,33 @@
import { Dropdown } from 'react-bootstrap'
import PdfFileList from './pdf-file-list'
import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import { memo } from 'react'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { useTranslation } from 'react-i18next'
function PdfDownloadFilesButton() {
const { compiling, fileList } = usePdfPreviewContext()
const { t } = useTranslation()
return (
<ControlledDropdown
id="dropdown-files-logs-pane"
dropup
pullRight
disabled={compiling || !fileList}
>
<Dropdown.Toggle
className="dropdown-toggle"
title={t('other_logs_and_files')}
bsSize="small"
bsStyle="info"
/>
<Dropdown.Menu id="dropdown-files-logs-pane-list">
<PdfFileList fileList={fileList} />
</Dropdown.Menu>
</ControlledDropdown>
)
}
export default memo(PdfDownloadFilesButton)

View file

@ -0,0 +1,32 @@
import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { memo, useMemo } from 'react'
function PdfExpandButton() {
const { pdfLayout, switchLayout } = usePdfPreviewContext()
const { t } = useTranslation()
const text = useMemo(() => {
return pdfLayout === 'sideBySide' ? t('full_screen') : t('split_screen')
}, [pdfLayout, t])
return (
<OverlayTrigger
placement="left"
overlay={<Tooltip id="expand-pdf-btn">{text}</Tooltip>}
>
<Button
onClick={switchLayout}
className="toolbar-pdf-expand-btn toolbar-item"
aria-label={text}
>
<Icon type={pdfLayout === 'sideBySide' ? 'expand' : 'compress'} />
</Button>
</OverlayTrigger>
)
}
export default memo(PdfExpandButton)

View file

@ -0,0 +1,50 @@
import { MenuItem } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import PropTypes from 'prop-types'
function PdfFileList({ fileList }) {
const { t } = useTranslation()
if (!fileList) {
return null
}
return (
<>
<MenuItem header>{t('other_output_files')}</MenuItem>
{fileList.top.map(file => (
<MenuItem download href={file.url} key={file.path}>
<b>{file.path}</b>
</MenuItem>
))}
{fileList.other.length > 0 && fileList.top.length > 0 && (
<MenuItem divider />
)}
{fileList.other.map(file => (
<MenuItem download href={file.url} key={file.path}>
<b>{file.path}</b>
</MenuItem>
))}
</>
)
}
const FilesArray = PropTypes.arrayOf(
PropTypes.shape({
path: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})
)
PdfFileList.propTypes = {
fileList: PropTypes.shape({
top: FilesArray,
other: FilesArray,
}),
}
export default memo(PdfFileList)

View file

@ -0,0 +1,239 @@
import PropTypes from 'prop-types'
import { memo, useCallback, useEffect, useState } from 'react'
import { debounce } from 'lodash'
import { Alert } from 'react-bootstrap'
import PdfViewerControls from './pdf-viewer-controls'
import { useProjectContext } from '../../../shared/context/project-context'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import useScopeValue from '../../../shared/context/util/scope-value-hook'
import { buildHighlightElement } from '../util/highlights'
import PDFJSWrapper from '../util/pdf-js-wrapper'
function PdfJsViewer({ url }) {
const { _id: projectId } = useProjectContext()
// state values persisted in localStorage to restore on load
const [scale, setScale] = usePersistedState(
`pdf-viewer-scale:${projectId}`,
'page-width'
)
const [, setScrollTop] = usePersistedState(
`pdf-viewer-scroll-top:${projectId}`,
0
)
// state values shared with Angular scope (highlights => editor, position => synctex buttons
const [highlights] = useScopeValue('pdf.highlights')
const [, setPosition] = useScopeValue('pdf.position')
// local state values
const [pdfJsWrapper, setPdfJsWrapper] = useState()
const [initialised, setInitialised] = useState(false)
const [error, setError] = useState()
// create the viewer when the container is mounted
const handleContainer = useCallback(parent => {
setPdfJsWrapper(parent ? new PDFJSWrapper(parent.firstChild) : undefined)
}, [])
// listen for initialize event
useEffect(() => {
if (pdfJsWrapper) {
pdfJsWrapper.eventBus.on('pagesinit', () => {
setInitialised(true)
})
}
}, [pdfJsWrapper])
// load the PDF document from the URL
useEffect(() => {
if (pdfJsWrapper && url) {
setInitialised(false)
setError(undefined)
// TODO: anything else to be reset?
pdfJsWrapper.loadDocument(url).catch(error => setError(error))
}
}, [pdfJsWrapper, url])
useEffect(() => {
if (pdfJsWrapper) {
// listen for 'pdf:scroll-to-position' events
const eventListener = event => {
pdfJsWrapper.container.scrollTop = event.data.position
}
window.addEventListener('pdf:scroll-to-position', eventListener)
return () => {
window.removeEventListener('pdf:scroll-to-position', eventListener)
}
}
}, [pdfJsWrapper])
// listen for scroll events
useEffect(() => {
if (pdfJsWrapper) {
// store the scroll position in localStorage, for the synctex button
const storePosition = debounce(pdfViewer => {
// set position for "sync to code" button
try {
setPosition(pdfViewer.currentPosition)
} catch (error) {
// console.error(error) // TODO
}
}, 500)
// store the scroll position in localStorage, for use when reloading
const storeScrollTop = debounce(pdfViewer => {
// set position for "sync to code" button
setScrollTop(pdfJsWrapper.container.scrollTop)
}, 500)
storePosition(pdfJsWrapper)
const scrollListener = () => {
storeScrollTop(pdfJsWrapper)
storePosition(pdfJsWrapper)
}
pdfJsWrapper.container.addEventListener('scroll', scrollListener)
return () => {
pdfJsWrapper.container.removeEventListener('scroll', scrollListener)
}
}
}, [setPosition, setScrollTop, pdfJsWrapper])
// listen for double-click events
useEffect(() => {
if (pdfJsWrapper) {
pdfJsWrapper.eventBus.on('textlayerrendered', textLayer => {
const pageElement = textLayer.source.textLayerDiv.closest('.page')
const doubleClickListener = event => {
window.dispatchEvent(
new CustomEvent('synctex:sync-to-position', {
detail: pdfJsWrapper.clickPosition(event, pageElement, textLayer),
})
)
}
pageElement.addEventListener('dblclick', doubleClickListener)
})
}
}, [pdfJsWrapper])
// restore the saved scale and scroll position
useEffect(() => {
if (initialised && pdfJsWrapper) {
setScale(scale => {
pdfJsWrapper.viewer.currentScaleValue = scale
return scale
})
// restore the scroll position
setScrollTop(scrollTop => {
if (scrollTop > 0) {
pdfJsWrapper.container.scrollTop = scrollTop
}
return scrollTop
})
}
}, [initialised, setScale, setScrollTop, pdfJsWrapper])
// transmit scale value to the viewer when it changes
useEffect(() => {
if (pdfJsWrapper) {
pdfJsWrapper.viewer.currentScaleValue = scale
}
}, [scale, pdfJsWrapper])
// when highlights are created, build the highlight elements
useEffect(() => {
if (pdfJsWrapper && highlights?.length) {
const elements = highlights.map(highlight =>
buildHighlightElement(highlight, pdfJsWrapper.viewer)
)
// scroll to the first highlighted element
elements[0].scrollIntoView({
block: 'start',
inline: 'nearest',
behavior: 'smooth',
})
return () => {
for (const element of elements) {
element.remove()
}
}
}
}, [highlights, pdfJsWrapper])
// set the scale in response to zoom option changes
const setZoom = useCallback(
zoom => {
switch (zoom) {
case 'fit-width':
setScale('page-width')
break
case 'fit-height':
setScale('page-height')
break
case 'zoom-in':
setScale(pdfJsWrapper.viewer.currentScale * 1.25)
break
case 'zoom-out':
setScale(pdfJsWrapper.viewer.currentScale * 0.75)
break
}
},
[pdfJsWrapper, setScale]
)
// adjust the scale when the container is resized
useEffect(() => {
if (pdfJsWrapper) {
const resizeListener = () => {
pdfJsWrapper.updateOnResize()
}
const resizeObserver = new ResizeObserver(resizeListener)
resizeObserver.observe(pdfJsWrapper.container)
window.addEventListener('resize', resizeListener)
return () => {
resizeObserver.disconnect()
window.removeEventListener('resize', resizeListener)
}
}
}, [pdfJsWrapper])
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
return (
<div className="pdfjs-viewer" ref={handleContainer}>
<div className="pdfjs-viewer-inner" tabIndex="0">
<div className="pdfViewer" />
</div>
<div className="pdfjs-controls">
<PdfViewerControls setZoom={setZoom} />
</div>
{error && (
<div className="pdfjs-error">
<Alert bsStyle="danger">{error.message}</Alert>
</div>
)}
</div>
)
}
PdfJsViewer.propTypes = {
url: PropTypes.string.isRequired,
}
export default memo(PdfJsViewer)

View file

@ -0,0 +1,67 @@
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
import PropTypes from 'prop-types'
import { memo } from 'react'
export function PdfLogsButtonContent({
showLogs,
logEntries,
autoCompileLintingError,
}) {
const { t } = useTranslation()
if (showLogs) {
return (
<>
<Icon type="file-pdf-o" />
<span className="toolbar-text toolbar-hide-small">{t('view_pdf')}</span>
</>
)
}
if (autoCompileLintingError) {
return (
<>
<Icon type="exclamation-triangle" />
<span className="toolbar-text toolbar-hide-small">
{t('code_check_failed')}
</span>
</>
)
}
const count = logEntries?.errors?.length || logEntries?.warnings?.length
if (!count) {
return (
<>
<Icon type="file-text-o" />
<span className="toolbar-text toolbar-hide-small">
{t('view_logs')}
</span>
</>
)
}
return (
<>
<Icon type="file-text-o" />
<span className="btn-toggle-logs-label toolbar-text toolbar-hide-small">
{logEntries.errors?.length
? t('your_project_has_an_error', { count })
: t('view_warning', { count })}
<span className="sr-hidden">
{count > 1 && ` (${count > 99 ? '99+' : count})`}
</span>
</span>
</>
)
}
PdfLogsButtonContent.propTypes = {
autoCompileLintingError: PropTypes.bool,
showLogs: PropTypes.bool,
logEntries: PropTypes.object,
}
export default memo(PdfLogsButtonContent)

View file

@ -0,0 +1,68 @@
import { memo, useCallback, useMemo } from 'react'
import { Button } from 'react-bootstrap'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { PdfLogsButtonContent } from './pdf-logs-button-content'
import { sendMBOnce } from '../../../infrastructure/event-tracking'
function PdfLogsButton() {
const {
autoCompileLintingError,
stopOnValidationError,
error,
logEntries,
showLogs,
setShowLogs,
} = usePdfPreviewContext()
const buttonStyle = useMemo(() => {
if (showLogs) {
return 'default'
}
if (autoCompileLintingError && stopOnValidationError) {
return 'danger'
}
if (logEntries) {
if (logEntries.errors?.length) {
return 'danger'
}
if (logEntries.warnings?.length) {
return 'warning'
}
}
return 'default'
}, [autoCompileLintingError, logEntries, showLogs, stopOnValidationError])
const handleClick = useCallback(() => {
setShowLogs(value => {
if (!value) {
sendMBOnce('ide-open-logs-once')
}
return !value
})
}, [setShowLogs])
return (
<Button
bsSize="xsmall"
bsStyle={buttonStyle}
disabled={Boolean(error)}
className="btn-toggle-logs toolbar-item"
onClick={handleClick}
>
<PdfLogsButtonContent
showLogs={showLogs}
logEntries={logEntries}
autoCompileLintingError={
autoCompileLintingError && stopOnValidationError
}
/>
</Button>
)
}
export default memo(PdfLogsButton)

View file

@ -0,0 +1,46 @@
import { memo, useCallback } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import PreviewLogsPaneEntry from '../../preview/components/preview-logs-pane-entry'
function PdfLogsEntries({ entries }) {
const { t } = useTranslation()
const syncToEntry = useCallback(entry => {
window.dispatchEvent(
new CustomEvent('synctex:sync-to-entry', {
detail: entry,
})
)
}, [])
return (
<>
{entries.map(logEntry => (
<PreviewLogsPaneEntry
key={logEntry.key}
headerTitle={logEntry.message}
rawContent={logEntry.content}
logType={logEntry.type}
formattedContent={logEntry.humanReadableHintComponent}
extraInfoURL={logEntry.extraInfoURL}
level={logEntry.level}
entryAriaLabel={t('log_entry_description', {
level: logEntry.level,
})}
sourceLocation={{
file: logEntry.file,
line: logEntry.line,
column: logEntry.column,
}}
onSourceLocationClick={syncToEntry}
/>
))}
</>
)
}
PdfLogsEntries.propTypes = {
entries: PropTypes.arrayOf(PropTypes.object),
}
export default memo(PdfLogsEntries)

View file

@ -0,0 +1,70 @@
import Icon from '../../../shared/components/icon'
import { useTranslation } from 'react-i18next'
import PreviewLogsPaneEntry from '../../preview/components/preview-logs-pane-entry'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { memo } from 'react'
import PdfValidationIssue from './pdf-validation-issue'
import TimeoutUpgradePrompt from './timeout-upgrade-prompt'
import PdfPreviewError from './pdf-preview-error'
import PdfClearCacheButton from './pdf-clear-cache-button'
import PdfDownloadFilesButton from './pdf-download-files-button'
import PdfLogsEntries from './pdf-logs-entries'
function PdfLogsViewer() {
const {
autoCompileLintingError,
stopOnValidationError,
error,
logEntries,
rawLog,
validationIssues,
} = usePdfPreviewContext()
const { t } = useTranslation()
return (
<div className="logs-pane">
<div className="logs-pane-content">
{autoCompileLintingError && stopOnValidationError && (
<div className="log-entry">
<div className="log-entry-header log-entry-header-error">
<div className="log-entry-header-icon-container">
<Icon type="exclamation-triangle" modifier="fw" />
</div>
<h3 className="log-entry-header-title">
{t('code_check_failed_explanation')}
</h3>
</div>
</div>
)}
{error && <PdfPreviewError error={error} />}
{error === 'timedout' && <TimeoutUpgradePrompt />}
{validationIssues &&
Object.entries(validationIssues).map(([name, issue]) => (
<PdfValidationIssue key={name} name={name} issue={issue} />
))}
{logEntries?.all && <PdfLogsEntries entries={logEntries.all} />}
{rawLog && (
<PreviewLogsPaneEntry
headerTitle={t('raw_logs')}
rawContent={rawLog}
entryAriaLabel={t('raw_logs_description')}
level="raw"
/>
)}
<div className="logs-pane-actions">
<PdfClearCacheButton />
<PdfDownloadFilesButton />
</div>
</div>
</div>
)
}
export default memo(PdfLogsViewer)

View file

@ -0,0 +1,155 @@
import PropTypes from 'prop-types'
import { useTranslation, Trans } from 'react-i18next'
import PreviewLogsPaneEntry from '../../preview/components/preview-logs-pane-entry'
import { memo } from 'react'
function PdfPreviewError({ error }) {
const { t } = useTranslation()
switch (error) {
case 'rendering-error':
return (
<ErrorLogEntry title={t('pdf_rendering_error')}>
{t('something_went_wrong_rendering_pdf')}
</ErrorLogEntry>
)
case 'clsi-maintenance':
return (
<ErrorLogEntry title={t('server_error')}>
{t('clsi_maintenance')}
</ErrorLogEntry>
)
case 'clsi-unavailable':
return (
<ErrorLogEntry title={t('server_error')}>
{t('clsi_unavailable')}
</ErrorLogEntry>
)
case 'too-recently-compiled':
return (
<ErrorLogEntry title={t('server_error')}>
{t('too_recently_compiled')}
</ErrorLogEntry>
)
case 'terminated':
return (
<ErrorLogEntry title={t('terminated')}>
{t('compile_terminated_by_user')}
</ErrorLogEntry>
)
case 'rate-limited':
return (
<ErrorLogEntry title={t('pdf_compile_rate_limit_hit')}>
{t('project_flagged_too_many_compiles')}
</ErrorLogEntry>
)
case 'compile-in-progress':
return (
<ErrorLogEntry title={t('pdf_compile_in_progress_error')}>
{t('pdf_compile_try_again')}
</ErrorLogEntry>
)
case 'auto-compile-disabled':
return (
<ErrorLogEntry title={t('autocompile_disabled')}>
{t('autocompile_disabled_reason')}
</ErrorLogEntry>
)
case 'project-too-large':
return (
<ErrorLogEntry title={t('project_too_large')}>
{t('project_too_much_editable_text')}
</ErrorLogEntry>
)
case 'timedout':
return (
<ErrorLogEntry title={t('timedout')}>
{t('proj_timed_out_reason')}
<div>
<a
href="https://www.overleaf.com/learn/how-to/Why_do_I_keep_getting_the_compile_timeout_error_message%3F"
target="_blank"
rel="noopener"
>
{t('learn_how_to_make_documents_compile_quickly')}
</a>
</div>
</ErrorLogEntry>
)
case 'failure':
return (
<ErrorLogEntry title={t('no_pdf_error_title')}>
{t('no_pdf_error_explanation')}
<ul className="log-entry-formatted-content-list">
<li>{t('no_pdf_error_reason_unrecoverable_error')}</li>
<li>
<Trans
i18nKey="no_pdf_error_reason_no_content"
components={{ code: <code /> }}
/>
</li>
<li>
<Trans
i18nKey="no_pdf_error_reason_output_pdf_already_exists"
components={{ code: <code /> }}
/>
</li>
</ul>
</ErrorLogEntry>
)
case 'clear-cache':
return (
<ErrorLogEntry title={t('server_error')}>
{t('somthing_went_wrong_compiling')}
</ErrorLogEntry>
)
case 'validation-problems':
return null // handled elsewhere
case 'error':
default:
return (
<ErrorLogEntry title={t('server_error')}>
{t('somthing_went_wrong_compiling')}
</ErrorLogEntry>
)
}
}
PdfPreviewError.propTypes = {
error: PropTypes.string.isRequired,
}
export default memo(PdfPreviewError)
function ErrorLogEntry({ title, children }) {
const { t } = useTranslation()
return (
<PreviewLogsPaneEntry
headerTitle={title}
formattedContent={children}
entryAriaLabel={t('compile_error_entry_description')}
level="error"
/>
)
}
ErrorLogEntry.propTypes = {
title: PropTypes.string.isRequired,
children: PropTypes.any.isRequired,
}

View file

@ -1,7 +1,24 @@
import { memo } from 'react' import { memo, Suspense } from 'react'
import PdfLogsViewer from './pdf-logs-viewer'
import PdfViewer from './pdf-viewer'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import PdfPreviewToolbar from './pdf-preview-toolbar'
function PdfPreviewPane() { function PdfPreviewPane() {
return <div>PDF Preview</div> const { showLogs } = usePdfPreviewContext()
return (
<div className="pdf full-size">
<PdfPreviewToolbar />
<Suspense fallback={<div>Loading</div>}>
<div className="pdf-viewer">
<PdfViewer />
</div>
</Suspense>
{showLogs && <PdfLogsViewer />}
</div>
)
} }
export default memo(PdfPreviewPane) export default memo(withErrorBoundary(PdfPreviewPane))

View file

@ -0,0 +1,32 @@
import PdfCompileButton from './pdf-compile-button'
import PdfDownloadButton from './pdf-download-button'
import PdfLogsButton from './pdf-logs-button'
import PdfExpandButton from './pdf-expand-button'
import { ButtonToolbar } from 'react-bootstrap'
import { memo, useState } from 'react'
import useToolbarBreakpoint from '../hooks/use-toolbar-breakpoint'
const isPreview = new URLSearchParams(window.location.search).get('preview')
function PdfPreviewToolbar() {
const [element, setElement] = useState()
const toolbarClasses = useToolbarBreakpoint(element)
return (
<div ref={element => setElement(element)}>
<ButtonToolbar className={toolbarClasses}>
<div className="toolbar-pdf-left">
<PdfCompileButton />
<PdfDownloadButton />
</div>
<div className="toolbar-pdf-right">
<PdfLogsButton />
{!isPreview && <PdfExpandButton />}
</div>
</ButtonToolbar>
</div>
)
}
export default memo(PdfPreviewToolbar)

View file

@ -0,0 +1,13 @@
import PdfPreviewProvider from '../contexts/pdf-preview-context'
import PdfPreviewPane from './pdf-preview-pane'
import { memo } from 'react'
function PdfPreview() {
return (
<PdfPreviewProvider>
<PdfPreviewPane />
</PdfPreviewProvider>
)
}
export default memo(PdfPreview)

View file

@ -0,0 +1,71 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import PreviewLogsPaneEntry from '../../preview/components/preview-logs-pane-entry'
PdfValidationIssue.propTypes = {
name: PropTypes.string.isRequired,
issue: PropTypes.any,
}
function PdfValidationIssue({ issue, name }) {
const { t } = useTranslation()
switch (name) {
case 'sizeCheck':
return (
<PreviewLogsPaneEntry
headerTitle={t('project_too_large')}
formattedContent={
<>
<div>{t('project_too_large_please_reduce')}</div>
<ul className="list-no-margin-bottom">
{issue.resources.map(resource => (
<li key={resource.path}>
{resource.path} &mdash; {resource.kbSize}
kb
</li>
))}
</ul>
</>
}
entryAriaLabel={t('validation_issue_entry_description')}
level="error"
/>
)
case 'conflictedPaths':
return (
<PreviewLogsPaneEntry
headerTitle={t('conflicting_paths_found')}
formattedContent={
<>
<div>{t('following_paths_conflict')}</div>
<ul className="list-no-margin-bottom">
{issue.map(detail => (
<li key={detail.path}>/{detail.path}</li>
))}
</ul>
</>
}
entryAriaLabel={t('validation_issue_entry_description')}
level="error"
/>
)
case 'mainFile':
return (
<PreviewLogsPaneEntry
headerTitle={t('main_file_not_found')}
formattedContent={t('please_set_main_file')}
entryAriaLabel={t('validation_issue_entry_description')}
level="error"
/>
)
default:
return null
}
}
export default memo(PdfValidationIssue)

View file

@ -0,0 +1,38 @@
import { ButtonGroup } from 'react-bootstrap'
import PropTypes from 'prop-types'
import Button from 'react-bootstrap/lib/Button'
import Icon from '../../../shared/components/icon'
import { memo } from 'react'
function PdfViewerControls({ setZoom }) {
return (
<ButtonGroup>
<Button
bsStyle="info"
bsSize="large"
onClick={() => setZoom('fit-width')}
>
<Icon type="arrows-h" />
</Button>
<Button
bsStyle="info"
bsSize="large"
onClick={() => setZoom('fit-height')}
>
<Icon type="arrows-v" />
</Button>
<Button bsStyle="info" bsSize="large" onClick={() => setZoom('zoom-in')}>
<Icon type="search-plus" />
</Button>
<Button bsStyle="info" bsSize="large" onClick={() => setZoom('zoom-out')}>
<Icon type="search-minus" />
</Button>
</ButtonGroup>
)
}
PdfViewerControls.propTypes = {
setZoom: PropTypes.func.isRequired,
}
export default memo(PdfViewerControls)

View file

@ -0,0 +1,38 @@
import useScopeValue from '../../../shared/context/util/scope-value-hook'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { lazy, memo, useEffect } from 'react'
const PdfJsViewer = lazy(() =>
import(/* webpackChunkName: "pdf-js-viewer" */ './pdf-js-viewer')
)
const params = new URLSearchParams(window.location.search)
function PdfViewer() {
const [pdfViewer, setPdfViewer] = useScopeValue('settings.pdfViewer')
useEffect(() => {
const viewer = params.get('viewer')
if (viewer) {
setPdfViewer(viewer)
}
}, [setPdfViewer])
const { pdfUrl } = usePdfPreviewContext()
if (!pdfUrl) {
return null
}
switch (pdfViewer) {
case 'native':
return <iframe title="PDF Preview" src={pdfUrl} />
case 'pdfjs':
default:
return <PdfJsViewer url={pdfUrl} />
}
}
export default memo(PdfViewer)

View file

@ -0,0 +1,83 @@
import { useTranslation } from 'react-i18next'
import { useEditorContext } from '../../../shared/context/editor-context'
import PreviewLogsPaneEntry from '../../preview/components/preview-logs-pane-entry'
import Icon from '../../../shared/components/icon'
import StartFreeTrialButton from '../../../shared/components/start-free-trial-button'
import { memo } from 'react'
function TimeoutUpgradePrompt() {
const { t } = useTranslation()
const { hasPremiumCompile, isProjectOwner } = useEditorContext()
if (!window.ExposedSettings.enableSubscriptions || hasPremiumCompile) {
return
}
return (
<PreviewLogsPaneEntry
headerTitle={
isProjectOwner
? t('upgrade_for_longer_compiles')
: t('ask_proj_owner_to_upgrade_for_longer_compiles')
}
formattedContent={
<>
<p>{t('free_accounts_have_timeout_upgrade_to_increase')}</p>
<p>{t('plus_upgraded_accounts_receive')}:</p>
<div>
<ul className="list-unstyled">
<li>
<Icon type="check" />
&nbsp;
{t('unlimited_projects')}
</li>
<li>
<Icon type="check" />
&nbsp;
{t('collabs_per_proj', { collabcount: 'Multiple' })}
</li>
<li>
<Icon type="check" />
&nbsp;
{t('full_doc_history')}
</li>
<li>
<Icon type="check" />
&nbsp;
{t('sync_to_dropbox')}
</li>
<li>
<Icon type="check" />
&nbsp;
{t('sync_to_github')}
</li>
<li>
<Icon type="check" />
&nbsp;
{t('compile_larger_projects')}
</li>
</ul>
</div>
{isProjectOwner && (
<p className="text-center">
<StartFreeTrialButton
source="compile-timeout"
buttonStyle="success"
classes={{ button: 'row-spaced-small' }}
/>
</p>
)}
</>
}
entryAriaLabel={
isProjectOwner
? t('upgrade_for_longer_compiles')
: t('ask_proj_owner_to_upgrade_for_longer_compiles')
}
level="success"
/>
)
}
export default memo(TimeoutUpgradePrompt)

View file

@ -0,0 +1,659 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import PropTypes from 'prop-types'
import useScopeValue from '../../../shared/context/util/scope-value-hook'
import { useProjectContext } from '../../../shared/context/project-context'
import getMeta from '../../../utils/meta'
import { deleteJSON, postJSON } from '../../../infrastructure/fetch-json'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import {
buildLogEntryAnnotations,
handleOutputFiles,
} from '../util/output-files'
import { debounce } from 'lodash'
import { useIdeContext } from '../../../shared/context/ide-context'
import {
send,
sendMB,
sendMBSampled,
} from '../../../infrastructure/event-tracking'
import { useEditorContext } from '../../../shared/context/editor-context'
import { isMainFile } from '../util/editor-files'
import useAbortController from '../../../shared/hooks/use-abort-controller'
const AUTO_COMPILE_MAX_WAIT = 5000
// We add a 1 second debounce to sending user changes to server if they aren't
// collaborating with anyone. This needs to be higher than that, and allow for
// client to server latency, otherwise we compile before the op reaches the server
// and then again on ack.
const AUTO_COMPILE_DEBOUNCE = 2000
const searchParams = new URLSearchParams(window.location.search)
export const PdfPreviewContext = createContext(undefined)
PdfPreviewProvider.propTypes = {
children: PropTypes.any,
}
export default function PdfPreviewProvider({ children }) {
const ide = useIdeContext()
const { _id: projectId, rootDoc_id: rootDocId } = useProjectContext()
const { hasPremiumCompile, isProjectOwner } = useEditorContext()
// the URL for loading the PDF in the preview pane
const [pdfUrl, setPdfUrl] = useScopeValue('pdf.url')
// the URL for downloading the PDF
const [pdfDownloadUrl, setPdfDownloadUrl] = useScopeValue('pdf.downloadUrl')
// the log entries parsed from the compile output log
const [logEntries, setLogEntries] = useScopeValue('pdf.logEntries')
// the project is considered to be "uncompiled" if a doc has changed since the last compile started
const [uncompiled, setUncompiled] = useScopeValue('pdf.uncompiled')
// annotations for display in the editor, built from the log entries
const [, setLogEntryAnnotations] = useScopeValue('pdf.logEntryAnnotations')
// the id of the CLSI server which ran the compile
const [clsiServerId, setClsiServerId] = useScopeValue('ide.clsiServerId')
// the compile group (standard or priority)
const [compileGroup, setCompileGroup] = useScopeValue('ide.compileGroup')
// whether to display the editor and preview side-by-side or full-width ("flat")
const [pdfLayout, setPdfLayout] = useScopeValue('ui.pdfLayout')
// what to show in the "flat" view (editor or pdf)
const [, setUiView] = useScopeValue('ui.view')
// whether a compile is in progress
const [compiling, setCompiling] = useState(false)
// whether the project has been compiled yet
const [compiledOnce, setCompiledOnce] = useState(false)
// whether the cache is being cleared
const [clearingCache, setClearingCache] = useState(false)
// whether the logs should be visible
const [showLogs, setShowLogs] = useState(false)
// an error that occurred
const [error, setError] = useState()
// the list of files that can be downloaded
const [fileList, setFileList] = useState()
// the raw contents of the log file
const [rawLog, setRawLog] = useState()
// validation issues from CLSI
const [validationIssues, setValidationIssues] = useState()
// whether autocompile is switched on
const [autoCompile, _setAutoCompile] = usePersistedState(
`autocompile_enabled:${projectId}`,
false,
true
)
// whether the compile should run in draft mode
const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, true)
// whether compiling should be prevented if there are linting errors
const [stopOnValidationError, setStopOnValidationError] = usePersistedState(
`stop_on_validation_error:${projectId}`,
true,
true
)
// the id of the currently open document in the editor
const [currentDocId] = useScopeValue('editor.open_doc_id')
// the Document currently open in the editor?
const [currentDoc] = useScopeValue('editor.sharejs_doc')
// whether the PDF view is hidden
const [pdfHidden] = useScopeValue('ui.pdfHidden')
// whether the editor linter found errors
const [hasLintingError, setHasLintingError] = useScopeValue('hasLintingError')
// whether syntax validation is enabled globally
const [syntaxValidation] = useScopeValue('settings.syntaxValidation')
// the timestamp that a doc was last changed or saved
const [changedAt, setChangedAt] = useState(0)
const { signal } = useAbortController()
// pass the "uncompiled" value up into the scope for use outside this context provider
useEffect(() => {
setUncompiled(changedAt > 0)
}, [setUncompiled, changedAt])
// record changes to the autocompile setting
const setAutoCompile = useCallback(
value => {
_setAutoCompile(value)
sendMB('autocompile-setting-changed', { value })
},
[_setAutoCompile]
)
// parse the text of the current doc in the editor
// if it contains "\documentclass" then use this as the root doc
const getRootDocOverrideId = useCallback(() => {
if (currentDocId === rootDocId) {
return null // no need to override when in the root doc itself
}
if (currentDoc) {
const doc = currentDoc.getSnapshot()
if (doc) {
return isMainFile(doc)
}
}
return null
}, [currentDoc, currentDocId, rootDocId])
// TODO: remove this?
const sendCompileMetrics = useCallback(() => {
if (compiledOnce && !error && !window.user.alphaProgram) {
const metadata = {
errors: logEntries.errors.length,
warnings: logEntries.warnings.length,
typesetting: logEntries.typesetting.length,
newPdfPreview: true,
}
sendMBSampled('compile-result', metadata, 0.01)
}
}, [compiledOnce, error, logEntries])
// handle the data returned from a compile request
const handleCompileData = useCallback(
(data, options) => {
if (data.clsiServerId) {
setClsiServerId(data.clsiServerId)
}
if (data.compileGroup) {
setCompileGroup(data.compileGroup)
}
if (data.outputFiles) {
handleOutputFiles(projectId, data).then(result => {
setLogEntryAnnotations(
buildLogEntryAnnotations(result.logEntries.all, ide.fileTreeManager)
)
setLogEntries(result.logEntries)
setFileList(result.fileList)
setPdfDownloadUrl(result.pdfDownloadUrl)
setPdfUrl(result.pdfUrl)
setRawLog(result.log)
})
}
switch (data.status) {
case 'success':
setError(undefined)
setShowLogs(false) // TODO: always?
break
case 'clsi-maintenance':
case 'compile-in-progress':
case 'exited':
case 'failure':
case 'project-too-large':
case 'terminated':
case 'too-recently-compiled':
setError(data.status)
break
case 'timedout':
setError('timedout')
if (!hasPremiumCompile && isProjectOwner) {
send(
'subscription-funnel',
'editor-click-feature',
'compile-timeout'
)
sendMB('paywall-prompt', {
'paywall-type': 'compile-timeout',
})
}
break
case 'autocompile-backoff':
if (!options.isAutoCompileOnLoad) {
setError('autocompile-disabled')
setAutoCompile(false)
sendMB('autocompile-rate-limited', { hasPremiumCompile })
}
break
case 'unavailable':
setError('clsi-unavailable')
break
case 'validation-problems':
setError('validation-problems')
setValidationIssues(data.validationProblems)
break
default:
setError('error')
break
}
},
[
hasPremiumCompile,
ide.fileTreeManager,
isProjectOwner,
projectId,
setAutoCompile,
setClsiServerId,
setCompileGroup,
setLogEntries,
setLogEntryAnnotations,
setPdfDownloadUrl,
setPdfUrl,
]
)
const buildCompileParams = useCallback(
options => {
const params = new URLSearchParams()
if (clsiServerId) {
params.set('clsiserverid', clsiServerId)
}
if (options.isAutoCompileOnLoad || options.isAutoCompileOnChange) {
params.set('auto_compile', 'true')
}
if (getMeta('ol-enablePdfCaching')) {
params.set('enable_pdf_caching', 'true')
}
if (searchParams.get('file_line_errors') === 'true') {
params.file_line_errors = 'true'
}
return params
},
[clsiServerId]
)
// run a compile
const recompile = useCallback(
(options = {}) => {
if (compiling) {
return
}
sendMBSampled('editor-recompile-sampled', options)
setChangedAt(0) // NOTE: this sets uncompiled to false
setCompiling(true)
setValidationIssues(undefined)
window.dispatchEvent(new CustomEvent('flush-changes')) // TODO: wait for this?
postJSON(`/project/${projectId}/compile?${buildCompileParams(options)}`, {
body: {
rootDoc_id: getRootDocOverrideId(),
draft,
check: 'silent', // NOTE: 'error' and 'validate' are possible, but unused
// use incremental compile for all users but revert to a full compile
// if there was previously a server error
incrementalCompilesEnabled: !error,
},
signal,
})
.then(data => {
handleCompileData(data, options)
})
.catch(error => {
// console.error(error)
setError(error.info?.statusCode === 429 ? 'rate-limited' : 'error')
})
.finally(() => {
setCompiling(false)
sendCompileMetrics()
})
},
[
compiling,
projectId,
buildCompileParams,
getRootDocOverrideId,
draft,
error,
handleCompileData,
sendCompileMetrics,
signal,
]
)
// switch to logs if there's an error
useEffect(() => {
if (error) {
setShowLogs(true)
}
}, [error])
// recompile on key press
useEffect(() => {
const listener = event => {
recompile(event.detail)
}
window.addEventListener('pdf:recompile', listener)
return () => {
window.removeEventListener('pdf:recompile', listener)
}
}, [recompile])
// always compile the PDF once, when joining the project
useEffect(() => {
const listener = () => {
if (!compiledOnce) {
setCompiledOnce(true)
recompile({ isAutoCompileOnLoad: true })
}
}
window.addEventListener('project:joined', listener)
return () => {
window.removeEventListener('project:joined', listener)
}
}, [compiledOnce, recompile])
// whether there has been an autocompile linting error, if syntax validation is switched on
const autoCompileLintingError = Boolean(
autoCompile && syntaxValidation && hasLintingError
)
// the project has visible changes
const hasChanges = Boolean(
autoCompile &&
uncompiled &&
compiledOnce &&
!(stopOnValidationError && autoCompileLintingError)
)
// the project is available for auto-compiling
const canAutoCompile = Boolean(
autoCompile &&
!compiling &&
!pdfHidden &&
!(stopOnValidationError && autoCompileLintingError)
)
// a debounced wrapper around the recompile function, used for auto-compile
const [debouncedAutoCompile] = useState(() => {
return debounce(
() => {
recompile({ isAutoCompileOnChange: true })
},
AUTO_COMPILE_DEBOUNCE,
{
maxWait: AUTO_COMPILE_MAX_WAIT,
}
)
})
// call the debounced recompile function if the project is available for auto-compiling and it has changed
useEffect(() => {
if (canAutoCompile && changedAt > 0) {
debouncedAutoCompile()
} else {
debouncedAutoCompile.cancel()
}
}, [canAutoCompile, debouncedAutoCompile, recompile, changedAt])
// cancel debounced recompile on unmount
useEffect(() => {
return () => {
debouncedAutoCompile.cancel()
}
}, [debouncedAutoCompile])
// record doc changes when notified by the editor
useEffect(() => {
const listener = () => {
setChangedAt(Date.now())
}
window.addEventListener('doc:changed', listener)
window.addEventListener('doc:saved', listener)
return () => {
window.removeEventListener('doc:changed', listener)
window.removeEventListener('doc:saved', listener)
}
}, [])
// send a request to stop the current compile
const stopCompile = useCallback(() => {
// TODO: stoppingCompile state?
const params = new URLSearchParams()
if (clsiServerId) {
params.set('clsiserverid', clsiServerId)
}
return postJSON(`/project/${projectId}/compile/stop?${params}`, { signal })
.catch(error => {
setError(error)
})
.finally(() => {
setCompiling(false)
})
}, [projectId, clsiServerId, signal])
const clearCache = useCallback(() => {
setClearingCache(true)
const params = new URLSearchParams()
if (clsiServerId) {
params.set('clsiserverid', clsiServerId)
}
return deleteJSON(`/project/${projectId}/output?${params}`, { signal })
.catch(error => {
console.error(error)
setError('clear-cache')
})
.finally(() => {
setClearingCache(false)
})
}, [clsiServerId, projectId, setError, signal])
// clear the cache then run a compile, triggered by a menu item
const recompileFromScratch = useCallback(() => {
setClearingCache(true)
clearCache().then(() => {
setClearingCache(false)
recompile()
})
}, [clearCache, recompile])
// switch to either side-by-side or flat (full-width) layout
const switchLayout = useCallback(() => {
setPdfLayout(layout => {
const newLayout = layout === 'sideBySide' ? 'flat' : 'sideBySide'
setUiView(newLayout === 'sideBySide' ? 'editor' : 'pdf')
setPdfLayout(newLayout)
window.localStorage.setItem('pdf.layout', newLayout)
})
}, [setPdfLayout, setUiView])
// the context value, memoized to minimize re-rendering
const value = useMemo(() => {
return {
autoCompile,
autoCompileLintingError,
clearCache,
clearingCache,
clsiServerId,
compileGroup,
compiledOnce,
compiling,
draft,
error,
fileList,
hasChanges,
hasLintingError,
logEntries,
pdfDownloadUrl,
pdfLayout,
pdfUrl,
rawLog,
recompile,
recompileFromScratch,
setAutoCompile,
setClsiServerId,
setCompileGroup,
setCompiledOnce,
setDraft,
setError,
setHasLintingError, // for story
setLogEntries,
setPdfDownloadUrl,
setPdfLayout,
setPdfUrl,
setShowLogs,
setStopOnValidationError,
setUiView,
showLogs,
stopCompile,
stopOnValidationError,
switchLayout,
uncompiled,
validationIssues,
}
}, [
autoCompile,
autoCompileLintingError,
clearCache,
clearingCache,
clsiServerId,
compileGroup,
compiledOnce,
compiling,
draft,
error,
fileList,
hasChanges,
hasLintingError,
logEntries,
pdfDownloadUrl,
pdfLayout,
pdfUrl,
rawLog,
recompile,
recompileFromScratch,
setAutoCompile,
setClsiServerId,
setCompileGroup,
setCompiledOnce,
setDraft,
setError,
setHasLintingError,
setLogEntries,
setPdfDownloadUrl,
setPdfLayout,
setPdfUrl,
setStopOnValidationError,
setUiView,
showLogs,
stopCompile,
stopOnValidationError,
switchLayout,
uncompiled,
validationIssues,
])
return (
<PdfPreviewContext.Provider value={value}>
{children}
</PdfPreviewContext.Provider>
)
}
PdfPreviewContext.Provider.propTypes = {
value: PropTypes.shape({
autoCompile: PropTypes.bool.isRequired,
autoCompileLintingError: PropTypes.bool.isRequired,
clearCache: PropTypes.func.isRequired,
clearingCache: PropTypes.bool.isRequired,
clsiServerId: PropTypes.string,
compileGroup: PropTypes.string,
compiledOnce: PropTypes.bool.isRequired,
compiling: PropTypes.bool.isRequired,
draft: PropTypes.bool.isRequired,
error: PropTypes.string,
fileList: PropTypes.object,
hasChanges: PropTypes.bool.isRequired,
hasLintingError: PropTypes.bool,
logEntries: PropTypes.object,
pdfDownloadUrl: PropTypes.string,
pdfLayout: PropTypes.string,
pdfUrl: PropTypes.string,
rawLog: PropTypes.string,
recompile: PropTypes.func.isRequired,
recompileFromScratch: PropTypes.func.isRequired,
setAutoCompile: PropTypes.func.isRequired,
setClsiServerId: PropTypes.func.isRequired,
setCompileGroup: PropTypes.func.isRequired,
setCompiledOnce: PropTypes.func.isRequired,
setDraft: PropTypes.func.isRequired,
setError: PropTypes.func.isRequired,
setHasLintingError: PropTypes.func.isRequired, // only for storybook
setLogEntries: PropTypes.func.isRequired,
setPdfDownloadUrl: PropTypes.func.isRequired,
setPdfLayout: PropTypes.func.isRequired,
setPdfUrl: PropTypes.func.isRequired,
setShowLogs: PropTypes.func.isRequired,
setStopOnValidationError: PropTypes.func.isRequired,
setUiView: PropTypes.func.isRequired,
showLogs: PropTypes.bool.isRequired,
stopCompile: PropTypes.func.isRequired,
stopOnValidationError: PropTypes.bool.isRequired,
switchLayout: PropTypes.func.isRequired,
uncompiled: PropTypes.bool,
validationIssues: PropTypes.object,
}),
}
export function usePdfPreviewContext() {
const context = useContext(PdfPreviewContext)
if (!context) {
throw new Error(
'usePdfPreviewContext is only available inside PdfPreviewProvider'
)
}
return context
}

View file

@ -1,6 +1,7 @@
import App from '../../../base' import App from '../../../base'
import { react2angular } from 'react2angular' import { react2angular } from 'react2angular'
import PdfPreviewPane from '../components/pdf-preview-pane' import PdfPreview from '../components/pdf-preview'
import { rootContext } from '../../../shared/context/root-context'
App.component('pdfPreviewPane', react2angular(PdfPreviewPane, undefined)) App.component('pdfPreview', react2angular(rootContext.use(PdfPreview), []))

View file

@ -0,0 +1,34 @@
import { useCallback, useEffect, useRef } from 'react'
export const useResizeObserver = callback => {
const resizeRef = useRef(null)
const elementRef = useCallback(
element => {
if (element) {
if (resizeRef.current) {
resizeRef.current.observer.unobserve(resizeRef.current.element)
}
const observer = new ResizeObserver(([entry]) => {
callback(entry)
})
resizeRef.current = { element, observer }
observer.observe(element)
}
},
[callback]
)
useEffect(() => {
return () => {
if (resizeRef.current) {
resizeRef.current.observer.unobserve(resizeRef.current.element)
}
}
}, [])
return elementRef
}

View file

@ -0,0 +1,70 @@
import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import classnames from 'classnames'
const measureContentWidth = element =>
[...element.querySelectorAll('button')].reduce(
(output, item) => output + item.scrollWidth,
0
)
export default function useToolbarBreakpoint(element) {
const [breakpoint, setBreakpoint] = useState(2)
const [recalculate, setRecalculate] = useState(true)
const [resizeObserver] = useState(
() =>
new ResizeObserver(() => {
setBreakpoint(2)
setRecalculate(true)
})
)
const [mutationObserver] = useState(
() =>
new MutationObserver(() => {
setBreakpoint(2)
setRecalculate(true)
})
)
useEffect(() => {
if (element && mutationObserver && resizeObserver) {
resizeObserver.observe(element)
mutationObserver.observe(element, {
childList: true,
subtree: true,
characterData: true,
})
return () => {
mutationObserver.disconnect()
resizeObserver.disconnect()
}
}
}, [element, mutationObserver, resizeObserver])
useLayoutEffect(() => {
if (recalculate && element && breakpoint) {
const contentWidth = measureContentWidth(element) + 150 // NOTE: remove this constant?
if (contentWidth > element.clientWidth) {
setBreakpoint(value => value - 1)
} else {
setRecalculate(false)
}
}
}, [element, breakpoint, recalculate])
return useMemo(
() =>
classnames({
toolbar: true,
'toolbar-pdf': true,
'toolbar-large': breakpoint === 2,
'toolbar-medium': breakpoint === 1,
'toolbar-small': breakpoint === 0,
}),
[breakpoint]
)
}

View file

@ -0,0 +1,28 @@
export const ChkTeXParser = {
parse(log) {
const errors = []
const warnings = []
for (const line of log.split('\n')) {
const m = line.match(/^(\S+):(\d+):(\d+): (Error|Warning): (.*)/)
if (m) {
const result = {
file: m[1],
line: m[2],
column: m[3],
level: m[4].toLowerCase(),
message: `${m[4]}: ${m[5]}`,
}
if (result.level === 'error') {
errors.push(result)
} else {
warnings.push(result)
}
}
}
return { errors, warnings }
},
}

View file

@ -0,0 +1,4 @@
const documentClassRe = /^[^%]*\\documentclass/
export const isMainFile = doc =>
doc.split('\n').some(line => documentClassRe.test(line))

View file

@ -0,0 +1,50 @@
const topFileTypes = ['bbl', 'gls', 'ind']
const ignoreFiles = ['output.fls', 'output.fdb_latexmk']
export const buildFileList = (outputFiles, clsiServerId) => {
const files = { top: [], other: [] }
if (outputFiles) {
const params = new URLSearchParams()
if (clsiServerId) {
params.set('clsiserverid', clsiServerId)
}
const allFiles = []
// filter out ignored files and set some properties
for (const file of outputFiles.values()) {
if (!ignoreFiles.includes(file.path)) {
file.main = file.path.startsWith('output.')
file.url += `?${params}`
allFiles.push(file)
}
}
// sort main files first, then alphabetical
allFiles.sort((a, b) => {
if (a.main && !b.main) {
return a
}
if (b.main && !a.main) {
return b
}
return a.path.localeCompare(b.path)
})
// group files into "top" and "other"
for (const file of allFiles) {
if (topFileTypes.includes(file.type)) {
files.top.push(file)
} else if (!(file.type === 'pdf' && file.main === true)) {
files.other.push(file)
}
}
}
return files
}

View file

@ -0,0 +1,42 @@
import * as PDFJS from 'pdfjs-dist/legacy/build/pdf'
export function buildHighlightElement(highlight, viewer) {
const pageView = viewer.getPageView(highlight.page - 1)
const viewport = pageView.viewport
const height = viewport.viewBox[3]
const rect = viewport.convertToViewportRectangle([
highlight.h, // xMin
height - (highlight.v + highlight.height) + 10, // yMin
highlight.h + highlight.width, // xMax
height - highlight.v + 10, // yMax
])
const [left, top, right, bottom] = PDFJS.Util.normalizeRect(rect)
const element = document.createElement('div')
element.style.left = Math.floor(left) + 'px'
element.style.top = Math.floor(top) + 'px'
element.style.width = Math.ceil(right - left) + 'px'
element.style.height = Math.ceil(bottom - top) + 'px'
element.style.backgroundColor = 'rgba(255,255,0)'
element.style.position = 'absolute'
element.style.display = 'inline-block'
element.style.scrollMargin = '72px'
element.style.pointerEvents = 'none'
element.style.opacity = '0'
element.style.transition = 'opacity 0.5s'
pageView.div.appendChild(element)
window.setTimeout(() => {
element.style.opacity = '0.3'
window.setTimeout(() => {
element.style.opacity = '0'
}, 1000)
}, 0)
return element
}

View file

@ -0,0 +1,168 @@
import getMeta from '../../../utils/meta'
import HumanReadableLogs from '../../../ide/human-readable-logs/HumanReadableLogs'
import BibLogParser from '../../../ide/log-parser/bib-log-parser'
import { ChkTeXParser } from './chktex-log-parser'
import { buildFileList } from './file-list'
const searchParams = new URLSearchParams(window.location.search)
export const handleOutputFiles = async (projectId, data) => {
const result = {}
const outputFiles = new Map()
for (const outputFile of data.outputFiles) {
outputFiles.set(outputFile.path, outputFile)
}
const outputFile = outputFiles.get('output.pdf')
if (outputFile) {
// build the URL for viewing the PDF in the preview UI
const params = new URLSearchParams({
compileGroup: data.compileGroup,
})
if (data.clsiServerId) {
params.set('clsiserverid', data.clsiServerId)
}
if (searchParams.get('verify_chunks') === 'true') {
// Instruct the serviceWorker to verify composed ranges.
params.set('verify_chunks', 'true')
}
if (getMeta('ol-enablePdfCaching')) {
// Tag traffic that uses the pdf caching logic.
params.set('enable_pdf_caching', 'true')
}
result.pdfUrl = `${data.pdfDownloadDomain}${outputFile.url}?${params}`
// build the URL for downloading the PDF
params.set('popupDownload', 'true') // save PDF download as file
result.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/output.pdf?${params}`
}
const params = new URLSearchParams({
compileGroup: data.compileGroup,
})
if (data.clsiServerId) {
params.set('clsiserverid', data.clsiServerId)
}
result.logEntries = {
all: [],
errors: [],
warnings: [],
typesetting: [],
}
function accumulateResults(newEntries, type) {
for (const key in result.logEntries) {
if (newEntries[key]) {
for (const entry of newEntries[key]) {
if (type) {
entry.type = newEntries.type
}
if (entry.file) {
entry.file = normalizeFilePath(entry.file)
}
entry.key = `${entry.file}:${entry.line}:${entry.column}:${entry.message}`
}
result.logEntries[key].push(...newEntries[key])
result.logEntries.all.push(...newEntries[key])
}
}
}
const logFile = outputFiles.get('output.log')
if (logFile) {
const response = await fetch(`${logFile.url}?${params}`)
const log = await response.text()
result.log = log
const { errors, warnings, typesetting } = HumanReadableLogs.parse(log, {
ignoreDuplicates: true,
})
accumulateResults({ errors, warnings, typesetting })
}
const blgFile = outputFiles.get('output.blg')
if (blgFile) {
const response = await fetch(`${blgFile.url}?${params}`)
const log = await response.text()
const { errors, warnings } = new BibLogParser(log, {}).parse()
accumulateResults({ errors, warnings }, 'BibTeX:')
}
const chktexFile = outputFiles.get('output.chktex')
if (chktexFile) {
const response = await fetch(`${chktexFile.url}?${params}`)
const log = await response.text()
const { errors, warnings } = ChkTeXParser.parse(log)
accumulateResults({ errors, warnings }, 'Syntax')
}
result.fileList = buildFileList(outputFiles, data.clsiServerId)
return result
}
export function buildLogEntryAnnotations(entries, fileTreeManager) {
const rootDocDirname = fileTreeManager.getRootDocDirname()
const logEntryAnnotations = {}
for (const entry of entries) {
if (entry.file) {
entry.file = normalizeFilePath(entry.file, rootDocDirname)
const entity = fileTreeManager.findEntityByPath(entry.file)
if (entity) {
if (!(entity.id in logEntryAnnotations)) {
logEntryAnnotations[entity.id] = []
}
logEntryAnnotations[entity.id].push({
row: entry.line - 1,
type: entry.level === 'error' ? 'error' : 'warning',
text: entry.message,
source: 'compile',
})
}
}
}
return logEntryAnnotations
}
function normalizeFilePath(path, rootDocDirname) {
path = path.replace(
/^.*\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/,
''
)
path = path.replace(/^\/compile\//, '')
if (rootDocDirname) {
path = path.replace(/^\.\//, rootDocDirname + '/')
}
return path
}

View file

@ -0,0 +1,126 @@
// NOTE: using "legacy" build as main build requires webpack v5
// import PDFJS from 'pdfjs-dist/webpack'
import * as PDFJS from 'pdfjs-dist/legacy/build/pdf'
import * as PDFJSViewer from 'pdfjs-dist/legacy/web/pdf_viewer'
import PDFJSWorker from 'pdfjs-dist/legacy/build/pdf.worker'
import 'pdfjs-dist/legacy/web/pdf_viewer.css'
import getMeta from '../../../utils/meta'
if (typeof window !== 'undefined' && 'Worker' in window) {
PDFJS.GlobalWorkerOptions.workerPort = new PDFJSWorker()
}
const params = new URLSearchParams(window.location.search)
const disableFontFace = params.get('disable-font-face') === 'true'
const cMapUrl = getMeta('ol-pdfCMapsPath')
const imageResourcesPath = getMeta('ol-pdfImageResourcesPath')
const rangeChunkSize = 128 * 1024 // 128K chunks
export default class PDFJSWrapper {
constructor(container) {
this.container = container
// create the event bus
const eventBus = new PDFJSViewer.EventBus()
// create the link service
const linkService = new PDFJSViewer.PDFLinkService({
eventBus,
externalLinkTarget: 2,
externalLinkRel: 'noopener',
})
// create the localization
const l10n = new PDFJSViewer.GenericL10n('en-GB') // TODO: locale mapping?
// create the viewer
const viewer = new PDFJSViewer.PDFViewer({
container,
eventBus,
imageResourcesPath,
linkService,
l10n,
enableScripting: false, // default is false, but set explicitly to be sure
renderInteractiveForms: false,
})
linkService.setViewer(viewer)
this.eventBus = eventBus
this.linkService = linkService
this.viewer = viewer
}
// load a document from a URL
async loadDocument(url) {
const doc = await PDFJS.getDocument({
url,
cMapUrl,
cMapPacked: true,
disableFontFace,
rangeChunkSize,
disableAutoFetch: true,
disableStream: true,
textLayerMode: 2, // PDFJSViewer.TextLayerMode.ENABLE,
}).promise
this.viewer.setDocument(doc)
this.linkService.setDocument(doc)
return doc
}
// update the current scale value if the container size changes
updateOnResize() {
const currentScaleValue = this.viewer.currentScaleValue
if (
currentScaleValue === 'auto' ||
currentScaleValue === 'page-fit' ||
currentScaleValue === 'page-width'
) {
this.viewer.currentScaleValue = currentScaleValue
}
this.viewer.update()
}
// get the page and offset of a click event
clickPosition(event, pageElement, textLayer) {
const { viewport } = this.viewer.getPageView(textLayer.pageNumber - 1)
const pageRect = pageElement.querySelector('canvas').getBoundingClientRect()
const dx = event.clientX - pageRect.left
const dy = event.clientY - pageRect.top
const [left, top] = viewport.convertToPdfPoint(dx, dy)
return {
page: textLayer.pageNumber - 1,
offset: {
left,
top: viewport.viewBox[3] - top,
},
}
}
// get the current page, offset and page size
get currentPosition() {
const pageIndex = this.viewer.currentPageNumber - 1
const pageView = this.viewer.getPageView(pageIndex)
const pageRect = pageView.div.getBoundingClientRect()
const containerRect = this.container.getBoundingClientRect()
const dy = containerRect.top - pageRect.top
const [, , width, height] = pageView.viewport.viewBox
const [, top] = pageView.viewport.convertToPdfPoint(0, dy)
return {
page: pageIndex,
offset: { top, left: 0 },
pageSize: { height, width },
}
}
}

View file

@ -314,6 +314,19 @@ If the project has been renamed please look in your project list for a new proje
$scope.switchToSideBySideLayout() $scope.switchToSideBySideLayout()
} }
// note: { keyShortcut: true } exists only for tracking purposes.
$scope.recompileViaKey = () => {
if ($scope.recompile) {
$scope.recompile({ keyShortcut: true })
} else {
window.dispatchEvent(
new CustomEvent('pdf:recompile', {
detail: { keyShortcut: true },
})
)
}
}
$scope.handleKeyDown = event => { $scope.handleKeyDown = event => {
if (event.shiftKey || event.altKey) { if (event.shiftKey || event.altKey) {
return return

View file

@ -450,6 +450,9 @@ Something went wrong connecting to your project. Please refresh if this continue
this.$scope.project = { ...defaultProjectAttributes, ...project } this.$scope.project = { ...defaultProjectAttributes, ...project }
this.$scope.permissionsLevel = permissionsLevel this.$scope.permissionsLevel = permissionsLevel
this.ide.loadingManager.socketLoaded() this.ide.loadingManager.socketLoaded()
window.dispatchEvent(
new CustomEvent('project:joined', { detail: this.$scope.project })
)
this.$scope.$broadcast('project:joined') this.$scope.$broadcast('project:joined')
}) })
} }

View file

@ -653,12 +653,18 @@ export default Document = (function () {
}) })
this.doc.on('change', (ops, oldSnapshot, msg) => { this.doc.on('change', (ops, oldSnapshot, msg) => {
this._applyOpsToRanges(ops, oldSnapshot, msg) this._applyOpsToRanges(ops, oldSnapshot, msg)
window.dispatchEvent(
new CustomEvent('doc:changed', { detail: { id: this.doc_id } })
)
return this.ide.$scope.$emit('doc:changed', { doc_id: this.doc_id }) return this.ide.$scope.$emit('doc:changed', { doc_id: this.doc_id })
}) })
this.doc.on('flipped_pending_to_inflight', () => { this.doc.on('flipped_pending_to_inflight', () => {
return this.trigger('flipped_pending_to_inflight') return this.trigger('flipped_pending_to_inflight')
}) })
return this.doc.on('saved', () => { return this.doc.on('saved', () => {
window.dispatchEvent(
new CustomEvent('doc:saved', { detail: { id: this.doc_id } })
)
return this.ide.$scope.$emit('doc:saved', { doc_id: this.doc_id }) return this.ide.$scope.$emit('doc:saved', { doc_id: this.doc_id })
}) })
} }

View file

@ -96,6 +96,12 @@ export default EditorManager = (function () {
this.$scope.$on('flush-changes', () => { this.$scope.$on('flush-changes', () => {
return Document.flushAll() return Document.flushAll()
}) })
// event dispatched by pdf preview
window.addEventListener('flush-changes', () => {
Document.flushAll()
})
window.addEventListener('blur', () => { window.addEventListener('blur', () => {
// The browser may put the tab into sleep as it looses focus. // The browser may put the tab into sleep as it looses focus.
// Flushing the documents should help with keeping the documents in // Flushing the documents should help with keeping the documents in
@ -112,6 +118,11 @@ export default EditorManager = (function () {
} }
return this._syncTrackChangesState(this.$scope.editor.sharejs_doc) return this._syncTrackChangesState(this.$scope.editor.sharejs_doc)
}) })
window.addEventListener('editor:open-doc', event => {
const { doc, ...options } = event.detail
this.openDoc(doc, options)
})
} }
showRichText() { showRichText() {

View file

@ -834,10 +834,6 @@ App.controller(
// This needs to be public. // This needs to be public.
ide.$scope.recompile = $scope.recompile ide.$scope.recompile = $scope.recompile
// This method is a simply wrapper and exists only for tracking purposes.
ide.$scope.recompileViaKey = function () {
$scope.recompile({ keyShortcut: true })
}
$scope.stop = function () { $scope.stop = function () {
if (!$scope.pdf.compiling) { if (!$scope.pdf.compiling) {

View file

@ -1,4 +1,5 @@
import App from '../../../base' import App from '../../../base'
import { sendMBOnce } from '../../../infrastructure/event-tracking'
App.controller('PdfSynctexController', function ($scope, synctex, ide) { App.controller('PdfSynctexController', function ($scope, synctex, ide) {
this.cursorPosition = null this.cursorPosition = null
@ -27,17 +28,41 @@ App.controller('PdfSynctexController', function ($scope, synctex, ide) {
ide.$scope.$on('cursor:editor:syncToPdf', $scope.syncToPdf) ide.$scope.$on('cursor:editor:syncToPdf', $scope.syncToPdf)
$scope.syncToCode = function () { function syncToPosition(position, options) {
synctex synctex.syncToCode(position, options).then(data => {
.syncToCode($scope.pdf.position, { ide.editorManager.openDoc(data.doc, {
includeVisualOffset: true, gotoLine: data.line,
fromPdfPosition: true,
})
.then(function (data) {
const { doc, line } = data
ide.editorManager.openDoc(doc, { gotoLine: line })
}) })
})
} }
$scope.syncToCode = function () {
syncToPosition($scope.pdf.position, {
includeVisualOffset: true,
fromPdfPosition: true,
})
}
window.addEventListener('synctex:sync-to-position', event => {
syncToPosition(event.detail, {
fromPdfPosition: true,
})
})
window.addEventListener('synctex:sync-to-entry', event => {
sendMBOnce('logs-jump-to-location-once')
const entry = event.detail
const entity = ide.fileTreeManager.findEntityByPath(entry.file)
if (entity && entity.type === 'doc') {
ide.editorManager.openDoc(entity, {
gotoLine: entry.line ?? undefined,
gotoColumn: entry.column ?? undefined,
})
}
})
}) })
App.factory('synctex', function (ide, $http, $q) { App.factory('synctex', function (ide, $http, $q) {

View file

@ -13,6 +13,7 @@ export function setupContext() {
user: window.user, user: window.user,
project: {}, project: {},
$watch: () => {}, $watch: () => {},
$applyAsync: () => {},
ui: { ui: {
chatOpen: true, chatOpen: true,
pdfLayout: 'flat', pdfLayout: 'flat',
@ -27,6 +28,10 @@ export function setupContext() {
on: sinon.stub(), on: sinon.stub(),
removeListener: sinon.stub(), removeListener: sinon.stub(),
}, },
fileTreeManager: {
findEntityByPath: () => null,
getRootDocDirname: () => undefined,
},
} }
window.ExposedSettings = window.ExposedSettings || {} window.ExposedSettings = window.ExposedSettings || {}
window.ExposedSettings.appName = 'Overleaf' window.ExposedSettings.appName = 'Overleaf'

View file

@ -0,0 +1,114 @@
import PdfJsViewer from '../js/features/pdf-preview/components/pdf-js-viewer'
import useFetchMock from './hooks/use-fetch-mock'
import examplePdf from './fixtures/storybook-example.pdf'
import { Button } from 'react-bootstrap'
import { useCallback } from 'react'
import { withContextRoot } from './utils/with-context-root'
import { setupContext } from './fixtures/context'
import useScopeValue from '../js/shared/context/util/scope-value-hook'
setupContext()
export default {
title: 'PDF Viewer',
component: PdfJsViewer,
}
const project = {
_id: 'story-project',
}
const mockHighlights = [
{
page: 1,
h: 85.03936,
v: 509.999878,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 486.089539,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 498.044708,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 521.955078,
width: 441.921265,
height: 8.855677,
},
]
export const Interactive = () => {
useFetchMock(fetchMock => {
fetchMock.get(
'express:/build/output.pdf',
(url, options, request) => {
return new Promise(resolve => {
const xhr = new XMLHttpRequest()
xhr.addEventListener('load', () => {
resolve({
status: 200,
headers: {
'Content-Length': xhr.getResponseHeader('Content-Length'),
'Content-Type': xhr.getResponseHeader('Content-Type'),
'Accept-Ranges': 'bytes',
},
body: xhr.response,
})
})
xhr.open('GET', examplePdf)
xhr.responseType = 'arraybuffer'
xhr.send()
})
},
{ sendAsJson: false }
)
})
const Inner = () => {
const [, setHighlights] = useScopeValue('pdf.highlights')
const dispatchSyncFromCode = useCallback(() => {
setHighlights([])
window.setTimeout(() => {
setHighlights(mockHighlights)
}, 0)
}, [setHighlights])
return (
<div
style={{
zIndex: 10,
position: 'absolute',
top: 20,
right: 40,
}}
>
<div style={{ display: 'flex', gap: 20 }}>
<Button onClick={dispatchSyncFromCode}>
sync position from editor
</Button>
</div>
</div>
)
}
return withContextRoot(
<div className="pdf-viewer">
<PdfJsViewer url="/build/output.pdf" />
<Inner />
</div>,
{ scope: { project } }
)
}

View file

@ -0,0 +1,562 @@
import { withContextRoot } from './utils/with-context-root'
import { useCallback, useEffect, useMemo, useState } from 'react'
import useFetchMock from './hooks/use-fetch-mock'
import { setupContext } from './fixtures/context'
import { Button } from 'react-bootstrap'
import PdfPreviewProvider, {
usePdfPreviewContext,
} from '../js/features/pdf-preview/contexts/pdf-preview-context'
import PdfPreviewPane from '../js/features/pdf-preview/components/pdf-preview-pane'
import PdfPreview from '../js/features/pdf-preview/components/pdf-preview'
import PdfPreviewToolbar from '../js/features/pdf-preview/components/pdf-preview-toolbar'
import PdfFileList from '../js/features/pdf-preview/components/pdf-file-list'
import { buildFileList } from '../js/features/pdf-preview/util/file-list'
import PdfLogsViewer from '../js/features/pdf-preview/components/pdf-logs-viewer'
import examplePdf from './fixtures/storybook-example.pdf'
import PdfPreviewError from '../js/features/pdf-preview/components/pdf-preview-error'
setupContext()
export default {
title: 'PDF Preview',
component: PdfPreview,
subcomponents: {
PdfPreviewToolbar,
PdfFileList,
PdfPreviewError,
},
}
const project = {
_id: 'a-project',
name: 'A Project',
features: {},
tokens: {},
owner: {
_id: 'a-user',
email: 'stories@overleaf.com',
},
members: [],
invites: [],
}
const scope = {
project,
settings: {
syntaxValidation: true,
},
hasLintingError: false,
$applyAsync: () => {},
}
const dispatchProjectJoined = () => {
window.dispatchEvent(new CustomEvent('project:joined', { detail: project }))
}
const dispatchDocChanged = () => {
window.dispatchEvent(
new CustomEvent('doc:changed', { detail: { doc_id: 'foo' } })
)
}
const outputFiles = [
{
path: 'output.pdf',
build: '123',
url: '/build/output.pdf',
type: 'pdf',
},
{
path: 'output.bbl',
build: '123',
url: '/build/output.bbl',
type: 'bbl',
},
{
path: 'output.bib',
build: '123',
url: '/build/output.bib',
type: 'bib',
},
{
path: 'example.txt',
build: '123',
url: '/build/example.txt',
type: 'txt',
},
{
path: 'output.log',
build: '123',
url: '/build/output.log',
type: 'log',
},
{
path: 'output.blg',
build: '123',
url: '/build/output.blg',
type: 'blg',
},
]
const mockCompile = (fetchMock, delay = 1000) =>
fetchMock.post(
'express:/project/:projectId/compile',
{
body: {
status: 'success',
clsiServerId: 'foo',
compileGroup: 'priority',
pdfDownloadDomain: '',
outputFiles,
},
},
{ delay }
)
const mockCompileError = (fetchMock, status = 'success', delay = 1000) =>
fetchMock.post(
'express:/project/:projectId/compile',
{
body: {
status,
clsiServerId: 'foo',
compileGroup: 'priority',
},
},
{ delay, overwriteRoutes: true }
)
const mockCompileValidationIssues = (
fetchMock,
validationProblems,
delay = 1000
) =>
fetchMock.post(
'express:/project/:projectId/compile',
() => {
return {
body: {
status: 'validation-problems',
validationProblems,
clsiServerId: 'foo',
compileGroup: 'priority',
},
}
},
{ delay, overwriteRoutes: true }
)
const mockClearCache = fetchMock =>
fetchMock.delete('express:/project/:projectId/output', 204, {
delay: 1000,
})
const mockBuildFile = fetchMock =>
fetchMock.get(
'express:/build/:file',
(url, options, request) => {
const { pathname } = new URL(url, 'https://example.com')
switch (pathname) {
case '/build/output.blg':
return 'This is BibTeX, Version 4.0' // FIXME
case '/build/output.log':
return `
The LaTeX compiler output
* With a lot of details
Wrapped in an HTML <pre> element with
preformatted text which is to be presented exactly
as written in the HTML file
(whitespace included)
The text is typically rendered using a non-proportional ("monospace") font.
LaTeX Font Info: External font \`cmex10' loaded for size
(Font) <7> on input line 18.
LaTeX Font Info: External font \`cmex10' loaded for size
(Font) <5> on input line 18.
! Undefined control sequence.
<recently read> \\Zlpha
main.tex, line 23
`
case '/build/output.pdf':
return new Promise(resolve => {
const xhr = new XMLHttpRequest()
xhr.addEventListener('load', () => {
resolve({
status: 200,
headers: {
'Content-Length': xhr.getResponseHeader('Content-Length'),
'Content-Type': xhr.getResponseHeader('Content-Type'),
},
body: xhr.response,
})
})
xhr.open('GET', examplePdf)
xhr.responseType = 'arraybuffer'
xhr.send()
})
default:
return 404
}
},
{ sendAsJson: false }
)
export const Interactive = () => {
useFetchMock(fetchMock => {
mockCompile(fetchMock)
mockBuildFile(fetchMock)
mockClearCache(fetchMock)
})
useEffect(() => {
dispatchProjectJoined()
}, [])
const Inner = () => {
const context = usePdfPreviewContext()
const { setHasLintingError } = context
const toggleLintingError = useCallback(() => {
setHasLintingError(value => !value)
}, [setHasLintingError])
const values = useMemo(() => {
const entries = Object.entries(context).sort((a, b) => {
return a[0].localeCompare(b[0])
})
const values = { boolean: [], other: [] }
for (const entry of entries) {
const type = typeof entry[1]
if (type === 'boolean') {
values.boolean.push(entry)
} else if (type !== 'function') {
values.other.push(entry)
}
}
return values
}, [context])
return (
<div
style={{
padding: 20,
background: 'white',
float: 'left',
zIndex: 10,
position: 'absolute',
top: 60,
bottom: 60,
right: 20,
left: 400,
overflow: 'hidden',
boxShadow: '0px 2px 5px #ccc',
borderRadius: 3,
}}
>
<div
style={{
display: 'flex',
fontSize: 14,
gap: 20,
height: '100%',
overflow: 'hidden',
}}
>
<div style={{ height: '100%', overflow: 'auto', flexShrink: 0 }}>
<table>
<tbody>
{values.boolean.map(([key, value]) => {
return (
<tr key={key} style={{ border: '1px solid #ddd' }}>
<td style={{ padding: 5 }}>{value ? '🟢' : '🔴'}</td>
<th style={{ padding: 5 }}>{key}</th>
</tr>
)
})}
</tbody>
</table>
<div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
margin: '10px 0',
}}
>
<Button onClick={dispatchDocChanged}>trigger doc change</Button>
<Button onClick={toggleLintingError}>
toggle linting error
</Button>
</div>
</div>
</div>
<div style={{ height: '100%', overflow: 'auto' }}>
<table
style={{
width: '100%',
overflow: 'hidden',
}}
>
<tbody>
{values.other.map(([key, value]) => {
return (
<tr
key={key}
style={{
width: '100%',
overflow: 'hidden',
border: '1px solid #ddd',
}}
>
<th
style={{
verticalAlign: 'top',
padding: 5,
}}
>
{key}
</th>
<td
style={{
overflow: 'auto',
padding: 5,
}}
>
<pre
style={{
margin: '0 10px',
fontSize: 10,
}}
>
{JSON.stringify(value, null, 2)}
</pre>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)
}
return withContextRoot(
<div className="pdf-viewer">
<PdfPreviewProvider>
<PdfPreviewPane />
<Inner />
</PdfPreviewProvider>
</div>,
scope
)
}
const compileStatuses = [
'autocompile-backoff',
'clear-cache',
'clsi-maintenance',
'compile-in-progress',
'exited',
'failure',
'generic',
'project-too-large',
'rate-limited',
'success',
'terminated',
'timedout',
'too-recently-compiled',
'unavailable',
'validation-problems',
'foo',
]
export const CompileError = () => {
const [status, setStatus] = useState('success')
useFetchMock(fetchMock => {
mockCompileError(fetchMock, status, 0)
mockBuildFile(fetchMock)
})
const Inner = () => {
const { recompile } = usePdfPreviewContext()
const handleStatusChange = useCallback(
event => {
setStatus(event.target.value)
window.setTimeout(() => {
recompile()
}, 0)
},
[recompile]
)
return (
<div
style={{
position: 'absolute',
bottom: 10,
left: 10,
background: 'white',
padding: 10,
}}
>
<label>
{'status: '}
<select value={status} onInput={handleStatusChange}>
{compileStatuses.map(status => (
<option key={status}>{status}</option>
))}
</select>
</label>
</div>
)
}
return withContextRoot(
<PdfPreviewProvider>
<PdfPreviewPane />
<Inner />
</PdfPreviewProvider>,
scope
)
}
const compileErrors = [
'autocompile-backoff',
'clear-cache',
'clsi-maintenance',
'compile-in-progress',
'exited',
'failure',
'generic',
'project-too-large',
'rate-limited',
'success',
'terminated',
'timedout',
'too-recently-compiled',
'unavailable',
'validation-problems',
'foo',
]
export const DisplayError = () => {
return withContextRoot(
<PdfPreviewProvider>
{compileErrors.map(error => (
<div
key={error}
style={{ background: '#5d6879', padding: 10, margin: 5 }}
>
<div style={{ fontFamily: 'monospace', color: 'white' }}>{error}</div>
<PdfPreviewError error={error} />
</div>
))}
</PdfPreviewProvider>,
scope
)
}
export const Toolbar = () => {
useFetchMock(fetchMock => mockCompile(fetchMock, 500))
return withContextRoot(
<PdfPreviewProvider>
<div className="pdf">
<PdfPreviewToolbar />
</div>
</PdfPreviewProvider>,
scope
)
}
export const FileList = () => {
const fileList = useMemo(() => {
return buildFileList(outputFiles)
}, [])
return (
<div className="dropdown open">
<div className="dropdown-menu">
<PdfFileList fileList={fileList} />
</div>
</div>
)
}
export const Logs = () => {
useFetchMock(fetchMock => {
mockCompile(fetchMock, 0)
mockBuildFile(fetchMock)
mockClearCache(fetchMock)
})
useEffect(() => {
dispatchProjectJoined()
}, [])
return withContextRoot(
<PdfPreviewProvider>
<div className="pdf">
<PdfLogsViewer />
</div>
</PdfPreviewProvider>,
scope
)
}
const validationProblems = {
sizeCheck: {
resources: [
{ path: 'foo/bar', kbSize: 76221 },
{ path: 'bar/baz', kbSize: 2342 },
],
},
mainFile: true,
conflictedPaths: [
{
path: 'foo/bar',
},
{
path: 'foo/baz',
},
],
}
export const ValidationIssues = () => {
useFetchMock(fetchMock => {
mockCompileValidationIssues(fetchMock, validationProblems, 0)
mockBuildFile(fetchMock)
})
useEffect(() => {
dispatchProjectJoined()
}, [])
return withContextRoot(
<PdfPreviewProvider>
<PdfPreviewPane />
</PdfPreviewProvider>,
scope
)
}

View file

@ -1,15 +1,29 @@
import { ContextRoot } from '../../js/shared/context/root-context' import { ContextRoot } from '../../js/shared/context/root-context'
import _ from 'lodash'
// Unfortunately, we cannot currently use decorators here, since we need to // Unfortunately, we cannot currently use decorators here, since we need to
// set a value on window, before the contexts are rendered. // set a value on window, before the contexts are rendered.
// When using decorators, the contexts are rendered before the story, so we // When using decorators, the contexts are rendered before the story, so we
// don't have the opportunity to set the window value first. // don't have the opportunity to set the window value first.
export function withContextRoot(Story, scope) { export function withContextRoot(Story, scope) {
const scopeWatchers = []
const ide = { const ide = {
...window._ide, ...window._ide,
$scope: { $scope: {
...window._ide.$scope, ...window._ide.$scope,
...scope, ...scope,
$watch: (key, callback) => {
scopeWatchers.push([key, callback])
},
$applyAsync: callback => {
window.setTimeout(() => {
callback()
for (const [key, watcher] of scopeWatchers) {
watcher(_.get(ide.$scope, key))
}
}, 0)
},
}, },
} }

View file

@ -146,6 +146,17 @@
box-sizing: content-box; box-sizing: content-box;
user-select: none; user-select: none;
} }
.page {
box-sizing: content-box;
margin-left: auto;
margin-right: auto;
}
.pdfjs-viewer-inner {
position: absolute;
overflow: auto;
width: 100%;
height: 100%;
}
&:focus-within { &:focus-within {
outline: none; outline: none;
} }
@ -189,6 +200,12 @@
border-bottom: 2px solid white; border-bottom: 2px solid white;
} }
} }
.pdfjs-error {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
}
} }
.pdf-logs { .pdf-logs {

View file

@ -225,3 +225,22 @@
[data-toggle='buttons'] > .btn > input[type='checkbox'] { [data-toggle='buttons'] > .btn > input[type='checkbox'] {
display: none; display: none;
} }
// button group as toolbar item
.btn-group.toolbar-item {
display: flex;
flex-wrap: nowrap;
}
// allow hiding toolbar content at various breakpoints
.toolbar-large .toolbar-hide-large {
display: none !important;
}
.toolbar-medium .toolbar-hide-medium {
display: none !important;
}
.toolbar-small .toolbar-hide-small {
display: none !important;
}

View file

@ -11609,7 +11609,8 @@
"ajv-keywords": { "ajv-keywords": {
"version": "3.5.2", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==" "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
"dev": true
}, },
"algoliasearch": { "algoliasearch": {
"version": "3.35.1", "version": "3.35.1",
@ -13805,7 +13806,8 @@
"big.js": { "big.js": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz", "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz",
"integrity": "sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==" "integrity": "sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==",
"dev": true
}, },
"bignumber.js": { "bignumber.js": {
"version": "9.0.1", "version": "9.0.1",
@ -17970,7 +17972,8 @@
"emojis-list": { "emojis-list": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz",
"integrity": "sha512-knHEZMgs8BB+MInokmNTg/OyPlAddghe1YBgNwJBc5zsJi/uyIcXoSDsL/W9ymOsBoBGdPIHXYJ9+qKFwRwDng==" "integrity": "sha512-knHEZMgs8BB+MInokmNTg/OyPlAddghe1YBgNwJBc5zsJi/uyIcXoSDsL/W9ymOsBoBGdPIHXYJ9+qKFwRwDng==",
"dev": true
}, },
"emotion-theming": { "emotion-theming": {
"version": "10.0.27", "version": "10.0.27",
@ -25161,6 +25164,7 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.1.0.tgz", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.1.0.tgz",
"integrity": "sha512-gkD9aSEG9UGglyPcDJqY9YBTUtCLKaBK6ihD2VP1d1X60lTfFspNZNulGBBbUZLkPygy4LySYHyxBpq+VhjObQ==", "integrity": "sha512-gkD9aSEG9UGglyPcDJqY9YBTUtCLKaBK6ihD2VP1d1X60lTfFspNZNulGBBbUZLkPygy4LySYHyxBpq+VhjObQ==",
"dev": true,
"requires": { "requires": {
"big.js": "^3.1.3", "big.js": "^3.1.3",
"emojis-list": "^2.0.0", "emojis-list": "^2.0.0",
@ -25170,7 +25174,8 @@
"json5": { "json5": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz",
"integrity": "sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw==" "integrity": "sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw==",
"dev": true
} }
} }
}, },
@ -27021,11 +27026,6 @@
"minimatch": "^3.0.2" "minimatch": "^3.0.2"
} }
}, },
"node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw=="
},
"node-fetch": { "node-fetch": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
@ -28974,13 +28974,9 @@
} }
}, },
"pdfjs-dist": { "pdfjs-dist": {
"version": "2.2.228", "version": "2.9.359",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.2.228.tgz", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.9.359.tgz",
"integrity": "sha512-W5LhYPMS2UKX0ELIa4u+CFCMoox5qQNQElt0bAK2mwz1V8jZL0rvLao+0tBujce84PK6PvWG36Nwr7agCCWFGQ==", "integrity": "sha512-P2nYtkacdlZaNNwrBLw1ZyMm0oE2yY/5S/GDCAmMJ7U4+ciL/D0mrlEC/o4HZZc/LNE3w8lEVzBEyVgEQlPVKQ=="
"requires": {
"node-ensure": "^0.0.0",
"worker-loader": "^2.0.0"
}
}, },
"pend": { "pend": {
"version": "1.2.0", "version": "1.2.0",
@ -33090,6 +33086,7 @@
"version": "0.4.7", "version": "0.4.7",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz",
"integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==", "integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==",
"dev": true,
"requires": { "requires": {
"ajv": "^6.1.0", "ajv": "^6.1.0",
"ajv-keywords": "^3.1.0" "ajv-keywords": "^3.1.0"
@ -38635,6 +38632,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz", "resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz",
"integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==", "integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==",
"dev": true,
"requires": { "requires": {
"loader-utils": "^1.0.0", "loader-utils": "^1.0.0",
"schema-utils": "^0.4.0" "schema-utils": "^0.4.0"

View file

@ -143,7 +143,7 @@
"passport-orcid": "0.0.4", "passport-orcid": "0.0.4",
"passport-saml": "https://github.com/overleaf/passport-saml/releases/download/v3.0.0-overleaf/passport-saml-3.0.0-overleaf.tar.gz", "passport-saml": "https://github.com/overleaf/passport-saml/releases/download/v3.0.0-overleaf/passport-saml-3.0.0-overleaf.tar.gz",
"passport-twitter": "^1.0.4", "passport-twitter": "^1.0.4",
"pdfjs-dist": "^2.2.228", "pdfjs-dist": "^2.9.359",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"pug": "^3.0.1", "pug": "^3.0.1",
"pug-runtime": "^3.0.1", "pug-runtime": "^3.0.1",
@ -261,6 +261,7 @@
"webpack-assets-manifest": "^4.0.6", "webpack-assets-manifest": "^4.0.6",
"webpack-cli": "^3.3.11", "webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.11.0", "webpack-dev-server": "^3.11.0",
"webpack-merge": "^4.2.2" "webpack-merge": "^4.2.2",
"worker-loader": "^2.0.0"
} }
} }

View file

@ -0,0 +1,382 @@
import { expect } from 'chai'
import sinon from 'sinon'
import fetchMock from 'fetch-mock'
import { screen, fireEvent, waitFor } from '@testing-library/react'
import PdfPreview from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
const outputFiles = [
{
path: 'output.pdf',
build: '123',
// url: 'about:blank', // TODO: PDF URL to render
type: 'pdf',
},
{
path: 'output.bbl',
build: '123',
url: '/build/output.bbl',
type: 'bbl',
},
{
path: 'output.bib',
build: '123',
url: '/build/output.bib',
type: 'bib',
},
{
path: 'example.txt',
build: '123',
url: '/build/example.txt',
type: 'txt',
},
{
path: 'output.log',
build: '123',
url: '/build/output.log',
type: 'log',
},
{
path: 'output.blg',
build: '123',
url: '/build/output.blg',
type: 'blg',
},
]
const mockCompile = () =>
fetchMock.post('express:/project/:projectId/compile', {
body: {
status: 'success',
clsiServerId: 'foo',
compileGroup: 'priority',
pdfDownloadDomain: '',
outputFiles,
},
})
const mockCompileError = status =>
fetchMock.post('express:/project/:projectId/compile', {
body: {
status,
clsiServerId: 'foo',
compileGroup: 'priority',
},
})
const mockValidationProblems = validationProblems =>
fetchMock.post('express:/project/:projectId/compile', {
body: {
status: 'validation-problems',
validationProblems,
clsiServerId: 'foo',
compileGroup: 'priority',
},
})
const mockClearCache = () =>
fetchMock.delete('express:/project/:projectId/output', 204)
const defaultFileResponses = {
'/build/output.blg': 'This is BibTeX, Version 4.0', // FIXME
'/build/output.log': `
The LaTeX compiler output
* With a lot of details
Wrapped in an HTML <pre> element with
preformatted text which is to be presented exactly
as written in the HTML file
(whitespace included)
The text is typically rendered using a non-proportional ("monospace") font.
LaTeX Font Info: External font \`cmex10' loaded for size
(Font) <7> on input line 18.
LaTeX Font Info: External font \`cmex10' loaded for size
(Font) <5> on input line 18.
! Undefined control sequence.
<recently read> \\Zlpha
main.tex, line 23
`,
}
const mockBuildFile = (responses = defaultFileResponses) =>
fetchMock.get('express:/build/:file', (_url, options, request) => {
const url = new URL(_url, 'https://example.com')
if (url.pathname in responses) {
return responses[url.pathname]
}
return 404
})
const storeAndFireEvent = (key, value) => {
localStorage.setItem(key, value)
fireEvent(window, new StorageEvent('storage', { key }))
}
describe('<PdfPreview/>', function () {
var clock
beforeEach(function () {
clock = sinon.useFakeTimers({
shouldAdvanceTime: true,
now: Date.now(),
})
// xhrMock.setup()
})
afterEach(function () {
clock.runAll()
clock.restore()
// xhrMock.teardown()
fetchMock.reset()
localStorage.clear()
})
it('renders the PDF preview', function () {
renderWithEditorContext(<PdfPreview />)
screen.getByRole('button', { name: 'Recompile' })
})
it('runs a compile only on the first project:joined event', async function () {
mockCompile()
mockBuildFile()
renderWithEditorContext(<PdfPreview />)
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' })
// fire another project:joined event => no compile
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Recompile' })
expect(fetchMock.calls()).to.have.length(3)
expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path
})
it('runs a compile when the Recompile button is pressed', async function () {
mockCompile()
mockBuildFile()
renderWithEditorContext(<PdfPreview />)
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' })
// press the Recompile button => compile
const button = screen.getByRole('button', { name: 'Recompile' })
button.click()
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' })
expect(fetchMock.calls()).to.have.length(6)
})
it('runs a compile on doc change if autocompile is enabled', async function () {
mockCompile()
mockBuildFile()
renderWithEditorContext(<PdfPreview />)
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' })
// switch on auto compile
storeAndFireEvent('autocompile_enabled:project123', true)
// fire a doc:changed event => compile
fireEvent(window, new CustomEvent('doc:changed'))
clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
await screen.findByText('Compiling…')
await screen.findByRole('button', { name: 'Recompile' })
expect(fetchMock.calls()).to.have.length(6)
})
it('does not run a compile on doc change if autocompile is disabled', async function () {
mockCompile()
mockBuildFile()
renderWithEditorContext(<PdfPreview />)
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' })
// make sure auto compile is switched off
storeAndFireEvent('autocompile_enabled:project123', false)
screen.getByRole('button', { name: 'Recompile' })
// fire a doc:changed event => no compile
fireEvent(window, new CustomEvent('doc:changed'))
clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
screen.getByRole('button', { name: 'Recompile' })
expect(fetchMock.calls()).to.have.length(3)
})
it('does not run a compile on doc change if autocompile is blocked by syntax check', async function () {
mockCompile()
mockBuildFile()
renderWithEditorContext(<PdfPreview />, {
scope: {
'settings.syntaxValidation': true, // enable linting in the editor
hasLintingError: true, // mock a linting error
},
})
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' })
// switch on auto compile and syntax checking
storeAndFireEvent('autocompile_enabled:project123', true)
storeAndFireEvent('stop_on_validation_error:project123', true)
screen.getByRole('button', { name: 'Recompile' })
// fire a doc:changed event => no compile
fireEvent(window, new CustomEvent('doc:changed'))
clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
screen.getByRole('button', { name: 'Recompile' })
await screen.findByText('Code check failed')
expect(fetchMock.calls()).to.have.length(3)
})
it('displays an error message if there was a compile error', async function () {
mockCompileError('compile-in-progress')
renderWithEditorContext(<PdfPreview />)
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' })
screen.getByText(
'Please wait for your other compile to finish before trying again.'
)
expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path
})
it('disables the view logs button if there is a compile error', async function () {
mockCompileError()
mockBuildFile()
renderWithEditorContext(<PdfPreview />)
const logsButton = screen.getByRole('button', { name: 'View logs' })
expect(logsButton.hasAttribute('disabled')).to.be.false
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
expect(logsButton.hasAttribute('disabled')).to.be.false
await screen.findByRole('button', { name: 'Recompile' })
expect(logsButton.hasAttribute('disabled')).to.be.true
expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path
})
it('displays error messages if there were validation problems', async function () {
const validationProblems = {
sizeCheck: {
resources: [
{ path: 'foo/bar', kbSize: 76221 },
{ path: 'bar/baz', kbSize: 2342 },
],
},
mainFile: true,
conflictedPaths: [
{
path: 'foo/bar',
},
{
path: 'foo/baz',
},
],
}
mockValidationProblems(validationProblems)
renderWithEditorContext(<PdfPreview />)
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
await screen.findByRole('button', { name: 'Recompile' })
screen.getByText('Project too large')
screen.getByText('Unknown main document')
screen.getByText('Conflicting Paths Found')
expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path
})
it('sends a clear cache request when the button is pressed', async function () {
mockCompile()
mockBuildFile()
mockClearCache()
renderWithEditorContext(<PdfPreview />)
const logsButton = screen.getByRole('button', { name: 'View logs' })
logsButton.click()
let clearCacheButton = screen.getByRole('button', {
name: 'Clear cached files',
})
expect(clearCacheButton.hasAttribute('disabled')).to.be.false
// fire a project:joined event => compile
screen.getByRole('button', { name: 'Recompile' })
fireEvent(window, new CustomEvent('project:joined'))
screen.getByRole('button', { name: 'Compiling…' })
expect(clearCacheButton.hasAttribute('disabled')).to.be.true
await screen.findByRole('button', { name: 'Recompile' })
logsButton.click()
clearCacheButton = await screen.findByRole('button', {
name: 'Clear cached files',
})
expect(clearCacheButton.hasAttribute('disabled')).to.be.false
// click the button
clearCacheButton.click()
expect(clearCacheButton.hasAttribute('disabled')).to.be.true
await waitFor(() => {
expect(clearCacheButton.hasAttribute('disabled')).to.be.false
})
expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path
})
})

View file

@ -11,6 +11,7 @@ import { IdeProvider } from '../../../frontend/js/shared/context/ide-context'
import { get } from 'lodash' import { get } from 'lodash'
import { ProjectProvider } from '../../../frontend/js/shared/context/project-context' import { ProjectProvider } from '../../../frontend/js/shared/context/project-context'
import { SplitTestProvider } from '../../../frontend/js/shared/context/split-test-context' import { SplitTestProvider } from '../../../frontend/js/shared/context/split-test-context'
import { CompileProvider } from '../../../frontend/js/shared/context/compile-context'
export function EditorProviders({ export function EditorProviders({
user = { id: '123abd' }, user = { id: '123abd' },
@ -52,7 +53,17 @@ export function EditorProviders({
...scope, ...scope,
} }
window._ide = { $scope, socket, clsiServerId } const fileTreeManager = {
findEntityByPath: () => null,
getRootDocDirname: () => '',
}
window._ide = {
$scope,
socket,
clsiServerId,
fileTreeManager,
}
return ( return (
<SplitTestProvider> <SplitTestProvider>
@ -60,7 +71,9 @@ export function EditorProviders({
<UserProvider> <UserProvider>
<ProjectProvider> <ProjectProvider>
<EditorProvider settings={{}}> <EditorProvider settings={{}}>
<LayoutProvider>{children}</LayoutProvider> <CompileProvider>
<LayoutProvider>{children}</LayoutProvider>
</CompileProvider>
</EditorProvider> </EditorProvider>
</ProjectProvider> </ProjectProvider>
</UserProvider> </UserProvider>

View file

@ -159,6 +159,21 @@ module.exports = {
}, },
], ],
}, },
{
// Load images (static files)
test: /\.(svg|gif|png|jpg|pdf)$/,
use: [
{
loader: 'file-loader',
options: {
// Output to public/images
outputPath: 'images',
publicPath: '/images/',
name: '[name].[ext]',
},
},
],
},
{ {
// These options are necessary for handlebars to have access to helper // These options are necessary for handlebars to have access to helper
// methods // methods
@ -288,9 +303,13 @@ module.exports = {
from: 'node_modules/ace-builds/src-min-noconflict', from: 'node_modules/ace-builds/src-min-noconflict',
to: `js/ace-${PackageVersions.version.ace}/`, to: `js/ace-${PackageVersions.version.ace}/`,
}, },
// Copy CMap files from pdfjs-dist package to build output. These are used // Copy CMap files (used to provide support for non-Latin characters)
// to provide support for non-Latin characters // and static images from pdfjs-dist package to build output.
{ from: 'node_modules/pdfjs-dist/cmaps', to: 'js/cmaps' }, { from: 'node_modules/pdfjs-dist/cmaps', to: 'js/cmaps' },
{
from: 'node_modules/pdfjs-dist/legacy/web/images',
to: 'images',
},
]), ]),
], ],
} }