mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Add React version of the PDF preview pane (#5135)
GitOrigin-RevId: fcc88a362c3e97c9fddf85d47c3a83a0a0b89432
This commit is contained in:
parent
91c31b2523
commit
73bc3418a2
50 changed files with 3678 additions and 47 deletions
|
@ -179,8 +179,9 @@ block append meta
|
|||
meta(name="ol-gitBridgePublicBaseUrl" content=gitBridgePublicBaseUrl)
|
||||
//- Set base path for Ace scripts loaded on demand/workers and don't use cdn
|
||||
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-pdfImageResourcesPath" content="/images/")
|
||||
//- enable doc hash checking for all projects
|
||||
//- used in public/js/libs/sharejs.js
|
||||
meta(name="ol-useShareJsHash" data-type="boolean" content=true)
|
||||
|
|
|
@ -18,14 +18,17 @@ div.full-size(
|
|||
include ./editor-no-symbol-palette
|
||||
|
||||
.ui-layout-east
|
||||
div(ng-if="ui.pdfLayout == 'sideBySide'")
|
||||
if showNewPdfPreview
|
||||
pdf-preview-pane()
|
||||
else
|
||||
// The pdf-preview component needs to always be rendered, even when the editor is in "full-width" mode and it's not visible.
|
||||
// It doesn't recompile while hidden, due to the ui.pdfHidden flag, but maintains its state for when it's shown again.
|
||||
if showNewPdfPreview
|
||||
div(ng-show="ui.pdfLayout == 'sideBySide'")
|
||||
pdf-preview()
|
||||
else
|
||||
div(ng-if="ui.pdfLayout == 'sideBySide'")
|
||||
include ./pdf
|
||||
|
||||
.ui-layout-resizer-controls.synctex-controls(
|
||||
ng-show="!!pdf.url && settings.pdfViewer == 'pdfjs'"
|
||||
ng-show="!!pdf.url && settings.pdfViewer !== 'native'"
|
||||
ng-controller="PdfSynctexController"
|
||||
)
|
||||
a.btn.btn-default.btn-xs.synctex-control.synctex-control-goto-pdf(
|
||||
|
@ -52,7 +55,7 @@ div.full-size(
|
|||
ng-show="ui.view == 'pdf'"
|
||||
)
|
||||
if showNewPdfPreview
|
||||
pdf-preview-pane()
|
||||
pdf-preview()
|
||||
else
|
||||
include ./pdf
|
||||
|
||||
|
|
|
@ -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" />}
|
||||
|
||||
<span>{t('clear_cached_files')}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PdfClearCacheButton)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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,
|
||||
}
|
|
@ -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() {
|
||||
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))
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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} — {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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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" />
|
||||
|
||||
{t('unlimited_projects')}
|
||||
</li>
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
{t('collabs_per_proj', { collabcount: 'Multiple' })}
|
||||
</li>
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
{t('full_doc_history')}
|
||||
</li>
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
{t('sync_to_dropbox')}
|
||||
</li>
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
{t('sync_to_github')}
|
||||
</li>
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
{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)
|
|
@ -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
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import App from '../../../base'
|
||||
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), []))
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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]
|
||||
)
|
||||
}
|
|
@ -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 }
|
||||
},
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
const documentClassRe = /^[^%]*\\documentclass/
|
||||
|
||||
export const isMainFile = doc =>
|
||||
doc.split('\n').some(line => documentClassRe.test(line))
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 },
|
||||
}
|
||||
}
|
||||
}
|
|
@ -314,6 +314,19 @@ If the project has been renamed please look in your project list for a new proje
|
|||
$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 => {
|
||||
if (event.shiftKey || event.altKey) {
|
||||
return
|
||||
|
|
|
@ -450,6 +450,9 @@ Something went wrong connecting to your project. Please refresh if this continue
|
|||
this.$scope.project = { ...defaultProjectAttributes, ...project }
|
||||
this.$scope.permissionsLevel = permissionsLevel
|
||||
this.ide.loadingManager.socketLoaded()
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('project:joined', { detail: this.$scope.project })
|
||||
)
|
||||
this.$scope.$broadcast('project:joined')
|
||||
})
|
||||
}
|
||||
|
|
|
@ -653,12 +653,18 @@ export default Document = (function () {
|
|||
})
|
||||
this.doc.on('change', (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 })
|
||||
})
|
||||
this.doc.on('flipped_pending_to_inflight', () => {
|
||||
return this.trigger('flipped_pending_to_inflight')
|
||||
})
|
||||
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 })
|
||||
})
|
||||
}
|
||||
|
|
|
@ -96,6 +96,12 @@ export default EditorManager = (function () {
|
|||
this.$scope.$on('flush-changes', () => {
|
||||
return Document.flushAll()
|
||||
})
|
||||
|
||||
// event dispatched by pdf preview
|
||||
window.addEventListener('flush-changes', () => {
|
||||
Document.flushAll()
|
||||
})
|
||||
|
||||
window.addEventListener('blur', () => {
|
||||
// The browser may put the tab into sleep as it looses focus.
|
||||
// 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)
|
||||
})
|
||||
|
||||
window.addEventListener('editor:open-doc', event => {
|
||||
const { doc, ...options } = event.detail
|
||||
this.openDoc(doc, options)
|
||||
})
|
||||
}
|
||||
|
||||
showRichText() {
|
||||
|
|
|
@ -834,10 +834,6 @@ App.controller(
|
|||
|
||||
// This needs to be public.
|
||||
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 () {
|
||||
if (!$scope.pdf.compiling) {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import App from '../../../base'
|
||||
import { sendMBOnce } from '../../../infrastructure/event-tracking'
|
||||
|
||||
App.controller('PdfSynctexController', function ($scope, synctex, ide) {
|
||||
this.cursorPosition = null
|
||||
|
@ -27,17 +28,41 @@ App.controller('PdfSynctexController', function ($scope, synctex, ide) {
|
|||
|
||||
ide.$scope.$on('cursor:editor:syncToPdf', $scope.syncToPdf)
|
||||
|
||||
$scope.syncToCode = function () {
|
||||
synctex
|
||||
.syncToCode($scope.pdf.position, {
|
||||
includeVisualOffset: true,
|
||||
fromPdfPosition: true,
|
||||
})
|
||||
.then(function (data) {
|
||||
const { doc, line } = data
|
||||
ide.editorManager.openDoc(doc, { gotoLine: line })
|
||||
function syncToPosition(position, options) {
|
||||
synctex.syncToCode(position, options).then(data => {
|
||||
ide.editorManager.openDoc(data.doc, {
|
||||
gotoLine: data.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) {
|
||||
|
|
|
@ -13,6 +13,7 @@ export function setupContext() {
|
|||
user: window.user,
|
||||
project: {},
|
||||
$watch: () => {},
|
||||
$applyAsync: () => {},
|
||||
ui: {
|
||||
chatOpen: true,
|
||||
pdfLayout: 'flat',
|
||||
|
@ -27,6 +28,10 @@ export function setupContext() {
|
|||
on: sinon.stub(),
|
||||
removeListener: sinon.stub(),
|
||||
},
|
||||
fileTreeManager: {
|
||||
findEntityByPath: () => null,
|
||||
getRootDocDirname: () => undefined,
|
||||
},
|
||||
}
|
||||
window.ExposedSettings = window.ExposedSettings || {}
|
||||
window.ExposedSettings.appName = 'Overleaf'
|
||||
|
|
BIN
services/web/frontend/stories/fixtures/storybook-example.pdf
Normal file
BIN
services/web/frontend/stories/fixtures/storybook-example.pdf
Normal file
Binary file not shown.
114
services/web/frontend/stories/pdf-js-viewer.stories.js
Normal file
114
services/web/frontend/stories/pdf-js-viewer.stories.js
Normal 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 } }
|
||||
)
|
||||
}
|
562
services/web/frontend/stories/pdf-preview.stories.js
Normal file
562
services/web/frontend/stories/pdf-preview.stories.js
Normal 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
|
||||
)
|
||||
}
|
|
@ -1,15 +1,29 @@
|
|||
import { ContextRoot } from '../../js/shared/context/root-context'
|
||||
import _ from 'lodash'
|
||||
|
||||
// Unfortunately, we cannot currently use decorators here, since we need to
|
||||
// set a value on window, before the contexts are rendered.
|
||||
// When using decorators, the contexts are rendered before the story, so we
|
||||
// don't have the opportunity to set the window value first.
|
||||
export function withContextRoot(Story, scope) {
|
||||
const scopeWatchers = []
|
||||
|
||||
const ide = {
|
||||
...window._ide,
|
||||
$scope: {
|
||||
...window._ide.$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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -146,6 +146,17 @@
|
|||
box-sizing: content-box;
|
||||
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 {
|
||||
outline: none;
|
||||
}
|
||||
|
@ -189,6 +200,12 @@
|
|||
border-bottom: 2px solid white;
|
||||
}
|
||||
}
|
||||
.pdfjs-error {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.pdf-logs {
|
||||
|
|
|
@ -225,3 +225,22 @@
|
|||
[data-toggle='buttons'] > .btn > input[type='checkbox'] {
|
||||
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;
|
||||
}
|
||||
|
|
30
services/web/package-lock.json
generated
30
services/web/package-lock.json
generated
|
@ -11609,7 +11609,8 @@
|
|||
"ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"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": {
|
||||
"version": "3.35.1",
|
||||
|
@ -13805,7 +13806,8 @@
|
|||
"big.js": {
|
||||
"version": "3.2.0",
|
||||
"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": {
|
||||
"version": "9.0.1",
|
||||
|
@ -17970,7 +17972,8 @@
|
|||
"emojis-list": {
|
||||
"version": "2.1.0",
|
||||
"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": {
|
||||
"version": "10.0.27",
|
||||
|
@ -25161,6 +25164,7 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.1.0.tgz",
|
||||
"integrity": "sha512-gkD9aSEG9UGglyPcDJqY9YBTUtCLKaBK6ihD2VP1d1X60lTfFspNZNulGBBbUZLkPygy4LySYHyxBpq+VhjObQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"big.js": "^3.1.3",
|
||||
"emojis-list": "^2.0.0",
|
||||
|
@ -25170,7 +25174,8 @@
|
|||
"json5": {
|
||||
"version": "0.5.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
|
||||
|
@ -28974,13 +28974,9 @@
|
|||
}
|
||||
},
|
||||
"pdfjs-dist": {
|
||||
"version": "2.2.228",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.2.228.tgz",
|
||||
"integrity": "sha512-W5LhYPMS2UKX0ELIa4u+CFCMoox5qQNQElt0bAK2mwz1V8jZL0rvLao+0tBujce84PK6PvWG36Nwr7agCCWFGQ==",
|
||||
"requires": {
|
||||
"node-ensure": "^0.0.0",
|
||||
"worker-loader": "^2.0.0"
|
||||
}
|
||||
"version": "2.9.359",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.9.359.tgz",
|
||||
"integrity": "sha512-P2nYtkacdlZaNNwrBLw1ZyMm0oE2yY/5S/GDCAmMJ7U4+ciL/D0mrlEC/o4HZZc/LNE3w8lEVzBEyVgEQlPVKQ=="
|
||||
},
|
||||
"pend": {
|
||||
"version": "1.2.0",
|
||||
|
@ -33090,6 +33086,7 @@
|
|||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz",
|
||||
"integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ajv": "^6.1.0",
|
||||
"ajv-keywords": "^3.1.0"
|
||||
|
@ -38635,6 +38632,7 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz",
|
||||
"integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"loader-utils": "^1.0.0",
|
||||
"schema-utils": "^0.4.0"
|
||||
|
|
|
@ -143,7 +143,7 @@
|
|||
"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-twitter": "^1.0.4",
|
||||
"pdfjs-dist": "^2.2.228",
|
||||
"pdfjs-dist": "^2.9.359",
|
||||
"prop-types": "^15.7.2",
|
||||
"pug": "^3.0.1",
|
||||
"pug-runtime": "^3.0.1",
|
||||
|
@ -261,6 +261,7 @@
|
|||
"webpack-assets-manifest": "^4.0.6",
|
||||
"webpack-cli": "^3.3.11",
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
"webpack-merge": "^4.2.2"
|
||||
"webpack-merge": "^4.2.2",
|
||||
"worker-loader": "^2.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
})
|
Binary file not shown.
|
@ -11,6 +11,7 @@ import { IdeProvider } from '../../../frontend/js/shared/context/ide-context'
|
|||
import { get } from 'lodash'
|
||||
import { ProjectProvider } from '../../../frontend/js/shared/context/project-context'
|
||||
import { SplitTestProvider } from '../../../frontend/js/shared/context/split-test-context'
|
||||
import { CompileProvider } from '../../../frontend/js/shared/context/compile-context'
|
||||
|
||||
export function EditorProviders({
|
||||
user = { id: '123abd' },
|
||||
|
@ -52,7 +53,17 @@ export function EditorProviders({
|
|||
...scope,
|
||||
}
|
||||
|
||||
window._ide = { $scope, socket, clsiServerId }
|
||||
const fileTreeManager = {
|
||||
findEntityByPath: () => null,
|
||||
getRootDocDirname: () => '',
|
||||
}
|
||||
|
||||
window._ide = {
|
||||
$scope,
|
||||
socket,
|
||||
clsiServerId,
|
||||
fileTreeManager,
|
||||
}
|
||||
|
||||
return (
|
||||
<SplitTestProvider>
|
||||
|
@ -60,7 +71,9 @@ export function EditorProviders({
|
|||
<UserProvider>
|
||||
<ProjectProvider>
|
||||
<EditorProvider settings={{}}>
|
||||
<LayoutProvider>{children}</LayoutProvider>
|
||||
<CompileProvider>
|
||||
<LayoutProvider>{children}</LayoutProvider>
|
||||
</CompileProvider>
|
||||
</EditorProvider>
|
||||
</ProjectProvider>
|
||||
</UserProvider>
|
||||
|
|
|
@ -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
|
||||
// methods
|
||||
|
@ -288,9 +303,13 @@ module.exports = {
|
|||
from: 'node_modules/ace-builds/src-min-noconflict',
|
||||
to: `js/ace-${PackageVersions.version.ace}/`,
|
||||
},
|
||||
// Copy CMap files from pdfjs-dist package to build output. These are used
|
||||
// to provide support for non-Latin characters
|
||||
// Copy CMap files (used 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/legacy/web/images',
|
||||
to: 'images',
|
||||
},
|
||||
]),
|
||||
],
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue