Bladeren bron

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

GitOrigin-RevId: fcc88a362c3e97c9fddf85d47c3a83a0a0b89432
Alf Eaton 4 jaren geleden
bovenliggende
commit
73bc3418a2
50 gewijzigde bestanden met toevoegingen van 3678 en 47 verwijderingen
  1. 2 1
      services/web/app/views/project/editor.pug
  2. 9 6
      services/web/app/views/project/editor/editor.pug
  3. 27 0
      services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js
  4. 118 0
      services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js
  5. 50 0
      services/web/frontend/js/features/pdf-preview/components/pdf-download-button.js
  6. 33 0
      services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js
  7. 32 0
      services/web/frontend/js/features/pdf-preview/components/pdf-expand-button.js
  8. 50 0
      services/web/frontend/js/features/pdf-preview/components/pdf-file-list.js
  9. 239 0
      services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js
  10. 67 0
      services/web/frontend/js/features/pdf-preview/components/pdf-logs-button-content.js
  11. 68 0
      services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js
  12. 46 0
      services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js
  13. 70 0
      services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js
  14. 155 0
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-error.js
  15. 20 3
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js
  16. 32 0
      services/web/frontend/js/features/pdf-preview/components/pdf-preview-toolbar.js
  17. 13 0
      services/web/frontend/js/features/pdf-preview/components/pdf-preview.js
  18. 71 0
      services/web/frontend/js/features/pdf-preview/components/pdf-validation-issue.js
  19. 38 0
      services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls.js
  20. 38 0
      services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js
  21. 83 0
      services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt.js
  22. 659 0
      services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js
  23. 3 2
      services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js
  24. 34 0
      services/web/frontend/js/features/pdf-preview/hooks/use-resize-observer.js
  25. 70 0
      services/web/frontend/js/features/pdf-preview/hooks/use-toolbar-breakpoint.js
  26. 28 0
      services/web/frontend/js/features/pdf-preview/util/chktex-log-parser.js
  27. 4 0
      services/web/frontend/js/features/pdf-preview/util/editor-files.js
  28. 50 0
      services/web/frontend/js/features/pdf-preview/util/file-list.js
  29. 42 0
      services/web/frontend/js/features/pdf-preview/util/highlights.js
  30. 168 0
      services/web/frontend/js/features/pdf-preview/util/output-files.js
  31. 126 0
      services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.js
  32. 13 0
      services/web/frontend/js/ide.js
  33. 3 0
      services/web/frontend/js/ide/connection/ConnectionManager.js
  34. 6 0
      services/web/frontend/js/ide/editor/Document.js
  35. 11 0
      services/web/frontend/js/ide/editor/EditorManager.js
  36. 0 4
      services/web/frontend/js/ide/pdf/controllers/PdfController.js
  37. 34 9
      services/web/frontend/js/ide/pdf/controllers/PdfSynctexController.js
  38. 5 0
      services/web/frontend/stories/fixtures/context.js
  39. BIN
      services/web/frontend/stories/fixtures/storybook-example.pdf
  40. 114 0
      services/web/frontend/stories/pdf-js-viewer.stories.js
  41. 562 0
      services/web/frontend/stories/pdf-preview.stories.js
  42. 14 0
      services/web/frontend/stories/utils/with-context-root.js
  43. 17 0
      services/web/frontend/stylesheets/app/editor/pdf.less
  44. 19 0
      services/web/frontend/stylesheets/components/button-groups.less
  45. 14 16
      services/web/package-lock.json
  46. 3 2
      services/web/package.json
  47. 382 0
      services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js
  48. BIN
      services/web/test/frontend/features/pdf-preview/fixtures/test-example.pdf
  49. 15 2
      services/web/test/frontend/helpers/render-with-context.js
  50. 21 2
      services/web/webpack.config.js

+ 2 - 1
services/web/app/views/project/editor.pug

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

+ 9 - 6
services/web/app/views/project/editor/editor.pug

