Migrate synctex controls to React (#5503)

GitOrigin-RevId: 80362a00ae6b73616a6fa9b3193b9b9974b5fd35
This commit is contained in:
Alf Eaton 2021-10-21 11:31:51 +01:00 committed by Copybot
parent 684efaaf5f
commit 913a62fbc8
20 changed files with 858 additions and 364 deletions

View file

@ -24,6 +24,12 @@ div.full-size(
else else
include ./pdf include ./pdf
if showNewPdfPreview
.ui-layout-resizer-controls.synctex-controls(
ng-show="settings.pdfViewer !== 'native'"
)
pdf-synctex-controls()
else
.ui-layout-resizer-controls.synctex-controls( .ui-layout-resizer-controls.synctex-controls(
ng-show="!!pdf.url && settings.pdfViewer !== 'native'" ng-show="!!pdf.url && settings.pdfViewer !== 'native'"
ng-controller="PdfSynctexController" ng-controller="PdfSynctexController"

View file

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

View file

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

View file

@ -3,19 +3,28 @@ import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import PreviewLogsPaneMaxEntries from '../../preview/components/preview-logs-pane-max-entries' import PreviewLogsPaneMaxEntries from '../../preview/components/preview-logs-pane-max-entries'
import PdfLogEntry from './pdf-log-entry' import PdfLogEntry from './pdf-log-entry'
import { useIdeContext } from '../../../shared/context/ide-context'
const LOG_PREVIEW_LIMIT = 100 const LOG_PREVIEW_LIMIT = 100
function PdfLogsEntries({ entries }) { function PdfLogsEntries({ entries }) {
const { t } = useTranslation() const { t } = useTranslation()
const syncToEntry = useCallback(entry => { const ide = useIdeContext()
window.dispatchEvent(
new CustomEvent('synctex:sync-to-entry', { const syncToEntry = useCallback(
detail: entry, 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) const logEntries = entries.slice(0, LOG_PREVIEW_LIMIT)

View file

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

View file

@ -1,25 +1,12 @@
import useScopeValue from '../../../shared/hooks/use-scope-value' import { lazy, memo } from 'react'
import { lazy, memo, useEffect } from 'react'
import { useCompileContext } from '../../../shared/context/compile-context' import { useCompileContext } from '../../../shared/context/compile-context'
const PdfJsViewer = lazy(() => const PdfJsViewer = lazy(() =>
import(/* webpackChunkName: "pdf-js-viewer" */ './pdf-js-viewer') import(/* webpackChunkName: "pdf-js-viewer" */ './pdf-js-viewer')
) )
const params = new URLSearchParams(window.location.search)
function PdfViewer() { function PdfViewer() {
const [pdfViewer, setPdfViewer] = useScopeValue('settings.pdfViewer') const { pdfUrl, pdfViewer } = useCompileContext()
useEffect(() => {
const viewer = params.get('viewer')
if (viewer) {
setPdfViewer(viewer)
}
}, [setPdfViewer])
const { pdfUrl } = useCompileContext()
if (!pdfUrl) { if (!pdfUrl) {
return null return null

View file

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

View file

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

View file

@ -99,7 +99,12 @@ export default CursorPositionManager = class CursorPositionManager {
emitCursorUpdateEvent() { emitCursorUpdateEvent() {
const cursor = this.adapter.getCursor() 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() { gotoStoredPosition() {

View file

@ -38,15 +38,20 @@ CompileContext.Provider.propTypes = {
fileList: PropTypes.object, fileList: PropTypes.object,
hasChanges: PropTypes.bool.isRequired, hasChanges: PropTypes.bool.isRequired,
hasLintingError: PropTypes.bool, hasLintingError: PropTypes.bool,
highlights: PropTypes.arrayOf(PropTypes.object),
logEntries: PropTypes.object, logEntries: PropTypes.object,
logEntryAnnotations: PropTypes.object, logEntryAnnotations: PropTypes.object,
pdfDownloadUrl: PropTypes.string, pdfDownloadUrl: PropTypes.string,
pdfUrl: PropTypes.string, pdfUrl: PropTypes.string,
pdfViewer: PropTypes.string,
position: PropTypes.object,
rawLog: PropTypes.string, rawLog: PropTypes.string,
setAutoCompile: PropTypes.func.isRequired, setAutoCompile: PropTypes.func.isRequired,
setDraft: PropTypes.func.isRequired, setDraft: PropTypes.func.isRequired,
setError: PropTypes.func.isRequired, setError: PropTypes.func.isRequired,
setHasLintingError: PropTypes.func.isRequired, // only for storybook setHasLintingError: PropTypes.func.isRequired, // only for storybook
setHighlights: PropTypes.func.isRequired,
setPosition: PropTypes.func.isRequired,
setShowLogs: PropTypes.func.isRequired, setShowLogs: PropTypes.func.isRequired,
setStopOnValidationError: PropTypes.func.isRequired, setStopOnValidationError: PropTypes.func.isRequired,
showLogs: PropTypes.bool.isRequired, showLogs: PropTypes.bool.isRequired,
@ -70,24 +75,27 @@ export function CompileProvider({ children }) {
const [compiling, setCompiling] = useState(false) const [compiling, setCompiling] = useState(false)
// the log entries parsed from the compile output log // 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 // annotations for display in the editor, built from the log entries
const [logEntryAnnotations, setLogEntryAnnotations] = useScopeValue( const [logEntryAnnotations, setLogEntryAnnotations] = useScopeValue(
'pdf.logEntryAnnotations' 'pdf.logEntryAnnotations'
) )
// the PDF viewer
const [pdfViewer] = useScopeValue('settings.pdfViewer')
// the URL for downloading the PDF // 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 // 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 // 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 // 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 // data received in response to a compile request
const [data, setData] = useState() const [data, setData] = useState()
@ -116,6 +124,12 @@ export function CompileProvider({ children }) {
// validation issues from CLSI // validation issues from CLSI
const [validationIssues, setValidationIssues] = useState() 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 // whether autocompile is switched on
const [autoCompile, _setAutoCompile] = usePersistedState( const [autoCompile, _setAutoCompile] = usePersistedState(
`autocompile_enabled:${projectId}`, `autocompile_enabled:${projectId}`,
@ -331,18 +345,18 @@ export function CompileProvider({ children }) {
const codeCheckFailed = stopOnValidationError && autoCompileLintingError 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 // 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 // call the debounced autocompile function if the project is available for auto-compiling and it has changed
useEffect(() => { useEffect(() => {
if (canAutoCompile && changedAt > 0) { if (canAutoCompile) {
if (changedAt > 0) {
compiler.debouncedAutoCompile() compiler.debouncedAutoCompile()
}
} else { } else {
compiler.debouncedAutoCompile.cancel() compiler.debouncedAutoCompile.cancel()
} }
@ -409,10 +423,13 @@ export function CompileProvider({ children }) {
fileList, fileList,
hasChanges, hasChanges,
hasLintingError, hasLintingError,
highlights,
logEntries, logEntries,
logEntryAnnotations, logEntryAnnotations,
pdfDownloadUrl, pdfDownloadUrl,
pdfUrl, pdfUrl,
pdfViewer,
position,
rawLog, rawLog,
recompileFromScratch, recompileFromScratch,
setAutoCompile, setAutoCompile,
@ -421,6 +438,8 @@ export function CompileProvider({ children }) {
setDraft, setDraft,
setError, setError,
setHasLintingError, // only for stories setHasLintingError, // only for stories
setHighlights,
setPosition,
setShowLogs, setShowLogs,
setStopOnValidationError, setStopOnValidationError,
showLogs, showLogs,
@ -443,16 +462,21 @@ export function CompileProvider({ children }) {
fileList, fileList,
hasChanges, hasChanges,
hasLintingError, hasLintingError,
highlights,
logEntries, logEntries,
logEntryAnnotations, logEntryAnnotations,
position,
pdfDownloadUrl, pdfDownloadUrl,
pdfUrl, pdfUrl,
pdfViewer,
rawLog, rawLog,
recompileFromScratch, recompileFromScratch,
setAutoCompile, setAutoCompile,
setDraft, setDraft,
setError, setError,
setHasLintingError, setHasLintingError,
setHighlights,
setPosition,
setStopOnValidationError, setStopOnValidationError,
showLogs, showLogs,
startCompile, startCompile,

View file

@ -0,0 +1,228 @@
import examplePdf from './storybook-example.pdf'
import { cloneDeep } from 'lodash'
export const dispatchDocChanged = () => {
window.dispatchEvent(
new CustomEvent('doc:changed', { detail: { doc_id: 'foo' } })
)
}
export const outputFiles = [
{
path: 'output.pdf',
build: '123',
url: '/build/output.pdf',
type: 'pdf',
},
{
path: 'output.bbl',
build: '123',
url: '/build/output.bbl',
type: 'bbl',
},
{
path: 'output.bib',
build: '123',
url: '/build/output.bib',
type: 'bib',
},
{
path: 'example.txt',
build: '123',
url: '/build/example.txt',
type: 'txt',
},
{
path: 'output.log',
build: '123',
url: '/build/output.log',
type: 'log',
},
{
path: 'output.blg',
build: '123',
url: '/build/output.blg',
type: 'blg',
},
]
export const mockCompile = (fetchMock, delay = 1000) =>
fetchMock.post(
'express:/project/:projectId/compile',
{
body: {
status: 'success',
clsiServerId: 'foo',
compileGroup: 'priority',
pdfDownloadDomain: '',
outputFiles: cloneDeep(outputFiles),
},
},
{ delay }
)
export const mockCompileError = (fetchMock, status = 'success', delay = 1000) =>
fetchMock.post(
'express:/project/:projectId/compile',
{
body: {
status,
clsiServerId: 'foo',
compileGroup: 'priority',
},
},
{ delay, overwriteRoutes: true }
)
export const mockCompileValidationIssues = (
fetchMock,
validationProblems,
delay = 1000
) =>
fetchMock.post(
'express:/project/:projectId/compile',
() => {
return {
body: {
status: 'validation-problems',
validationProblems,
clsiServerId: 'foo',
compileGroup: 'priority',
},
}
},
{ delay, overwriteRoutes: true }
)
export const mockClearCache = fetchMock =>
fetchMock.delete('express:/project/:projectId/output', 204, {
delay: 1000,
})
export const mockBuildFile = fetchMock =>
fetchMock.get(
'express:/build/:file',
(url, options, request) => {
const { pathname } = new URL(url, 'https://example.com')
switch (pathname) {
case '/build/output.blg':
return 'This is BibTeX, Version 4.0' // FIXME
case '/build/output.log':
return `
The LaTeX compiler output
* With a lot of details
Wrapped in an HTML <pre> element with
preformatted text which is to be presented exactly
as written in the HTML file
(whitespace included)
The text is typically rendered using a non-proportional ("monospace") font.
LaTeX Font Info: External font \`cmex10' loaded for size
(Font) <7> on input line 18.
LaTeX Font Info: External font \`cmex10' loaded for size
(Font) <5> on input line 18.
! Undefined control sequence.
<recently read> \\Zlpha
main.tex, line 23
`
case '/build/output.pdf':
return new Promise(resolve => {
const xhr = new XMLHttpRequest()
xhr.addEventListener('load', () => {
resolve({
status: 200,
headers: {
'Content-Length': xhr.getResponseHeader('Content-Length'),
'Content-Type': xhr.getResponseHeader('Content-Type'),
},
body: xhr.response,
})
})
xhr.open('GET', examplePdf)
xhr.responseType = 'arraybuffer'
xhr.send()
})
default:
console.log(pathname)
return 404
}
},
{ sendAsJson: false }
)
const mockHighlights = [
{
page: 1,
h: 85.03936,
v: 509.999878,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 486.089539,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 498.044708,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 521.955078,
width: 441.921265,
height: 8.855677,
},
]
export const mockEventTracking = fetchMock =>
fetchMock.get('express:/event/:event', 204)
export const mockValidPdf = fetchMock =>
fetchMock.get(
'express:/build/output.pdf',
(url, options, request) => {
return new Promise(resolve => {
const xhr = new XMLHttpRequest()
xhr.addEventListener('load', () => {
resolve({
status: 200,
headers: {
'Content-Length': xhr.getResponseHeader('Content-Length'),
'Content-Type': xhr.getResponseHeader('Content-Type'),
'Accept-Ranges': 'bytes',
},
body: xhr.response,
})
})
xhr.open('GET', examplePdf)
xhr.responseType = 'arraybuffer'
xhr.send()
})
},
{ sendAsJson: false }
)
export const mockSynctex = fetchMock =>
fetchMock
.get('express:/project/:projectId/sync/code', () => {
return { pdf: cloneDeep(mockHighlights) }
})
.get('express:/project/:projectId/sync/pdf', () => {
return { code: [{ file: 'main.tex', line: 100 }] }
})

View file

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

View file

@ -1,114 +0,0 @@
import PdfJsViewer from '../js/features/pdf-preview/components/pdf-js-viewer'
import useFetchMock from './hooks/use-fetch-mock'
import examplePdf from './fixtures/storybook-example.pdf'
import { Button } from 'react-bootstrap'
import { useCallback } from 'react'
import { withContextRoot } from './utils/with-context-root'
import { setupContext } from './fixtures/context'
import useScopeValue from '../js/shared/hooks/use-scope-value'
setupContext()
export default {
title: 'PDF Viewer',
component: PdfJsViewer,
}
const project = {
_id: 'story-project',
}
const mockHighlights = [
{
page: 1,
h: 85.03936,
v: 509.999878,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 486.089539,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 498.044708,
width: 441.921265,
height: 8.855677,
},
{
page: 1,
h: 85.03936,
v: 521.955078,
width: 441.921265,
height: 8.855677,
},
]
export const Interactive = () => {
useFetchMock(fetchMock => {
fetchMock.get(
'express:/build/output.pdf',
(url, options, request) => {
return new Promise(resolve => {
const xhr = new XMLHttpRequest()
xhr.addEventListener('load', () => {
resolve({
status: 200,
headers: {
'Content-Length': xhr.getResponseHeader('Content-Length'),
'Content-Type': xhr.getResponseHeader('Content-Type'),
'Accept-Ranges': 'bytes',
},
body: xhr.response,
})
})
xhr.open('GET', examplePdf)
xhr.responseType = 'arraybuffer'
xhr.send()
})
},
{ sendAsJson: false }
)
})
const Inner = () => {
const [, setHighlights] = useScopeValue('pdf.highlights')
const dispatchSyncFromCode = useCallback(() => {
setHighlights([])
window.setTimeout(() => {
setHighlights(mockHighlights)
}, 0)
}, [setHighlights])
return (
<div
style={{
zIndex: 10,
position: 'absolute',
top: 20,
right: 40,
}}
>
<div style={{ display: 'flex', gap: 20 }}>
<Button onClick={dispatchSyncFromCode}>
sync position from editor
</Button>
</div>
</div>
)
}
return withContextRoot(
<div className="pdf-viewer">
<PdfJsViewer url="/build/output.pdf" />
<Inner />
</div>,
{ project }
)
}

View file

@ -1,5 +1,5 @@
import { withContextRoot } from './utils/with-context-root' 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 useFetchMock from './hooks/use-fetch-mock'
import { setupContext } from './fixtures/context' import { setupContext } from './fixtures/context'
import { Button } from 'react-bootstrap' 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 PdfFileList from '../js/features/pdf-preview/components/pdf-file-list'
import { buildFileList } from '../js/features/pdf-preview/util/file-list' import { buildFileList } from '../js/features/pdf-preview/util/file-list'
import PdfLogsViewer from '../js/features/pdf-preview/components/pdf-logs-viewer' 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 PdfPreviewError from '../js/features/pdf-preview/components/pdf-preview-error'
import PdfPreviewHybridToolbar from '../js/features/pdf-preview/components/pdf-preview-hybrid-toolbar' import PdfPreviewHybridToolbar from '../js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
import { useCompileContext } from '../js/shared/context/compile-context' 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() setupContext()
@ -55,167 +65,6 @@ const scope = {
}, },
} }
const dispatchProjectJoined = () => {
window.dispatchEvent(new CustomEvent('project:joined', { detail: project }))
}
const dispatchDocChanged = () => {
window.dispatchEvent(
new CustomEvent('doc:changed', { detail: { doc_id: 'foo' } })
)
}
const outputFiles = [
{
path: 'output.pdf',
build: '123',
url: '/build/output.pdf',
type: 'pdf',
},
{
path: 'output.bbl',
build: '123',
url: '/build/output.bbl',
type: 'bbl',
},
{
path: 'output.bib',
build: '123',
url: '/build/output.bib',
type: 'bib',
},
{
path: 'example.txt',
build: '123',
url: '/build/example.txt',
type: 'txt',
},
{
path: 'output.log',
build: '123',
url: '/build/output.log',
type: 'log',
},
{
path: 'output.blg',
build: '123',
url: '/build/output.blg',
type: 'blg',
},
]
const mockCompile = (fetchMock, delay = 1000) =>
fetchMock.post(
'express:/project/:projectId/compile',
{
body: {
status: 'success',
clsiServerId: 'foo',
compileGroup: 'priority',
pdfDownloadDomain: '',
outputFiles,
},
},
{ delay }
)
const mockCompileError = (fetchMock, status = 'success', delay = 1000) =>
fetchMock.post(
'express:/project/:projectId/compile',
{
body: {
status,
clsiServerId: 'foo',
compileGroup: 'priority',
},
},
{ delay, overwriteRoutes: true }
)
const mockCompileValidationIssues = (
fetchMock,
validationProblems,
delay = 1000
) =>
fetchMock.post(
'express:/project/:projectId/compile',
() => {
return {
body: {
status: 'validation-problems',
validationProblems,
clsiServerId: 'foo',
compileGroup: 'priority',
},
}
},
{ delay, overwriteRoutes: true }
)
const mockClearCache = fetchMock =>
fetchMock.delete('express:/project/:projectId/output', 204, {
delay: 1000,
})
const mockBuildFile = fetchMock =>
fetchMock.get(
'express:/build/:file',
(url, options, request) => {
const { pathname } = new URL(url, 'https://example.com')
switch (pathname) {
case '/build/output.blg':
return 'This is BibTeX, Version 4.0' // FIXME
case '/build/output.log':
return `
The LaTeX compiler output
* With a lot of details
Wrapped in an HTML <pre> element with
preformatted text which is to be presented exactly
as written in the HTML file
(whitespace included)
The text is typically rendered using a non-proportional ("monospace") font.
LaTeX Font Info: External font \`cmex10' loaded for size
(Font) <7> on input line 18.
LaTeX Font Info: External font \`cmex10' loaded for size
(Font) <5> on input line 18.
! Undefined control sequence.
<recently read> \\Zlpha
main.tex, line 23
`
case '/build/output.pdf':
return new Promise(resolve => {
const xhr = new XMLHttpRequest()
xhr.addEventListener('load', () => {
resolve({
status: 200,
headers: {
'Content-Length': xhr.getResponseHeader('Content-Length'),
'Content-Type': xhr.getResponseHeader('Content-Type'),
},
body: xhr.response,
})
})
xhr.open('GET', examplePdf)
xhr.responseType = 'arraybuffer'
xhr.send()
})
default:
return 404
}
},
{ sendAsJson: false }
)
export const Interactive = () => { export const Interactive = () => {
useFetchMock(fetchMock => { useFetchMock(fetchMock => {
mockCompile(fetchMock) mockCompile(fetchMock)
@ -223,10 +72,6 @@ export const Interactive = () => {
mockClearCache(fetchMock) mockClearCache(fetchMock)
}) })
useEffect(() => {
dispatchProjectJoined()
}, [])
const Inner = () => { const Inner = () => {
const context = useCompileContext() const context = useCompileContext()
@ -465,6 +310,10 @@ const compileErrors = [
] ]
export const DisplayError = () => { export const DisplayError = () => {
useFetchMock(fetchMock => {
mockCompile(fetchMock)
})
return withContextRoot( return withContextRoot(
<> <>
{compileErrors.map(error => ( {compileErrors.map(error => (
@ -482,7 +331,10 @@ export const DisplayError = () => {
} }
export const Toolbar = () => { export const Toolbar = () => {
useFetchMock(fetchMock => mockCompile(fetchMock, 500)) useFetchMock(fetchMock => {
mockCompile(fetchMock, 500)
mockBuildFile(fetchMock)
})
return withContextRoot( return withContextRoot(
<div className="pdf"> <div className="pdf">
@ -496,6 +348,7 @@ export const HybridToolbar = () => {
useFetchMock(fetchMock => { useFetchMock(fetchMock => {
mockCompile(fetchMock, 500) mockCompile(fetchMock, 500)
mockBuildFile(fetchMock) mockBuildFile(fetchMock)
mockEventTracking(fetchMock)
}) })
return withContextRoot( return withContextRoot(
@ -508,7 +361,7 @@ export const HybridToolbar = () => {
export const FileList = () => { export const FileList = () => {
const fileList = useMemo(() => { const fileList = useMemo(() => {
return buildFileList(outputFiles) return buildFileList(cloneDeep(outputFiles))
}, []) }, [])
return ( return (
@ -559,9 +412,5 @@ export const ValidationIssues = () => {
mockBuildFile(fetchMock) mockBuildFile(fetchMock)
}) })
useEffect(() => {
dispatchProjectJoined()
}, [])
return withContextRoot(<PdfPreviewPane />, scope) return withContextRoot(<PdfPreviewPane />, scope)
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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