From 913a62fbc824eb3a6fd2620f961463688d94f240 Mon Sep 17 00:00:00 2001 From: Alf Eaton Date: Thu, 21 Oct 2021 11:31:51 +0100 Subject: [PATCH] Migrate synctex controls to React (#5503) GitOrigin-RevId: 80362a00ae6b73616a6fa9b3193b9b9974b5fd35 --- .../web/app/views/project/editor/editor.pug | 46 ++-- .../web/frontend/extracted-translations.json | 2 + .../pdf-preview/components/pdf-js-viewer.js | 30 +-- .../components/pdf-logs-entries.js | 23 +- .../components/pdf-synctex-controls.js | 234 ++++++++++++++++++ .../pdf-preview/components/pdf-viewer.js | 17 +- .../controllers/pdf-preview-controller.js | 5 + .../pdf-preview/util/pdf-js-wrapper.js | 4 +- .../cursor-position/CursorPositionManager.js | 7 +- .../js/shared/context/compile-context.js | 50 +++- .../web/frontend/stories/fixtures/compile.js | 228 +++++++++++++++++ .../web/frontend/stories/fixtures/context.js | 11 + .../frontend/stories/pdf-js-viewer.stories.js | 114 --------- .../frontend/stories/pdf-preview.stories.js | 195 ++------------- .../frontend/stories/pdf-viewer.stories.js | 62 +++++ .../stories/utils/with-context-root.js | 3 + .../frontend/stylesheets/app/editor/pdf.less | 4 + .../components/pdf-preview.test.js | 8 +- .../components/pdf-synctex-controls.test.js | 167 +++++++++++++ .../frontend/helpers/render-with-context.js | 12 +- 20 files changed, 858 insertions(+), 364 deletions(-) create mode 100644 services/web/frontend/js/features/pdf-preview/components/pdf-synctex-controls.js create mode 100644 services/web/frontend/stories/fixtures/compile.js delete mode 100644 services/web/frontend/stories/pdf-js-viewer.stories.js create mode 100644 services/web/frontend/stories/pdf-viewer.stories.js create mode 100644 services/web/test/frontend/features/pdf-preview/components/pdf-synctex-controls.test.js 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 ( + <> + + {t('go_to_code_location_in_pdf')} + + } + > + + + + + {t('go_to_pdf_location_in_code')} + + } + > + + + + ) +} + +export default memo(PdfSynctexControls) diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js b/services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js index 91209b2db8..362aa86bbc 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-viewer.js +++ b/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 diff --git a/services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js b/services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js index 7254e7d774..d17fed586b 100644 --- a/services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js +++ b/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), []) +) diff --git a/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.js b/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.js index e74c080aff..0aafcf9971 100644 --- a/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.js +++ b/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({ diff --git a/services/web/frontend/js/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.js b/services/web/frontend/js/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.js index b16ca12540..663e163ad3 100644 --- a/services/web/frontend/js/ide/editor/directives/aceEditor/cursor-position/CursorPositionManager.js +++ b/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() { diff --git a/services/web/frontend/js/shared/context/compile-context.js b/services/web/frontend/js/shared/context/compile-context.js index c08a54003b..b2cfbe32f8 100644 --- a/services/web/frontend/js/shared/context/compile-context.js +++ b/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, diff --git a/services/web/frontend/stories/fixtures/compile.js b/services/web/frontend/stories/fixtures/compile.js new file mode 100644 index 0000000000..12a6e2fab0 --- /dev/null +++ b/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
 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, }