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