diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug
index 9cb9c71585..5b575ba463 100644
--- a/services/web/app/views/project/editor/editor.pug
+++ b/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'"
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 8fbc84cd94..b878821c05 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/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": "",
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js b/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js
index 82ea6e9cb6..0449da952b 100644
--- a/services/web/frontend/js/features/pdf-preview/components/pdf-js-viewer.js
+++ b/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])
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js
index 552e431f21..dc06ee0c26 100644
--- a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-entries.js
+++ b/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)
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js b/services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js
new file mode 100644
index 0000000000..1ca5d7616a
--- /dev/null
+++ b/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 (
+ <>
+
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.
+ \\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 }] }
+ })
diff --git a/services/web/frontend/stories/fixtures/context.js b/services/web/frontend/stories/fixtures/context.js
index 3277b1c4ba..94a9feef45 100644
--- a/services/web/frontend/stories/fixtures/context.js
+++ b/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'
diff --git a/services/web/frontend/stories/pdf-js-viewer.stories.js b/services/web/frontend/stories/pdf-js-viewer.stories.js
deleted file mode 100644
index 85528f2d3f..0000000000
--- a/services/web/frontend/stories/pdf-js-viewer.stories.js
+++ /dev/null
@@ -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 (
-
-
-
-
-
- )
- }
-
- return withContextRoot(
-
-
-
- ,
- { project }
- )
-}
diff --git a/services/web/frontend/stories/pdf-preview.stories.js b/services/web/frontend/stories/pdf-preview.stories.js
index 51633ebc43..da41838656 100644
--- a/services/web/frontend/stories/pdf-preview.stories.js
+++ b/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 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.
- \\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(
@@ -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( , scope)
}
diff --git a/services/web/frontend/stories/pdf-viewer.stories.js b/services/web/frontend/stories/pdf-viewer.stories.js
new file mode 100644
index 0000000000..9b4af0836b
--- /dev/null
+++ b/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(
+
+
+
+
+
+
+
+ ,
+ scope
+ )
+}
diff --git a/services/web/frontend/stories/utils/with-context-root.js b/services/web/frontend/stories/utils/with-context-root.js
index 8e7b0fb420..18020ab5d9 100644
--- a/services/web/frontend/stories/utils/with-context-root.js
+++ b/services/web/frontend/stories/utils/with-context-root.js
@@ -24,6 +24,9 @@ export function withContextRoot(Story, scope) {
}
}, 0)
},
+ $on: (eventName, callback) => {
+ //
+ },
},
}
diff --git a/services/web/frontend/stylesheets/app/editor/pdf.less b/services/web/frontend/stylesheets/app/editor/pdf.less
index 619f83a409..19cafc3c63 100644
--- a/services/web/frontend/stylesheets/app/editor/pdf.less
+++ b/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%);
diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js
index 66efe43d9e..40516a5bc4 100644
--- a/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js
+++ b/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js
@@ -473,7 +473,7 @@ describe(' ', 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(' ', 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( )
@@ -498,7 +500,7 @@ describe(' ', 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(' ', 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( )
diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js
new file mode 100644
index 0000000000..0975ae58e2
--- /dev/null
+++ b/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(' ', 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(
+
+
+
+
+ )
+
+ 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
+ })
+ })
+})
diff --git a/services/web/test/frontend/helpers/render-with-context.js b/services/web/test/frontend/helpers/render-with-context.js
index ac7d5ee35d..51bb036b20 100644
--- a/services/web/test/frontend/helpers/render-with-context.js
+++ b/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,
}