Przeglądaj źródła

Migrate synctex controls to React (#5503)

GitOrigin-RevId: 80362a00ae6b73616a6fa9b3193b9b9974b5fd35
Alf Eaton 4 lat temu
rodzic
commit
913a62fbc8
20 zmienionych plików z 858 dodań i 364 usunięć
  1. 26 20
      services/web/app/views/project/editor/editor.pug
  2. 2 0
      services/web/frontend/extracted-translations.json
  3. 15 15
      services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js
  4. 16 7
      services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js
  5. 234 0
      services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js
  6. 2 15
      services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js
  7. 5 0
      services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js
  8. 2 2
      services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.js
  9. 6 1
      services/web/frontend/js/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.js
  10. 37 13
      services/web/frontend/js/shared/context/compile-context.js
  11. 228 0
      services/web/frontend/stories/fixtures/compile.js
  12. 11 0
      services/web/frontend/stories/fixtures/context.js
  13. 0 114
      services/web/frontend/stories/pdf-js-viewer.stories.js
  14. 22 173
      services/web/frontend/stories/pdf-preview.stories.js
  15. 62 0
      services/web/frontend/stories/pdf-viewer.stories.js
  16. 3 0
      services/web/frontend/stories/utils/with-context-root.js
  17. 4 0
      services/web/frontend/stylesheets/app/editor/pdf.less
  18. 6 2
      services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js
  19. 167 0
      services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js
  20. 10 2
      services/web/test/frontend/helpers/render-with-context.js

+ 26 - 20
services/web/app/views/project/editor/editor.pug

@@ -24,28 +24,34 @@ div.full-size(
 			else
 				include ./pdf
 
-	.ui-layout-resizer-controls.synctex-controls(
-		ng-show="!!pdf.url && settings.pdfViewer !== 'native'"
-		ng-controller="PdfSynctexController"
-	)
-		a.btn.btn-default.btn-xs.synctex-control.synctex-control-goto-pdf(
-			tooltip=translate('go_to_code_location_in_pdf')
-			tooltip-placement="right"
-			tooltip-append-to-body="true"
-			ng-click="syncToPdf()"
-			ng-disabled="syncToPdfInFlight"
+	if showNewPdfPreview
+		.ui-layout-resizer-controls.synctex-controls(
+			ng-show="settings.pdfViewer !== 'native'"
 		)
-			i.synctex-control-icon(ng-show="!syncToPdfInFlight")
-			i.synctex-spin-icon.fa.fa-refresh.fa-spin(ng-show="syncToPdfInFlight")
-		a.btn.btn-default.btn-xs.synctex-control.synctex-control-goto-code(
-			tooltip=translate('go_to_pdf_location_in_code')
-			tooltip-placement="right"
-			tooltip-append-to-body="true"
-			ng-click="syncToCode()"
-			ng-disabled="syncToCodeInFlight"
+			pdf-synctex-controls()
+	else
+		.ui-layout-resizer-controls.synctex-controls(
+			ng-show="!!pdf.url && settings.pdfViewer !== 'native'"
+			ng-controller="PdfSynctexController"
 		)
-			i.synctex-control-icon(ng-show="!syncToCodeInFlight")
-			i.synctex-spin-icon.fa.fa-refresh.fa-spin(ng-show="syncToCodeInFlight")
+			a.btn.btn-default.btn-xs.synctex-control.synctex-control-goto-pdf(
+				tooltip=translate('go_to_code_location_in_pdf')
+				tooltip-placement="right"
+				tooltip-append-to-body="true"
+				ng-click="syncToPdf()"
+				ng-disabled="syncToPdfInFlight"
+			)
+				i.synctex-control-icon(ng-show="!syncToPdfInFlight")
+				i.synctex-spin-icon.fa.fa-refresh.fa-spin(ng-show="syncToPdfInFlight")
+			a.btn.btn-default.btn-xs.synctex-control.synctex-control-goto-code(
+				tooltip=translate('go_to_pdf_location_in_code')
+				tooltip-placement="right"
+				tooltip-append-to-body="true"
+				ng-click="syncToCode()"
+				ng-disabled="syncToCodeInFlight"
+			)
+				i.synctex-control-icon(ng-show="!syncToCodeInFlight")
+				i.synctex-spin-icon.fa.fa-refresh.fa-spin(ng-show="syncToCodeInFlight")
 
 div.full-size(
 	ng-if="ui.pdfLayout == 'flat'"

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -122,7 +122,9 @@
   "go_next_page": "",
   "go_page": "",
   "go_prev_page": "",
+  "go_to_code_location_in_pdf": "",
   "go_to_error_location": "",
+  "go_to_pdf_location_in_code": "",
   "have_an_extra_backup": "",
   "headers": "",
   "hide_outline": "",

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

@@ -4,7 +4,6 @@ import { debounce } from 'lodash'
 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/hooks/use-scope-value'
 import { buildHighlightElement } from '../util/highlights'
 import PDFJSWrapper from '../util/pdf-js-wrapper'
 import withErrorBoundary from '../../../infrastructure/error-boundary'
@@ -15,7 +14,12 @@ import getMeta from '../../../utils/meta'
 function PdfJsViewer({ url }) {
   const { _id: projectId } = useProjectContext()
 
-  const { setError, firstRenderDone } = useCompileContext()
+  const {
+    setError,
+    firstRenderDone,
+    highlights,
+    setPosition,
+  } = useCompileContext()
   const [timePDFFetched, setTimePDFFetched] = useState()
 
   // state values persisted in localStorage to restore on load
@@ -24,10 +28,6 @@ function PdfJsViewer({ url }) {
     'page-width'
   )
 
-  // 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)
@@ -144,16 +144,16 @@ function PdfJsViewer({ url }) {
   useEffect(() => {
     if (initialised && pdfJsWrapper) {
       setScale(scale => {
-        pdfJsWrapper.viewer.currentScaleValue = scale
-        return scale
-      })
+        setPosition(position => {
+          if (position) {
+            pdfJsWrapper.scrollToPosition(position, scale)
+          } else {
+            pdfJsWrapper.viewer.currentScaleValue = scale
+          }
+          return position
+        })
 
-      // restore the scroll position
-      setPosition(position => {
-        if (position) {
-          pdfJsWrapper.currentPosition = position
-        }
-        return position
+        return scale
       })
     }
   }, [initialised, setScale, setPosition, pdfJsWrapper])

+ 16 - 7
services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js

@@ -3,19 +3,28 @@ import PropTypes from 'prop-types'
 import { useTranslation } from 'react-i18next'
 import PreviewLogsPaneMaxEntries from '../../preview/components/preview-logs-pane-max-entries'
 import PdfLogEntry from './pdf-log-entry'
+import { useIdeContext } from '../../../shared/context/ide-context'
 
 const LOG_PREVIEW_LIMIT = 100
 
 function PdfLogsEntries({ entries }) {
   const { t } = useTranslation()
 
-  const syncToEntry = useCallback(entry => {
-    window.dispatchEvent(
-      new CustomEvent('synctex:sync-to-entry', {
-        detail: entry,
-      })
-    )
-  }, [])
+  const ide = useIdeContext()
+
+  const syncToEntry = useCallback(
+    entry => {
+      const entity = ide.fileTreeManager.findEntityByPath(entry.file)
+
+      if (entity && entity.type === 'doc') {
+        ide.editorManager.openDoc(entity, {
+          gotoLine: entry.line ?? undefined,
+          gotoColumn: entry.column ?? undefined,
+        })
+      }
+    },
+    [ide]
+  )
 
   const logEntries = entries.slice(0, LOG_PREVIEW_LIMIT)
 

+ 234 - 0
services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js

@@ -0,0 +1,234 @@
+import { memo, useCallback, useEffect, useState } from 'react'
+import { useIdeContext } from '../../../shared/context/ide-context'
+import { useProjectContext } from '../../../shared/context/project-context'
+import { getJSON } from '../../../infrastructure/fetch-json'
+import { useCompileContext } from '../../../shared/context/compile-context'
+import useScopeValue from '../../../shared/hooks/use-scope-value'
+import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
+import Icon from '../../../shared/components/icon'
+import { useTranslation } from 'react-i18next'
+import useIsMounted from '../../../shared/hooks/use-is-mounted'
+import useAbortController from '../../../shared/hooks/use-abort-controller'
+
+function PdfSynctexControls() {
+  const ide = useIdeContext()
+
+  const { _id: projectId } = useProjectContext()
+
+  const {
+    clsiServerId,
+    pdfUrl,
+    pdfViewer,
+    position,
+    setHighlights,
+  } = useCompileContext()
+
+  const [cursorPosition, setCursorPosition] = useState(null)
+
+  const isMounted = useIsMounted()
+
+  const { signal } = useAbortController()
+
+  useEffect(() => {
+    const listener = event => setCursorPosition(event.detail)
+    window.addEventListener('cursor:editor:update', listener)
+    return () => window.removeEventListener('cursor:editor:update', listener)
+  }, [ide])
+
+  const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false)
+  const [syncToCodeInFlight, setSyncToCodeInFlight] = useState(false)
+
+  const [, setSynctexError] = useScopeValue('sync_tex_error')
+
+  const { t } = useTranslation()
+
+  const getCurrentFilePath = useCallback(() => {
+    const docId = ide.editorManager.getCurrentDocId()
+    const doc = ide.fileTreeManager.findEntityById(docId)
+
+    let path = ide.fileTreeManager.getEntityPath(doc)
+
+    // If the root file is folder/main.tex, then synctex sees the path as folder/./main.tex
+    const rootDocDirname = ide.fileTreeManager.getRootDocDirname()
+
+    if (rootDocDirname) {
+      path = path.replace(RegExp(`^${rootDocDirname}`), `${rootDocDirname}/.`)
+    }
+
+    return path
+  }, [ide])
+
+  const syncToPdf = useCallback(
+    cursorPosition => {
+      setSyncToPdfInFlight(true)
+
+      const params = new URLSearchParams({
+        file: getCurrentFilePath(),
+        line: cursorPosition.row + 1,
+        column: cursorPosition.column,
+      })
+
+      if (clsiServerId) {
+        params.set('clsiserverid', clsiServerId)
+      }
+
+      getJSON(`/project/${projectId}/sync/code?${params}`, { signal })
+        .then(data => {
+          setHighlights(data.pdf)
+        })
+        .catch(error => {
+          console.error(error)
+        })
+        .finally(() => {
+          if (isMounted.current) {
+            setSyncToPdfInFlight(false)
+          }
+        })
+    },
+    [
+      clsiServerId,
+      projectId,
+      setHighlights,
+      getCurrentFilePath,
+      signal,
+      isMounted,
+    ]
+  )
+
+  const syncToCode = useCallback(
+    (position, visualOffset = 0) => {
+      // FIXME: this actually works better if it's halfway across the
+      // page (or the visible part of the page). Synctex doesn't
+      // always find the right place in the file when the point is at
+      // the edge of the page, it sometimes returns the start of the
+      // next paragraph instead.
+      const h = position.offset.left
+
+      // Compute the vertical position to pass to synctex, which
+      // works with coordinates increasing from the top of the page
+      // down.  This matches the browser's DOM coordinate of the
+      // click point, but the pdf position is measured from the
+      // bottom of the page so we need to invert it.
+      let v = 0
+      if (position.pageSize?.height) {
+        v += position.pageSize.height - position.offset.top // measure from pdf point (inverted)
+      } else {
+        v += position.offset.top // measure from html click position
+      }
+      v += visualOffset
+
+      setSyncToCodeInFlight(true)
+
+      const params = new URLSearchParams({
+        page: position.page + 1,
+        h: h.toFixed(2),
+        v: v.toFixed(2),
+      })
+
+      if (clsiServerId) {
+        params.set('clsiserverid', clsiServerId)
+      }
+
+      getJSON(`/project/${projectId}/sync/pdf?${params}`, { signal })
+        .then(data => {
+          const [{ file, line }] = data.code
+          if (file) {
+            const doc = ide.fileTreeManager.findEntityByPath(file)
+
+            ide.editorManager.openDoc(doc, {
+              gotoLine: line,
+            })
+          } else {
+            setSynctexError(true)
+
+            window.setTimeout(() => {
+              if (isMounted.current) {
+                setSynctexError(false)
+              }
+            }, 4000)
+          }
+        })
+        .catch(error => {
+          console.error(error)
+        })
+        .finally(() => {
+          if (isMounted.current) {
+            setSyncToCodeInFlight(false)
+          }
+        })
+    },
+    [clsiServerId, ide, projectId, setSynctexError, signal, isMounted]
+  )
+
+  useEffect(() => {
+    const listener = event => syncToCode(event.detail)
+    window.addEventListener('synctex:sync-to-position', listener)
+    return () => {
+      window.removeEventListener('synctex:sync-to-position', listener)
+    }
+  }, [syncToCode])
+
+  if (!pdfUrl || pdfViewer === 'native') {
+    return null
+  }
+
+  return (
+    <>
+      <OverlayTrigger
+        placement="right"
+        overlay={
+          <Tooltip id="sync-to-pdf-tooltip">
+            {t('go_to_code_location_in_pdf')}
+          </Tooltip>
+        }
+      >
+        <Button
+          bsStyle="default"
+          bsSize="xs"
+          onClick={() => syncToPdf(cursorPosition)}
+          disabled={syncToPdfInFlight}
+          className="synctex-control"
+          aria-label={t('go_to_code_location_in_pdf')}
+        >
+          {syncToPdfInFlight ? (
+            <Icon type="refresh" spin classes={{ icon: 'synctex-spin-icon' }} />
+          ) : (
+            <Icon
+              type="arrow-right"
+              classes={{ icon: 'synctex-control-icon' }}
+            />
+          )}
+        </Button>
+      </OverlayTrigger>
+
+      <OverlayTrigger
+        placement="right"
+        overlay={
+          <Tooltip id="sync-to-code-tooltip">
+            {t('go_to_pdf_location_in_code')}
+          </Tooltip>
+        }
+      >
+        <Button
+          bsStyle="default"
+          bsSize="xs"
+          onClick={() => syncToCode(position, 72)}
+          disabled={syncToCodeInFlight}
+          className="synctex-control"
+          aria-label={t('go_to_pdf_location_in_code')}
+        >
+          {syncToCodeInFlight ? (
+            <Icon type="refresh" spin classes={{ icon: 'synctex-spin-icon' }} />
+          ) : (
+            <Icon
+              type="arrow-left"
+              classes={{ icon: 'synctex-control-icon' }}
+            />
+          )}
+        </Button>
+      </OverlayTrigger>
+    </>
+  )
+}
+
+export default memo(PdfSynctexControls)

+ 2 - 15
services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js

@@ -1,25 +1,12 @@
-import useScopeValue from '../../../shared/hooks/use-scope-value'
-import { lazy, memo, useEffect } from 'react'
+import { lazy, memo } from 'react'
 import { useCompileContext } from '../../../shared/context/compile-context'
 
 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 } = useCompileContext()
+  const { pdfUrl, pdfViewer } = useCompileContext()
 
   if (!pdfUrl) {
     return null

+ 5 - 0
services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js

@@ -3,5 +3,10 @@ import { react2angular } from 'react2angular'
 
 import PdfPreview from '../components/pdf-preview'
 import { rootContext } from '../../../shared/context/root-context'
+import PdfSynctexControls from '../components/pdf-synctex-controls'
 
 App.component('pdfPreview', react2angular(rootContext.use(PdfPreview), []))
+App.component(
+  'pdfSynctexControls',
+  react2angular(rootContext.use(PdfSynctexControls), [])
+)

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

@@ -160,7 +160,7 @@ export default class PDFJSWrapper {
     }
   }
 
-  set currentPosition(position) {
+  scrollToPosition(position, scale = null) {
     const destArray = [
       null,
       {
@@ -168,7 +168,7 @@ export default class PDFJSWrapper {
       },
       position.offset.left,
       position.offset.top,
-      null,
+      scale,
     ]
 
     this.viewer.scrollPageIntoView({

+ 6 - 1
services/web/frontend/js/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.js

@@ -99,7 +99,12 @@ export default CursorPositionManager = class CursorPositionManager {
 
   emitCursorUpdateEvent() {
     const cursor = this.adapter.getCursor()
-    return this.$scope.$emit(`cursor:${this.$scope.name}:update`, cursor)
+    this.$scope.$emit(`cursor:${this.$scope.name}:update`, cursor)
+    window.dispatchEvent(
+      new CustomEvent(`cursor:${this.$scope.name}:update`, {
+        detail: cursor,
+      })
+    )
   }
 
   gotoStoredPosition() {

+ 37 - 13
services/web/frontend/js/shared/context/compile-context.js

@@ -38,15 +38,20 @@ CompileContext.Provider.propTypes = {
     fileList: PropTypes.object,
     hasChanges: PropTypes.bool.isRequired,
     hasLintingError: PropTypes.bool,
+    highlights: PropTypes.arrayOf(PropTypes.object),
     logEntries: PropTypes.object,
     logEntryAnnotations: PropTypes.object,
     pdfDownloadUrl: PropTypes.string,
     pdfUrl: PropTypes.string,
+    pdfViewer: PropTypes.string,
+    position: PropTypes.object,
     rawLog: PropTypes.string,
     setAutoCompile: PropTypes.func.isRequired,
     setDraft: PropTypes.func.isRequired,
     setError: PropTypes.func.isRequired,
     setHasLintingError: PropTypes.func.isRequired, // only for storybook
+    setHighlights: PropTypes.func.isRequired,
+    setPosition: PropTypes.func.isRequired,
     setShowLogs: PropTypes.func.isRequired,
     setStopOnValidationError: PropTypes.func.isRequired,
     showLogs: PropTypes.bool.isRequired,
@@ -70,24 +75,27 @@ export function CompileProvider({ children }) {
   const [compiling, setCompiling] = useState(false)
 
   // the log entries parsed from the compile output log
-  const [logEntries, setLogEntries] = useScopeValue('pdf.logEntries')
+  const [logEntries, setLogEntries] = useState()
 
   // annotations for display in the editor, built from the log entries
   const [logEntryAnnotations, setLogEntryAnnotations] = useScopeValue(
     'pdf.logEntryAnnotations'
   )
 
+  // the PDF viewer
+  const [pdfViewer] = useScopeValue('settings.pdfViewer')
+
   // the URL for downloading the PDF
-  const [pdfDownloadUrl, setPdfDownloadUrl] = useScopeValue('pdf.downloadUrl')
+  const [pdfDownloadUrl, setPdfDownloadUrl] = useState()
 
   // the URL for loading the PDF in the preview pane
-  const [pdfUrl, setPdfUrl] = useScopeValue('pdf.url')
+  const [pdfUrl, setPdfUrl] = useState()
 
   // the project is considered to be "uncompiled" if a doc has changed since the last compile started
-  const [uncompiled, setUncompiled] = useScopeValue('pdf.uncompiled')
+  const [uncompiled, setUncompiled] = useState()
 
   // the id of the CLSI server which ran the compile
-  const [clsiServerId, setClsiServerId] = useScopeValue('pdf.clsiServerId')
+  const [clsiServerId, setClsiServerId] = useState()
 
   // data received in response to a compile request
   const [data, setData] = useState()
@@ -116,6 +124,12 @@ export function CompileProvider({ children }) {
   // validation issues from CLSI
   const [validationIssues, setValidationIssues] = useState()
 
+  // areas to highlight on the PDF, from synctex
+  const [highlights, setHighlights] = useState()
+
+  // scroll position of the PDF
+  const [position, setPosition] = usePersistedState(`pdf.position.${projectId}`)
+
   // whether autocompile is switched on
   const [autoCompile, _setAutoCompile] = usePersistedState(
     `autocompile_enabled:${projectId}`,
@@ -331,18 +345,18 @@ export function CompileProvider({ children }) {
 
   const codeCheckFailed = stopOnValidationError && autoCompileLintingError
 
-  // show that the project has pending changes
-  const hasChanges = Boolean(
-    autoCompile && uncompiled && compiledOnce && !codeCheckFailed
-  )
-
   // the project is available for auto-compiling
-  const canAutoCompile = Boolean(autoCompile && !compiling && !codeCheckFailed)
+  const canAutoCompile = Boolean(autoCompile && !codeCheckFailed)
+
+  // show that the project has pending changes
+  const hasChanges = Boolean(canAutoCompile && uncompiled && compiledOnce)
 
   // call the debounced autocompile function if the project is available for auto-compiling and it has changed
   useEffect(() => {
-    if (canAutoCompile && changedAt > 0) {
-      compiler.debouncedAutoCompile()
+    if (canAutoCompile) {
+      if (changedAt > 0) {
+        compiler.debouncedAutoCompile()
+      }
     } else {
       compiler.debouncedAutoCompile.cancel()
     }
@@ -409,10 +423,13 @@ export function CompileProvider({ children }) {
       fileList,
       hasChanges,
       hasLintingError,
+      highlights,
       logEntries,
       logEntryAnnotations,
       pdfDownloadUrl,
       pdfUrl,
+      pdfViewer,
+      position,
       rawLog,
       recompileFromScratch,
       setAutoCompile,
@@ -421,6 +438,8 @@ export function CompileProvider({ children }) {
       setDraft,
       setError,
       setHasLintingError, // only for stories
+      setHighlights,
+      setPosition,
       setShowLogs,
       setStopOnValidationError,
       showLogs,
@@ -443,16 +462,21 @@ export function CompileProvider({ children }) {
       fileList,
       hasChanges,
       hasLintingError,
+      highlights,
       logEntries,
       logEntryAnnotations,
+      position,
       pdfDownloadUrl,
       pdfUrl,
+      pdfViewer,
       rawLog,
       recompileFromScratch,
       setAutoCompile,
       setDraft,
       setError,
       setHasLintingError,
+      setHighlights,
+      setPosition,
       setStopOnValidationError,
       showLogs,
       startCompile,

+ 228 - 0
services/web/frontend/stories/fixtures/compile.js

@@ -0,0 +1,228 @@
+import examplePdf from './storybook-example.pdf'
+import { cloneDeep } from 'lodash'
+
+export const dispatchDocChanged = () => {
+  window.dispatchEvent(
+    new CustomEvent('doc:changed', { detail: { doc_id: 'foo' } })
+  )
+}
+
+export 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',
+  },
+]
+
+export const mockCompile = (fetchMock, delay = 1000) =>
+  fetchMock.post(
+    'express:/project/:projectId/compile',
+    {
+      body: {
+        status: 'success',
+        clsiServerId: 'foo',
+        compileGroup: 'priority',
+        pdfDownloadDomain: '',
+        outputFiles: cloneDeep(outputFiles),
+      },
+    },
+    { delay }
+  )
+
+export const mockCompileError = (fetchMock, status = 'success', delay = 1000) =>
+  fetchMock.post(
+    'express:/project/:projectId/compile',
+    {
+      body: {
+        status,
+        clsiServerId: 'foo',
+        compileGroup: 'priority',
+      },
+    },
+    { delay, overwriteRoutes: true }
+  )
+
+export 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 }
+  )
+
+export const mockClearCache = fetchMock =>
+  fetchMock.delete('express:/project/:projectId/output', 204, {
+    delay: 1000,
+  })
+
+export 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:
+          console.log(pathname)
+          return 404
+      }
+    },
+    { sendAsJson: false }
+  )
+
+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 mockEventTracking = fetchMock =>
+  fetchMock.get('express:/event/:event', 204)
+
+export const mockValidPdf = 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 }
+  )
+
+export const mockSynctex = fetchMock =>
+  fetchMock
+    .get('express:/project/:projectId/sync/code', () => {
+      return { pdf: cloneDeep(mockHighlights) }
+    })
+    .get('express:/project/:projectId/sync/pdf', () => {
+      return { code: [{ file: 'main.tex', line: 100 }] }
+    })

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

@@ -18,6 +18,9 @@ export function setupContext() {
         chatOpen: true,
         pdfLayout: 'flat',
       },
+      settings: {
+        pdfViewer: 'js',
+      },
       toggleHistory: () => {},
     }
   }
@@ -29,9 +32,17 @@ export function setupContext() {
       removeListener: sinon.stub(),
     },
     fileTreeManager: {
+      findEntityById: () => null,
       findEntityByPath: () => null,
+      getEntityPath: () => null,
       getRootDocDirname: () => undefined,
     },
+    editorManager: {
+      getCurrentDocId: () => 'foo',
+      openDoc: (id, options) => {
+        console.log('open doc', id, options)
+      },
+    },
   }
   window.ExposedSettings = window.ExposedSettings || {}
   window.ExposedSettings.appName = 'Overleaf'

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

@@ -1,114 +0,0 @@
-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/hooks/use-scope-value'
-
-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>,
-    { project }
-  )
-}

+ 22 - 173
services/web/frontend/stories/pdf-preview.stories.js

@@ -1,5 +1,5 @@
 import { withContextRoot } from './utils/with-context-root'
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
 import useFetchMock from './hooks/use-fetch-mock'
 import { setupContext } from './fixtures/context'
 import { Button } from 'react-bootstrap'
@@ -9,10 +9,20 @@ import PdfPreviewToolbar from '../js/features/pdf-preview/components/pdf-preview
 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'
 import PdfPreviewHybridToolbar from '../js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
 import { useCompileContext } from '../js/shared/context/compile-context'
+import {
+  dispatchDocChanged,
+  mockBuildFile,
+  mockClearCache,
+  mockCompile,
+  mockCompileError,
+  mockCompileValidationIssues,
+  mockEventTracking,
+  outputFiles,
+} from './fixtures/compile'
+import { cloneDeep } from 'lodash'
 
 setupContext()
 
@@ -55,167 +65,6 @@ const scope = {
   },
 }
 
-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)
@@ -223,10 +72,6 @@ export const Interactive = () => {
     mockClearCache(fetchMock)
   })
 
-  useEffect(() => {
-    dispatchProjectJoined()
-  }, [])
-
   const Inner = () => {
     const context = useCompileContext()
 
@@ -465,6 +310,10 @@ const compileErrors = [
 ]
 
 export const DisplayError = () => {
+  useFetchMock(fetchMock => {
+    mockCompile(fetchMock)
+  })
+
   return withContextRoot(
     <>
       {compileErrors.map(error => (
@@ -482,7 +331,10 @@ export const DisplayError = () => {
 }
 
 export const Toolbar = () => {
-  useFetchMock(fetchMock => mockCompile(fetchMock, 500))
+  useFetchMock(fetchMock => {
+    mockCompile(fetchMock, 500)
+    mockBuildFile(fetchMock)
+  })
 
   return withContextRoot(
     <div className="pdf">
@@ -496,6 +348,7 @@ export const HybridToolbar = () => {
   useFetchMock(fetchMock => {
     mockCompile(fetchMock, 500)
     mockBuildFile(fetchMock)
+    mockEventTracking(fetchMock)
   })
 
   return withContextRoot(
@@ -508,7 +361,7 @@ export const HybridToolbar = () => {
 
 export const FileList = () => {
   const fileList = useMemo(() => {
-    return buildFileList(outputFiles)
+    return buildFileList(cloneDeep(outputFiles))
   }, [])
 
   return (
@@ -559,9 +412,5 @@ export const ValidationIssues = () => {
     mockBuildFile(fetchMock)
   })
 
-  useEffect(() => {
-    dispatchProjectJoined()
-  }, [])
-
   return withContextRoot(<PdfPreviewPane />, scope)
 }

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

@@ -0,0 +1,62 @@
+import useFetchMock from './hooks/use-fetch-mock'
+import { withContextRoot } from './utils/with-context-root'
+import { setupContext } from './fixtures/context'
+import PdfSynctexControls from '../js/features/pdf-preview/components/pdf-synctex-controls'
+import PdfViewer from '../js/features/pdf-preview/components/pdf-viewer'
+import {
+  mockBuildFile,
+  mockCompile,
+  mockSynctex,
+  mockValidPdf,
+} from './fixtures/compile'
+import { useEffect } from 'react'
+
+setupContext()
+
+export default {
+  title: 'PDF Viewer',
+  component: PdfViewer,
+}
+
+const project = {
+  _id: 'story-project',
+}
+
+const scope = {
+  project,
+  editor: {
+    sharejs_doc: {
+      doc_id: 'test-doc',
+      getSnapshot: () => 'some doc content',
+    },
+  },
+}
+
+export const Interactive = () => {
+  useFetchMock(fetchMock => {
+    mockCompile(fetchMock)
+    mockBuildFile(fetchMock)
+    mockValidPdf(fetchMock)
+    mockSynctex(fetchMock)
+  })
+
+  useEffect(() => {
+    window.dispatchEvent(
+      new CustomEvent(`cursor:editor:update`, {
+        detail: { row: 10, position: 10 },
+      })
+    )
+  }, [])
+
+  return withContextRoot(
+    <div>
+      <div className="pdf-viewer">
+        <PdfViewer />
+      </div>
+      <div style={{ position: 'absolute', top: 150, left: 50 }}>
+        <PdfSynctexControls />
+      </div>
+    </div>,
+    scope
+  )
+}

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

@@ -24,6 +24,9 @@ export function withContextRoot(Story, scope) {
           }
         }, 0)
       },
+      $on: (eventName, callback) => {
+        //
+      },
     },
   }
 

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

@@ -362,6 +362,10 @@
   background-color: fade(@btn-default-bg, 80%);
   transition: background 0.15s ease;
 
+  &:focus:not(:focus-visible) {
+    outline: none;
+  }
+
   &[disabled] {
     opacity: 1;
     background-color: fade(@btn-default-bg, 60%);

+ 6 - 2
services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js

@@ -473,7 +473,7 @@ describe('<PdfPreview/>', function () {
     mockCompile()
     mockBuildFile()
 
-    nock('https://www.test-overleaf.com')
+    nock('https://clsi.test-overleaf.com')
       .get(/^\/build\/output.pdf/)
       .replyWithError({
         message: 'something awful happened',
@@ -485,6 +485,8 @@ describe('<PdfPreview/>', function () {
     await screen.findByText('Something went wrong while rendering this PDF.')
     expect(screen.queryByLabelText('Page 1')).to.not.exist
 
+    expect(nock.isDone()).to.be.true
+
     mockValidPdf()
 
     rerender(<PdfPreview />)
@@ -498,7 +500,7 @@ describe('<PdfPreview/>', function () {
     mockCompile()
     mockBuildFile()
 
-    nock('https://www.test-overleaf.com')
+    nock('https://clsi.test-overleaf.com')
       .get(/^\/build\/output.pdf/)
       .replyWithFile(200, corruptPDF)
 
@@ -507,6 +509,8 @@ describe('<PdfPreview/>', function () {
     await screen.findByText('Something went wrong while rendering this PDF.')
     expect(screen.queryByLabelText('Page 1')).to.not.exist
 
+    expect(nock.isDone()).to.be.true
+
     mockValidPdf()
 
     rerender(<PdfPreview />)

+ 167 - 0
services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js

@@ -0,0 +1,167 @@
+import PdfSynctexControls from '../../../../../frontend/js/features/pdf-preview/components/pdf-synctex-controls'
+import { EditorProviders } from '../../../helpers/render-with-context'
+import { cloneDeep } from 'lodash'
+import fetchMock from 'fetch-mock'
+import { fireEvent, screen, waitFor, render } from '@testing-library/react'
+import fs from 'fs'
+import path from 'path'
+import { expect } from 'chai'
+import { useCompileContext } from '../../../../../frontend/js/shared/context/compile-context'
+import { useEffect } from 'react'
+
+const examplePDF = path.join(__dirname, '../fixtures/test-example.pdf')
+
+const scope = {
+  settings: {
+    syntaxValidation: false,
+    pdfViewer: 'pdfjs',
+  },
+  editor: {
+    sharejs_doc: {
+      doc_id: 'test-doc',
+      getSnapshot: () => 'some doc content',
+    },
+  },
+}
+
+const outputFiles = [
+  {
+    path: 'output.pdf',
+    build: '123',
+    url: '/build/output.pdf',
+    type: 'pdf',
+  },
+  {
+    path: 'output.log',
+    build: '123',
+    url: '/build/output.log',
+    type: 'log',
+  },
+]
+
+const mockCompile = () =>
+  fetchMock.post('express:/project/:projectId/compile', {
+    body: {
+      status: 'success',
+      clsiServerId: 'foo',
+      compileGroup: 'standard',
+      pdfDownloadDomain: 'https://clsi.test-overleaf.com',
+      outputFiles: cloneDeep(outputFiles),
+    },
+  })
+
+const fileResponses = {
+  '/build/output.pdf': () => fs.createReadStream(examplePDF),
+  '/build/output.log': '',
+}
+
+const mockBuildFile = () =>
+  fetchMock.get('begin:https://clsi.test-overleaf.com/', _url => {
+    const url = new URL(_url, 'https://clsi.test-overleaf.com')
+
+    if (url.pathname in fileResponses) {
+      return fileResponses[url.pathname]
+    }
+
+    return 404
+  })
+
+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,
+  },
+]
+
+const mockSynctex = () =>
+  fetchMock
+    .get('express:/project/:projectId/sync/code', () => {
+      return { pdf: cloneDeep(mockHighlights) }
+    })
+    .get('express:/project/:projectId/sync/pdf', () => {
+      return { code: [{ file: 'main.tex', line: 100 }] }
+    })
+
+describe('<PdfSynctexControls/>', function () {
+  beforeEach(function () {
+    window.showNewPdfPreview = true
+    fetchMock.restore()
+  })
+
+  afterEach(function () {
+    window.showNewPdfPreview = undefined
+    fetchMock.restore()
+  })
+
+  it('handles clicks on sync buttons', async function () {
+    mockCompile()
+    mockSynctex()
+    mockBuildFile()
+
+    const Inner = () => {
+      const { setPosition } = useCompileContext()
+
+      // mock PDF scroll position update
+      useEffect(() => {
+        setPosition({
+          page: 1,
+          offset: { top: 10, left: 10 },
+          pageSize: { height: 500, width: 500 },
+        })
+      }, [setPosition])
+
+      return null
+    }
+
+    render(
+      <EditorProviders scope={scope}>
+        <Inner />
+        <PdfSynctexControls />
+      </EditorProviders>
+    )
+
+    const syncToPdfButton = await screen.findByRole('button', {
+      name: 'Go to code location in PDF',
+    })
+
+    const syncToCodeButton = await screen.findByRole('button', {
+      name: 'Go to PDF location in code',
+    })
+
+    // mock editor cursor position update
+    fireEvent(
+      window,
+      new CustomEvent('cursor:editor:update', {
+        detail: { row: 100, column: 10 },
+      })
+    )
+
+    fireEvent.click(syncToPdfButton)
+
+    expect(syncToPdfButton.disabled).to.be.true
+
+    await waitFor(() => {
+      expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be
+        .true
+    })
+
+    fireEvent.click(syncToCodeButton)
+
+    expect(syncToCodeButton.disabled).to.be.true
+
+    await waitFor(() => {
+      expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be
+        .true
+    })
+  })
+})

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

@@ -48,20 +48,28 @@ export function EditorProviders({
       callback(get($scope, path))
       return () => null
     },
-    $applyAsync: () => {},
-    toggleHistory: () => {},
+    $applyAsync: sinon.stub(),
+    toggleHistory: sinon.stub(),
     ...scope,
   }
 
   const fileTreeManager = {
+    findEntityById: () => null,
     findEntityByPath: () => null,
+    getEntityPath: () => '',
     getRootDocDirname: () => '',
   }
 
+  const editorManager = {
+    getCurrentDocId: () => 'foo',
+    openDoc: sinon.stub(),
+  }
+
   window._ide = {
     $scope,
     socket,
     clsiServerId,
+    editorManager,
     fileTreeManager,
   }