@@ -18,14 +18,17 @@ div.full-size(
 		include ./editor-no-symbol-palette
 		include ./editor-no-symbol-palette
 
 
 	.ui-layout-east
 	.ui-layout-east
-		div(ng-if="ui.pdfLayout == 'sideBySide'")
-			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
 				include ./pdf
 
 
 	.ui-layout-resizer-controls.synctex-controls(
 	.ui-layout-resizer-controls.synctex-controls(
-		ng-show="!!pdf.url && settings.pdfViewer == 'pdfjs'"
+		ng-show="!!pdf.url && settings.pdfViewer !== 'native'"
 		ng-controller="PdfSynctexController"
 		ng-controller="PdfSynctexController"
 	)
 	)
 		a.btn.btn-default.btn-xs.synctex-control.synctex-control-goto-pdf(
 		a.btn.btn-default.btn-xs.synctex-control.synctex-control-goto-pdf(
@@ -52,7 +55,7 @@ div.full-size(
 	ng-show="ui.view == 'pdf'"
 	ng-show="ui.view == 'pdf'"
 )
 )
 	if showNewPdfPreview
 	if showNewPdfPreview
-		pdf-preview-pane()
+		pdf-preview()
 	else
 	else
 		include ./pdf
 		include ./pdf
 
 

+ 27 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js

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

+ 118 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js

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

+ 50 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-download-button.js

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

+ 33 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js

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

+ 32 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-expand-button.js

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

+ 50 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-file-list.js

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

+ 239 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js

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

+ 67 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-logs-button-content.js

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

+ 68 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js

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

+ 46 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js

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

+ 70 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js

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

+ 155 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-preview-error.js

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

+ 20 - 3
services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js

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

+ 32 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-preview-toolbar.js

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

+ 13 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-preview.js

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

+ 71 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-validation-issue.js

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

+ 38 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-viewer-controls.js

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

+ 38 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js

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

+ 83 - 0
services/web/frontend/js/features/pdf-preview/components/timeout-upgrade-prompt.js

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

+ 659 - 0
services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js

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

+ 3 - 2
services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js

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

+ 34 - 0
services/web/frontend/js/features/pdf-preview/hooks/use-resize-observer.js

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

+ 70 - 0
services/web/frontend/js/features/pdf-preview/hooks/use-toolbar-breakpoint.js

@@ -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]
+  )
+}

+ 28 - 0
services/web/frontend/js/features/pdf-preview/util/chktex-log-parser.js

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

+ 4 - 0
services/web/frontend/js/features/pdf-preview/util/editor-files.js

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

+ 50 - 0
services/web/frontend/js/features/pdf-preview/util/file-list.js

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

+ 42 - 0
services/web/frontend/js/features/pdf-preview/util/highlights.js

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

+ 168 - 0
services/web/frontend/js/features/pdf-preview/util/output-files.js

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

+ 126 - 0
services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.js

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

+ 13 - 0
services/web/frontend/js/ide.js

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

+ 3 - 0
services/web/frontend/js/ide/connection/ConnectionManager.js

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

+ 6 - 0
services/web/frontend/js/ide/editor/Document.js

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

+ 11 - 0
services/web/frontend/js/ide/editor/EditorManager.js

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

+ 0 - 4
services/web/frontend/js/ide/pdf/controllers/PdfController.js

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

+ 34 - 9
services/web/frontend/js/ide/pdf/controllers/PdfSynctexController.js

@@ -1,4 +1,5 @@
 import App from '../../../base'
 import App from '../../../base'
+import { sendMBOnce } from '../../../infrastructure/event-tracking'
 
 
 App.controller('PdfSynctexController', function ($scope, synctex, ide) {
 App.controller('PdfSynctexController', function ($scope, synctex, ide) {
   this.cursorPosition = null
   this.cursorPosition = null
@@ -27,17 +28,41 @@ App.controller('PdfSynctexController', function ($scope, synctex, ide) {
 
 
   ide.$scope.$on('cursor:editor:syncToPdf', $scope.syncToPdf)
   ide.$scope.$on('cursor:editor:syncToPdf', $scope.syncToPdf)
 
 
-  $scope.syncToCode = function () {
-    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) {
 App.factory('synctex', function (ide, $http, $q) {

+ 5 - 0
services/web/frontend/stories/fixtures/context.js

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

BIN
services/web/frontend/stories/fixtures/storybook-example.pdf


+ 114 - 0
services/web/frontend/stories/pdf-js-viewer.stories.js

@@ -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 - 0
services/web/frontend/stories/pdf-preview.stories.js

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

+ 14 - 0
services/web/frontend/stories/utils/with-context-root.js

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

+ 17 - 0
services/web/frontend/stylesheets/app/editor/pdf.less

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

+ 19 - 0
services/web/frontend/stylesheets/components/button-groups.less

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

+ 14 - 16
services/web/package-lock.json

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

+ 3 - 2
services/web/package.json

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

+ 382 - 0
services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js

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

BIN
services/web/test/frontend/features/pdf-preview/fixtures/test-example.pdf


+ 15 - 2
services/web/test/frontend/helpers/render-with-context.js

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

+ 21 - 2
services/web/webpack.config.js

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