Handle PDF preview on toggle between split and full-width views (#5470)

* Only hide the compile logs pane when toggled off
* Handle PDF preview on toggle between split and full-width views

GitOrigin-RevId: 9ceca8a06a22abfa78f245e1ae5d24af98215906
This commit is contained in:
Alf Eaton 2021-10-15 10:39:56 +01:00 committed by Copybot
parent 90befc1fdd
commit f7ef2532e0
22 changed files with 500 additions and 642 deletions

View file

@ -18,13 +18,10 @@ div.full-size(
include ./editor-no-symbol-palette
.ui-layout-east
// The pdf-preview component needs to always be rendered, even when the editor is in "full-width" mode and it's not visible.
// It doesn't recompile while hidden, due to the ui.pdfHidden flag, but maintains its state for when it's shown again.
if showNewPdfPreview
div(ng-show="ui.pdfLayout == 'sideBySide'")
div(ng-if="ui.pdfLayout == 'sideBySide'")
if showNewPdfPreview
pdf-preview()
else
div(ng-if="ui.pdfLayout == 'sideBySide'")
else
include ./pdf
.ui-layout-resizer-controls.synctex-controls(

View file

@ -1,11 +1,11 @@
import Icon from '../../../shared/components/icon'
import { Button } from 'react-bootstrap'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import { useCompileContext } from '../../../shared/context/compile-context'
function PdfClearCacheButton() {
const { compiling, clearCache, clearingCache } = usePdfPreviewContext()
const { compiling, clearCache, clearingCache } = useCompileContext()
const { t } = useTranslation()

View file

@ -8,9 +8,9 @@ import {
import Icon from '../../../shared/components/icon'
import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import { useTranslation } from 'react-i18next'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { memo } from 'react'
import classnames from 'classnames'
import { useCompileContext } from '../../../shared/context/compile-context'
const modifierKey = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl'
@ -23,11 +23,11 @@ function PdfCompileButton() {
setAutoCompile,
setDraft,
setStopOnValidationError,
stopOnValidationError,
startCompile,
stopCompile,
stopOnValidationError,
recompileFromScratch,
} = usePdfPreviewContext()
} = useCompileContext()
const { t } = useTranslation()

View file

@ -3,11 +3,11 @@ import { Button, Dropdown } from 'react-bootstrap'
import Icon from '../../../shared/components/icon'
import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import PdfFileList from './pdf-file-list'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { memo } from 'react'
import { useCompileContext } from '../../../shared/context/compile-context'
function PdfDownloadButton() {
const { compiling, pdfDownloadUrl, fileList } = usePdfPreviewContext()
const { compiling, pdfDownloadUrl, fileList } = useCompileContext()
const { t } = useTranslation()

View file

@ -2,11 +2,11 @@ import { Dropdown } from 'react-bootstrap'
import PdfFileList from './pdf-file-list'
import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import { memo } from 'react'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { useTranslation } from 'react-i18next'
import { useCompileContext } from '../../../shared/context/compile-context'
function PdfDownloadFilesButton() {
const { compiling, fileList } = usePdfPreviewContext()
const { compiling, fileList } = useCompileContext()
const { t } = useTranslation()

View file

@ -1,11 +1,11 @@
import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { memo, useMemo } from 'react'
import { useLayoutContext } from '../../../shared/context/layout-context'
function PdfExpandButton() {
const { pdfLayout, switchLayout } = usePdfPreviewContext()
const { pdfLayout, switchLayout } = useLayoutContext()
const { t } = useTranslation()

View file

@ -1,12 +1,12 @@
import { memo, useCallback } from 'react'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { sendMBOnce } from '../../../infrastructure/event-tracking'
import { Button } from 'react-bootstrap'
import Icon from '../../../shared/components/icon'
import { useTranslation } from 'react-i18next'
import { useCompileContext } from '../../../shared/context/compile-context'
function PdfHybridCodeCheckButton() {
const { codeCheckFailed, error, setShowLogs } = usePdfPreviewContext()
const { codeCheckFailed, error, setShowLogs } = useCompileContext()
const { t } = useTranslation()

View file

@ -1,11 +1,11 @@
import { useTranslation } from 'react-i18next'
import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
import Icon from '../../../shared/components/icon'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { memo } from 'react'
import { useCompileContext } from '../../../shared/context/compile-context'
function PdfHybridDownloadButton() {
const { pdfDownloadUrl } = usePdfPreviewContext()
const { pdfDownloadUrl } = useCompileContext()
const { t } = useTranslation()

View file

@ -1,12 +1,12 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Label, OverlayTrigger, Tooltip } from 'react-bootstrap'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { sendMBOnce } from '../../../infrastructure/event-tracking'
import Icon from '../../../shared/components/icon'
import { useCompileContext } from '../../../shared/context/compile-context'
function PdfHybridLogsButton() {
const { error, logEntries, setShowLogs, showLogs } = usePdfPreviewContext()
const { error, logEntries, setShowLogs, showLogs } = useCompileContext()
const { t } = useTranslation()

View file

@ -19,10 +19,6 @@ function PdfJsViewer({ url }) {
`pdf-viewer-scale:${projectId}`,
'page-width'
)
const [, setScrollTop] = usePersistedState(
`pdf-viewer-scroll-top:${projectId}`,
0
)
// state values shared with Angular scope (highlights => editor, position => synctex buttons
const [highlights] = useScopeValue('pdf.highlights')
@ -63,23 +59,10 @@ function PdfJsViewer({ url }) {
}
}, [pdfJsWrapper, url])
useEffect(() => {
if (pdfJsWrapper) {
// listen for 'pdf:scroll-to-position' events
const eventListener = event => {
pdfJsWrapper.container.scrollTop = event.data.position
}
window.addEventListener('pdf:scroll-to-position', eventListener)
return () => {
window.removeEventListener('pdf:scroll-to-position', eventListener)
}
}
}, [pdfJsWrapper])
// listen for scroll events
useEffect(() => {
let storePositionTimer
if (initialised && pdfJsWrapper) {
// store the scroll position in localStorage, for the synctex button
const storePosition = debounce(pdfViewer => {
@ -87,33 +70,30 @@ function PdfJsViewer({ url }) {
try {
setPosition(pdfViewer.currentPosition)
} catch (error) {
// TODO
console.error(error)
// TODO: investigate handling missing offsetParent in jsdom
// console.error(error)
}
}, 500)
// store the scroll position in localStorage, for use when reloading
const storeScrollTop = debounce(pdfViewer => {
// set position for "sync to code" button
setScrollTop(pdfViewer.container.scrollTop)
}, 500)
storePosition(pdfJsWrapper)
storePositionTimer = window.setTimeout(() => {
storePosition(pdfJsWrapper)
}, 100)
const scrollListener = () => {
storeScrollTop(pdfJsWrapper)
storePosition(pdfJsWrapper)
}
pdfJsWrapper.container.addEventListener('scroll', scrollListener)
return () => {
storePosition.cancel()
storeScrollTop.cancel()
pdfJsWrapper.container.removeEventListener('scroll', scrollListener)
if (storePositionTimer) {
window.clearTimeout(storePositionTimer)
}
storePosition.cancel()
}
}
}, [setPosition, setScrollTop, pdfJsWrapper, initialised])
}, [setPosition, pdfJsWrapper, initialised])
// listen for double-click events
useEffect(() => {
@ -147,14 +127,14 @@ function PdfJsViewer({ url }) {
})
// restore the scroll position
setScrollTop(scrollTop => {
if (scrollTop > 0) {
pdfJsWrapper.container.scrollTop = scrollTop
setPosition(position => {
if (position) {
pdfJsWrapper.currentPosition = position
}
return scrollTop
return position
})
}
}, [initialised, setScale, setScrollTop, pdfJsWrapper])
}, [initialised, setScale, setPosition, pdfJsWrapper])
// transmit scale value to the viewer when it changes
useEffect(() => {

View file

@ -1,8 +1,8 @@
import { memo, useCallback, useMemo } from 'react'
import { Button } from 'react-bootstrap'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { PdfLogsButtonContent } from './pdf-logs-button-content'
import { sendMBOnce } from '../../../infrastructure/event-tracking'
import { useCompileContext } from '../../../shared/context/compile-context'
function PdfLogsButton() {
const {
@ -11,7 +11,7 @@ function PdfLogsButton() {
logEntries,
showLogs,
setShowLogs,
} = usePdfPreviewContext()
} = useCompileContext()
const buttonStyle = useMemo(() => {
if (showLogs) {

View file

@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'
import PreviewLogsPaneEntry from '../../preview/components/preview-logs-pane-entry'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { memo } from 'react'
import classnames from 'classnames'
import PdfValidationIssue from './pdf-validation-issue'
import TimeoutUpgradePrompt from './timeout-upgrade-prompt'
import PdfPreviewError from './pdf-preview-error'
@ -12,6 +12,7 @@ import withErrorBoundary from '../../../infrastructure/error-boundary'
import ErrorBoundaryFallback from './error-boundary-fallback'
import PdfCodeCheckFailedNotice from '../../preview/components/pdf-code-check-failed-notice'
import PdfLogsPaneInfoNotice from '../../preview/components/pdf-logs-pane-info-notice'
import { useCompileContext } from '../../../shared/context/compile-context'
function PdfLogsViewer() {
const {
@ -20,12 +21,13 @@ function PdfLogsViewer() {
logEntries,
rawLog,
validationIssues,
} = usePdfPreviewContext()
showLogs,
} = useCompileContext()
const { t } = useTranslation()
return (
<div className="logs-pane">
<div className={classnames('logs-pane', { hidden: !showLogs })}>
<div className="logs-pane-content">
<PdfLogsPaneInfoNotice />

View file

@ -1,7 +1,6 @@
import { memo, Suspense } from 'react'
import PdfLogsViewer from './pdf-logs-viewer'
import PdfViewer from './pdf-viewer'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import LoadingSpinner from '../../../shared/components/loading-spinner'
import PdfHybridPreviewToolbar from './pdf-preview-hybrid-toolbar'
import PdfPreviewToolbar from './pdf-preview-toolbar'
@ -11,8 +10,6 @@ const newPreviewToolbar = new URLSearchParams(window.location.search).has(
)
function PdfPreviewPane() {
const { showLogs } = usePdfPreviewContext()
return (
<div className="pdf full-size">
{newPreviewToolbar ? <PdfPreviewToolbar /> : <PdfHybridPreviewToolbar />}
@ -21,7 +18,7 @@ function PdfPreviewPane() {
<PdfViewer />
</div>
</Suspense>
{showLogs && <PdfLogsViewer />}
<PdfLogsViewer />
</div>
)
}

View file

@ -1,15 +1,10 @@
import PdfPreviewProvider from '../contexts/pdf-preview-context'
import PdfPreviewPane from './pdf-preview-pane'
import { memo } from 'react'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import ErrorBoundaryFallback from './error-boundary-fallback'
function PdfPreview() {
return (
<PdfPreviewProvider>
<PdfPreviewPane />
</PdfPreviewProvider>
)
return <PdfPreviewPane />
}
export default withErrorBoundary(memo(PdfPreview), () => (

View file

@ -1,6 +1,6 @@
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { usePdfPreviewContext } from '../contexts/pdf-preview-context'
import { lazy, memo, useEffect } from 'react'
import { useCompileContext } from '../../../shared/context/compile-context'
const PdfJsViewer = lazy(() =>
import(/* webpackChunkName: "pdf-js-viewer" */ './pdf-js-viewer')
@ -19,7 +19,7 @@ function PdfViewer() {
}
}, [setPdfViewer])
const { pdfUrl } = usePdfPreviewContext()
const { pdfUrl } = useCompileContext()
if (!pdfUrl) {
return null

View file

@ -1,494 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import PropTypes from 'prop-types'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { useProjectContext } from '../../../shared/context/project-context'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import {
buildLogEntryAnnotations,
handleOutputFiles,
} from '../util/output-files'
import {
send,
sendMB,
sendMBSampled,
} from '../../../infrastructure/event-tracking'
import { useEditorContext } from '../../../shared/context/editor-context'
import useAbortController from '../../../shared/hooks/use-abort-controller'
import DocumentCompiler from '../util/compiler'
import { useIdeContext } from '../../../shared/context/ide-context'
import { useLayoutContext } from '../../../shared/context/layout-context'
import { useCompileContext } from '../../../shared/context/compile-context'
export const PdfPreviewContext = createContext(undefined)
PdfPreviewProvider.propTypes = {
children: PropTypes.any,
}
export default function PdfPreviewProvider({ children }) {
const ide = useIdeContext()
const { pdfHidden, pdfLayout, setPdfLayout, setView } = useLayoutContext()
const project = useProjectContext()
const projectId = project._id
const { hasPremiumCompile, isProjectOwner } = useEditorContext()
const {
logEntries,
pdfDownloadUrl,
pdfUrl,
setClsiServerId,
setLogEntries,
setLogEntryAnnotations,
setPdfDownloadUrl,
setPdfUrl,
setUncompiled,
uncompiled,
} = useCompileContext()
// whether a compile is in progress
const [compiling, setCompiling] = useState(false)
// data received in response to a compile request
const [data, setData] = useState()
// whether the project has been compiled yet
const [compiledOnce, setCompiledOnce] = useState(false)
// whether the cache is being cleared
const [clearingCache, setClearingCache] = useState(false)
// whether the logs should be visible
const [showLogs, setShowLogs] = useState(false)
// an error that occurred
const [error, setError] = useState()
// the list of files that can be downloaded
const [fileList, setFileList] = useState()
// the raw contents of the log file
const [rawLog, setRawLog] = useState()
// validation issues from CLSI
const [validationIssues, setValidationIssues] = useState()
// whether autocompile is switched on
const [autoCompile, _setAutoCompile] = usePersistedState(
`autocompile_enabled:${projectId}`,
false,
true
)
// whether the compile should run in draft mode
const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, true)
// whether compiling should be prevented if there are linting errors
const [stopOnValidationError, setStopOnValidationError] = usePersistedState(
`stop_on_validation_error:${projectId}`,
true,
true
)
// the Document currently open in the editor
const [currentDoc] = useScopeValue('editor.sharejs_doc')
// whether the editor linter found errors
const [hasLintingError, setHasLintingError] = useScopeValue('hasLintingError')
// whether syntax validation is enabled globally
const [syntaxValidation] = useScopeValue('settings.syntaxValidation')
// the timestamp that a doc was last changed or saved
const [changedAt, setChangedAt] = useState(0)
const { signal } = useAbortController()
// the document compiler
const [compiler] = useState(() => {
return new DocumentCompiler({
project,
setChangedAt,
setCompiling,
setData,
setError,
signal,
})
})
// clean up the compiler on unmount
useEffect(() => {
return () => {
compiler.destroy()
}
}, [compiler])
// keep currentDoc in sync with the compiler
useEffect(() => {
compiler.currentDoc = currentDoc
}, [compiler, currentDoc])
// keep draft setting in sync with the compiler
useEffect(() => {
compiler.draft = draft
}, [compiler, draft])
// pass the "uncompiled" value up into the scope for use outside this context provider
useEffect(() => {
setUncompiled(changedAt > 0)
}, [setUncompiled, changedAt])
// record changes to the autocompile setting
const setAutoCompile = useCallback(
value => {
_setAutoCompile(value)
sendMB('autocompile-setting-changed', { value })
},
[_setAutoCompile]
)
// always compile the PDF once after opening the project, after the doc has loaded
useEffect(() => {
if (!compiledOnce && currentDoc) {
setCompiledOnce(true)
compiler.compile({ isAutoCompileOnLoad: true })
}
}, [compiledOnce, currentDoc, compiler])
// handle the data returned from a compile request
// note: this should _only_ run when `data` changes,
// the other dependencies must all be static
useEffect(() => {
if (data) {
if (data.clsiServerId) {
setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController
compiler.clsiServerId = data.clsiServerId
}
if (data.compileGroup) {
compiler.compileGroup = data.compileGroup
}
if (data.outputFiles) {
handleOutputFiles(projectId, data).then(result => {
setLogEntryAnnotations(
buildLogEntryAnnotations(result.logEntries.all, ide.fileTreeManager)
)
setLogEntries(result.logEntries)
setFileList(result.fileList)
setPdfDownloadUrl(result.pdfDownloadUrl)
setPdfUrl(result.pdfUrl)
setRawLog(result.log)
// sample compile stats for real users
if (!window.user.alphaProgram && data.status === 'success') {
sendMBSampled(
'compile-result',
{
errors: result.logEntries.errors.length,
warnings: result.logEntries.warnings.length,
typesetting: result.logEntries.typesetting.length,
newPdfPreview: true, // TODO: is this useful?
},
0.01
)
}
})
}
switch (data.status) {
case 'success':
setError(undefined)
setShowLogs(false)
break
case 'clsi-maintenance':
case 'compile-in-progress':
case 'exited':
case 'failure':
case 'project-too-large':
case 'terminated':
case 'too-recently-compiled':
setError(data.status)
break
case 'timedout':
setError('timedout')
if (!hasPremiumCompile && isProjectOwner) {
send(
'subscription-funnel',
'editor-click-feature',
'compile-timeout'
)
sendMB('paywall-prompt', {
'paywall-type': 'compile-timeout',
})
}
break
case 'autocompile-backoff':
if (!data.options.isAutoCompileOnLoad) {
setError('autocompile-disabled')
setAutoCompile(false)
sendMB('autocompile-rate-limited', { hasPremiumCompile })
}
break
case 'unavailable':
setError('clsi-unavailable')
break
case 'validation-problems':
setError('validation-problems')
setValidationIssues(data.validationProblems)
break
default:
setError('error')
break
}
}
}, [
compiler,
data,
ide,
hasPremiumCompile,
isProjectOwner,
projectId,
setAutoCompile,
setClsiServerId,
setLogEntries,
setLogEntryAnnotations,
setPdfDownloadUrl,
setPdfUrl,
])
// switch to logs if there's an error
useEffect(() => {
if (error) {
setShowLogs(true)
}
}, [error])
// recompile on key press
useEffect(() => {
const listener = event => {
compiler.compile(event.detail)
}
window.addEventListener('pdf:recompile', listener)
return () => {
window.removeEventListener('pdf:recompile', listener)
}
}, [compiler])
// whether there has been an autocompile linting error, if syntax validation is switched on
const autoCompileLintingError = Boolean(
autoCompile && syntaxValidation && hasLintingError
)
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 && !pdfHidden && !codeCheckFailed
)
// call the debounced autocompile function if the project is available for auto-compiling and it has changed
useEffect(() => {
if (canAutoCompile && changedAt > 0) {
compiler.debouncedAutoCompile()
} else {
compiler.debouncedAutoCompile.cancel()
}
}, [compiler, canAutoCompile, changedAt])
// cancel debounced recompile on unmount
useEffect(() => {
return () => {
compiler.debouncedAutoCompile.cancel()
}
}, [compiler])
// record doc changes when notified by the editor
useEffect(() => {
const listener = () => {
setChangedAt(Date.now())
}
window.addEventListener('doc:changed', listener)
window.addEventListener('doc:saved', listener)
return () => {
window.removeEventListener('doc:changed', listener)
window.removeEventListener('doc:saved', listener)
}
}, [])
// start a compile manually
const startCompile = useCallback(() => {
compiler.compile()
}, [compiler])
// stop a compile manually
const stopCompile = useCallback(() => {
compiler.stopCompile()
}, [compiler])
// clear the compile cache
const clearCache = useCallback(() => {
setClearingCache(true)
return compiler.clearCache().finally(() => {
setClearingCache(false)
})
}, [compiler])
// clear the cache then run a compile, triggered by a menu item
const recompileFromScratch = useCallback(() => {
clearCache().then(() => {
compiler.compile()
})
}, [clearCache, compiler])
// switch to either side-by-side or flat (full-width) layout
// TODO: move this into LayoutContext?
const switchLayout = useCallback(() => {
setPdfLayout(layout => {
const newLayout = layout === 'sideBySide' ? 'flat' : 'sideBySide'
setView(newLayout === 'sideBySide' ? 'editor' : 'pdf')
setPdfLayout(newLayout)
window.localStorage.setItem('pdf.layout', newLayout)
})
}, [setPdfLayout, setView])
// the context value, memoized to minimize re-rendering
const value = useMemo(() => {
return {
autoCompile,
codeCheckFailed,
clearCache,
clearingCache,
compiling,
draft,
error,
fileList,
hasChanges,
hasLintingError,
logEntries,
pdfDownloadUrl,
pdfLayout,
pdfUrl,
rawLog,
recompileFromScratch,
setAutoCompile,
setDraft,
setHasLintingError, // only for stories
setShowLogs,
setStopOnValidationError,
showLogs,
startCompile,
stopCompile,
stopOnValidationError,
switchLayout,
uncompiled,
validationIssues,
}
}, [
autoCompile,
codeCheckFailed,
clearCache,
clearingCache,
compiling,
draft,
error,
fileList,
hasChanges,
hasLintingError,
logEntries,
pdfDownloadUrl,
pdfLayout,
pdfUrl,
rawLog,
recompileFromScratch,
setAutoCompile,
setDraft,
setHasLintingError, // only for stories
setStopOnValidationError,
showLogs,
startCompile,
stopCompile,
stopOnValidationError,
switchLayout,
uncompiled,
validationIssues,
])
return (
<PdfPreviewContext.Provider value={value}>
{children}
</PdfPreviewContext.Provider>
)
}
PdfPreviewContext.Provider.propTypes = {
value: PropTypes.shape({
autoCompile: PropTypes.bool.isRequired,
clearCache: PropTypes.func.isRequired,
clearingCache: PropTypes.bool.isRequired,
codeCheckFailed: PropTypes.bool.isRequired,
compiling: PropTypes.bool.isRequired,
draft: PropTypes.bool.isRequired,
error: PropTypes.string,
fileList: PropTypes.object,
hasChanges: PropTypes.bool.isRequired,
hasLintingError: PropTypes.bool,
logEntries: PropTypes.object,
pdfDownloadUrl: PropTypes.string,
pdfLayout: PropTypes.string,
pdfUrl: PropTypes.string,
rawLog: PropTypes.string,
recompileFromScratch: PropTypes.func.isRequired,
setAutoCompile: PropTypes.func.isRequired,
setDraft: PropTypes.func.isRequired,
setHasLintingError: PropTypes.func.isRequired, // only for storybook
setShowLogs: PropTypes.func.isRequired,
setStopOnValidationError: PropTypes.func.isRequired,
showLogs: PropTypes.bool.isRequired,
startCompile: PropTypes.func.isRequired,
stopCompile: PropTypes.func.isRequired,
stopOnValidationError: PropTypes.bool.isRequired,
switchLayout: PropTypes.func.isRequired,
uncompiled: PropTypes.bool,
validationIssues: PropTypes.object,
}),
}
export function usePdfPreviewContext() {
const context = useContext(PdfPreviewContext)
if (!context) {
throw new Error(
'usePdfPreviewContext is only available inside PdfPreviewProvider'
)
}
return context
}

View file

@ -45,10 +45,6 @@ export default class DocumentCompiler {
)
}
destroy() {
this.debouncedAutoCompile.cancel()
}
// The main "compile" function.
// Call this directly to run a compile now, otherwise call debouncedAutoCompile.
async compile(options = {}) {

View file

@ -151,6 +151,23 @@ export default class PDFJSWrapper {
}
}
set currentPosition(position) {
const destArray = [
null,
{
name: 'XYZ', // 'XYZ' = scroll to the given coordinates
},
position.offset.left,
position.offset.top,
null,
]
this.viewer.scrollPageIntoView({
pageNumber: position.page + 1,
destArray,
})
}
abortDocumentLoading() {
this.loadDocumentTask = undefined
}

View file

@ -1,27 +1,72 @@
import { createContext, useContext, useMemo } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import PropTypes from 'prop-types'
import useScopeValue from '../hooks/use-scope-value'
import usePersistedState from '../hooks/use-persisted-state'
import useAbortController from '../hooks/use-abort-controller'
import DocumentCompiler from '../../features/pdf-preview/util/compiler'
import {
send,
sendMB,
sendMBSampled,
} from '../../infrastructure/event-tracking'
import {
buildLogEntryAnnotations,
handleOutputFiles,
} from '../../features/pdf-preview/util/output-files'
import { useIdeContext } from './ide-context'
import { useProjectContext } from './project-context'
import { useEditorContext } from './editor-context'
export const CompileContext = createContext()
CompileContext.Provider.propTypes = {
value: PropTypes.shape({
autoCompile: PropTypes.bool.isRequired,
clearingCache: PropTypes.bool.isRequired,
clsiServerId: PropTypes.string,
codeCheckFailed: PropTypes.bool.isRequired,
compiling: PropTypes.bool.isRequired,
draft: PropTypes.bool.isRequired,
error: PropTypes.string,
fileList: PropTypes.object,
hasChanges: PropTypes.bool.isRequired,
hasLintingError: PropTypes.bool,
logEntries: PropTypes.object,
logEntryAnnotations: PropTypes.object,
pdfDownloadUrl: PropTypes.string,
pdfUrl: PropTypes.string,
setClsiServerId: PropTypes.func.isRequired,
setLogEntries: PropTypes.func.isRequired,
setLogEntryAnnotations: PropTypes.func.isRequired,
setPdfDownloadUrl: PropTypes.func.isRequired,
setPdfUrl: PropTypes.func.isRequired,
setUncompiled: PropTypes.func.isRequired,
rawLog: PropTypes.string,
setAutoCompile: PropTypes.func.isRequired,
setDraft: PropTypes.func.isRequired,
setHasLintingError: PropTypes.func.isRequired, // only for storybook
setShowLogs: PropTypes.func.isRequired,
setStopOnValidationError: PropTypes.func.isRequired,
showLogs: PropTypes.bool.isRequired,
stopOnValidationError: PropTypes.bool.isRequired,
uncompiled: PropTypes.bool,
validationIssues: PropTypes.object,
}),
}
export function CompileProvider({ children }) {
const ide = useIdeContext()
const { hasPremiumCompile, isProjectOwner } = useEditorContext()
const project = useProjectContext()
const projectId = project._id
// whether a compile is in progress
const [compiling, setCompiling] = useState(false)
// the log entries parsed from the compile output log
const [logEntries, setLogEntries] = useScopeValue('pdf.logEntries')
@ -42,34 +87,368 @@ export function CompileProvider({ children }) {
// the id of the CLSI server which ran the compile
const [clsiServerId, setClsiServerId] = useScopeValue('pdf.clsiServerId')
// data received in response to a compile request
const [data, setData] = useState()
// whether the project has been compiled yet
const [compiledOnce, setCompiledOnce] = useState(false)
// whether the cache is being cleared
const [clearingCache, setClearingCache] = useState(false)
// whether the logs should be visible
const [showLogs, setShowLogs] = useState(false)
// an error that occurred
const [error, setError] = useState()
// the list of files that can be downloaded
const [fileList, setFileList] = useState()
// the raw contents of the log file
const [rawLog, setRawLog] = useState()
// validation issues from CLSI
const [validationIssues, setValidationIssues] = useState()
// whether autocompile is switched on
const [autoCompile, _setAutoCompile] = usePersistedState(
`autocompile_enabled:${projectId}`,
false,
true
)
// whether the compile should run in draft mode
const [draft, setDraft] = usePersistedState(`draft:${projectId}`, false, true)
// whether compiling should be prevented if there are linting errors
const [stopOnValidationError, setStopOnValidationError] = usePersistedState(
`stop_on_validation_error:${projectId}`,
true,
true
)
// the Document currently open in the editor
const [currentDoc] = useScopeValue('editor.sharejs_doc')
// whether the editor linter found errors
const [hasLintingError, setHasLintingError] = useScopeValue('hasLintingError')
// whether syntax validation is enabled globally
const [syntaxValidation] = useScopeValue('settings.syntaxValidation')
// the timestamp that a doc was last changed or saved
const [changedAt, setChangedAt] = useState(0)
const { signal } = useAbortController()
// the document compiler
const [compiler] = useState(() => {
return new DocumentCompiler({
project,
setChangedAt,
setCompiling,
setData,
setError,
signal,
})
})
// keep currentDoc in sync with the compiler
useEffect(() => {
compiler.currentDoc = currentDoc
}, [compiler, currentDoc])
// keep draft setting in sync with the compiler
useEffect(() => {
compiler.draft = draft
}, [compiler, draft])
// pass the "uncompiled" value up into the scope for use outside this context provider
useEffect(() => {
setUncompiled(changedAt > 0)
}, [setUncompiled, changedAt])
// record changes to the autocompile setting
const setAutoCompile = useCallback(
value => {
_setAutoCompile(value)
sendMB('autocompile-setting-changed', { value })
},
[_setAutoCompile]
)
// always compile the PDF once after opening the project, after the doc has loaded
useEffect(() => {
if (!compiledOnce && currentDoc) {
setCompiledOnce(true)
compiler.compile({ isAutoCompileOnLoad: true })
}
}, [compiledOnce, currentDoc, compiler])
// handle the data returned from a compile request
// note: this should _only_ run when `data` changes,
// the other dependencies must all be static
useEffect(() => {
if (data) {
if (data.clsiServerId) {
setClsiServerId(data.clsiServerId) // set in scope, for PdfSynctexController
compiler.clsiServerId = data.clsiServerId
}
if (data.compileGroup) {
compiler.compileGroup = data.compileGroup
}
if (data.outputFiles) {
handleOutputFiles(projectId, data).then(result => {
setLogEntryAnnotations(
buildLogEntryAnnotations(result.logEntries.all, ide.fileTreeManager)
)
setLogEntries(result.logEntries)
setFileList(result.fileList)
setPdfDownloadUrl(result.pdfDownloadUrl)
setPdfUrl(result.pdfUrl)
setRawLog(result.log)
// sample compile stats for real users
if (!window.user.alphaProgram && data.status === 'success') {
sendMBSampled(
'compile-result',
{
errors: result.logEntries.errors.length,
warnings: result.logEntries.warnings.length,
typesetting: result.logEntries.typesetting.length,
newPdfPreview: true, // TODO: is this useful?
},
0.01
)
}
})
}
switch (data.status) {
case 'success':
setError(undefined)
setShowLogs(false)
break
case 'clsi-maintenance':
case 'compile-in-progress':
case 'exited':
case 'failure':
case 'project-too-large':
case 'rate-limited':
case 'terminated':
case 'too-recently-compiled':
setError(data.status)
break
case 'timedout':
setError('timedout')
if (!hasPremiumCompile && isProjectOwner) {
send(
'subscription-funnel',
'editor-click-feature',
'compile-timeout'
)
sendMB('paywall-prompt', {
'paywall-type': 'compile-timeout',
})
}
break
case 'autocompile-backoff':
if (!data.options.isAutoCompileOnLoad) {
setError('autocompile-disabled')
setAutoCompile(false)
sendMB('autocompile-rate-limited', { hasPremiumCompile })
}
break
case 'unavailable':
setError('clsi-unavailable')
break
case 'validation-problems':
setError('validation-problems')
setValidationIssues(data.validationProblems)
break
default:
setError('error')
break
}
}
}, [
compiler,
data,
ide,
hasPremiumCompile,
isProjectOwner,
projectId,
setAutoCompile,
setClsiServerId,
setLogEntries,
setLogEntryAnnotations,
setPdfDownloadUrl,
setPdfUrl,
])
// switch to logs if there's an error
useEffect(() => {
if (error) {
setShowLogs(true)
}
}, [error])
// recompile on key press
useEffect(() => {
const listener = event => {
compiler.compile(event.detail)
}
window.addEventListener('pdf:recompile', listener)
return () => {
window.removeEventListener('pdf:recompile', listener)
}
}, [compiler])
// whether there has been an autocompile linting error, if syntax validation is switched on
const autoCompileLintingError = Boolean(
autoCompile && syntaxValidation && hasLintingError
)
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)
// call the debounced autocompile function if the project is available for auto-compiling and it has changed
useEffect(() => {
if (canAutoCompile && changedAt > 0) {
compiler.debouncedAutoCompile()
} else {
compiler.debouncedAutoCompile.cancel()
}
}, [compiler, canAutoCompile, changedAt])
// cancel debounced recompile on unmount
useEffect(() => {
return () => {
compiler.debouncedAutoCompile.cancel()
}
}, [compiler])
// record doc changes when notified by the editor
useEffect(() => {
const listener = event => {
setChangedAt(Date.now())
}
window.addEventListener('doc:changed', listener)
window.addEventListener('doc:saved', listener)
return () => {
window.removeEventListener('doc:changed', listener)
window.removeEventListener('doc:saved', listener)
}
}, [])
// start a compile manually
const startCompile = useCallback(() => {
compiler.compile()
}, [compiler])
// stop a compile manually
const stopCompile = useCallback(() => {
compiler.stopCompile()
}, [compiler])
// clear the compile cache
const clearCache = useCallback(() => {
setClearingCache(true)
return compiler.clearCache().finally(() => {
setClearingCache(false)
})
}, [compiler, setClearingCache])
// clear the cache then run a compile, triggered by a menu item
const recompileFromScratch = useCallback(() => {
clearCache().then(() => {
compiler.compile()
})
}, [clearCache, compiler])
const value = useMemo(
() => ({
autoCompile,
clearCache,
clearingCache,
clsiServerId,
codeCheckFailed,
compiling,
draft,
error,
fileList,
hasChanges,
hasLintingError,
logEntries,
logEntryAnnotations,
pdfDownloadUrl,
pdfUrl,
setClsiServerId,
setLogEntries,
setLogEntryAnnotations,
setPdfDownloadUrl,
setPdfUrl,
setUncompiled,
rawLog,
recompileFromScratch,
setAutoCompile,
setClearingCache,
setCompiling,
setDraft,
setHasLintingError, // only for stories
setShowLogs,
setStopOnValidationError,
showLogs,
startCompile,
stopCompile,
stopOnValidationError,
uncompiled,
validationIssues,
}),
[
autoCompile,
clearCache,
clearingCache,
clsiServerId,
codeCheckFailed,
compiling,
draft,
error,
fileList,
hasChanges,
hasLintingError,
logEntries,
logEntryAnnotations,
pdfDownloadUrl,
pdfUrl,
setClsiServerId,
setLogEntries,
setLogEntryAnnotations,
setPdfDownloadUrl,
setPdfUrl,
setUncompiled,
rawLog,
recompileFromScratch,
setAutoCompile,
setDraft,
setHasLintingError,
setStopOnValidationError,
showLogs,
startCompile,
stopCompile,
stopOnValidationError,
uncompiled,
validationIssues,
]
)

View file

@ -2,6 +2,7 @@ import { createContext, useContext, useCallback, useMemo } from 'react'
import PropTypes from 'prop-types'
import useScopeValue from '../hooks/use-scope-value'
import { useIdeContext } from './ide-context'
import localStorage from '../../infrastructure/local-storage'
export const LayoutContext = createContext()
@ -15,7 +16,7 @@ LayoutContext.Provider.propTypes = {
setReviewPanelOpen: PropTypes.func.isRequired,
leftMenuShown: PropTypes.bool,
setLeftMenuShown: PropTypes.func.isRequired,
pdfLayout: PropTypes.oneOf(['sideBySide', 'flat', 'split']).isRequired,
pdfLayout: PropTypes.oneOf(['sideBySide', 'flat']).isRequired,
}).isRequired,
}
@ -53,14 +54,20 @@ export function LayoutProvider({ children }) {
// whether to display the editor and preview side-by-side or full-width ("flat")
const [pdfLayout, setPdfLayout] = useScopeValue('ui.pdfLayout')
// whether the PDF preview pane is hidden
const [pdfHidden] = useScopeValue('ui.pdfHidden')
// switch to either side-by-side or flat (full-width) layout
const switchLayout = useCallback(() => {
setPdfLayout(layout => {
const newLayout = layout === 'sideBySide' ? 'flat' : 'sideBySide'
setView(newLayout === 'sideBySide' ? 'editor' : 'pdf')
setPdfLayout(newLayout)
localStorage.setItem('pdf.layout', newLayout)
})
}, [setPdfLayout, setView])
const value = useMemo(
() => ({
chatIsOpen,
leftMenuShown,
pdfHidden,
pdfLayout,
reviewPanelOpen,
setChatIsOpen,
@ -68,12 +75,12 @@ export function LayoutProvider({ children }) {
setPdfLayout,
setReviewPanelOpen,
setView,
switchLayout,
view,
}),
[
chatIsOpen,
leftMenuShown,
pdfHidden,
pdfLayout,
reviewPanelOpen,
setChatIsOpen,
@ -81,6 +88,7 @@ export function LayoutProvider({ children }) {
setPdfLayout,
setReviewPanelOpen,
setView,
switchLayout,
view,
]
)

View file

@ -17,11 +17,11 @@ export function ContextRoot({ children, ide, settings }) {
<UserProvider>
<ProjectProvider>
<EditorProvider settings={settings}>
<CompileProvider>
<LayoutProvider>
<LayoutProvider>
<CompileProvider>
<ChatProvider>{children}</ChatProvider>
</LayoutProvider>
</CompileProvider>
</CompileProvider>
</LayoutProvider>
</EditorProvider>
</ProjectProvider>
</UserProvider>

View file

@ -3,9 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import useFetchMock from './hooks/use-fetch-mock'
import { setupContext } from './fixtures/context'
import { Button } from 'react-bootstrap'
import PdfPreviewProvider, {
usePdfPreviewContext,
} from '../js/features/pdf-preview/contexts/pdf-preview-context'
import PdfPreviewPane from '../js/features/pdf-preview/components/pdf-preview-pane'
import PdfPreview from '../js/features/pdf-preview/components/pdf-preview'
import PdfPreviewToolbar from '../js/features/pdf-preview/components/pdf-preview-toolbar'
@ -15,6 +12,7 @@ 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'
setupContext()
@ -230,7 +228,7 @@ export const Interactive = () => {
}, [])
const Inner = () => {
const context = usePdfPreviewContext()
const context = useCompileContext()
const { setHasLintingError } = context
@ -369,10 +367,8 @@ export const Interactive = () => {
return withContextRoot(
<div className="pdf-viewer">
<PdfPreviewProvider>
<PdfPreviewPane />
<Inner />
</PdfPreviewProvider>
<PdfPreviewPane />
<Inner />
</div>,
scope
)
@ -406,7 +402,7 @@ export const CompileError = () => {
})
const Inner = () => {
const { startCompile } = usePdfPreviewContext()
const { startCompile } = useCompileContext()
const handleStatusChange = useCallback(
event => {
@ -441,10 +437,10 @@ export const CompileError = () => {
}
return withContextRoot(
<PdfPreviewProvider>
<>
<PdfPreviewPane />
<Inner />
</PdfPreviewProvider>,
</>,
scope
)
}
@ -470,7 +466,7 @@ const compileErrors = [
export const DisplayError = () => {
return withContextRoot(
<PdfPreviewProvider>
<>
{compileErrors.map(error => (
<div
key={error}
@ -480,7 +476,7 @@ export const DisplayError = () => {
<PdfPreviewError error={error} />
</div>
))}
</PdfPreviewProvider>,
</>,
scope
)
}
@ -489,11 +485,9 @@ export const Toolbar = () => {
useFetchMock(fetchMock => mockCompile(fetchMock, 500))
return withContextRoot(
<PdfPreviewProvider>
<div className="pdf">
<PdfPreviewToolbar />
</div>
</PdfPreviewProvider>,
<div className="pdf">
<PdfPreviewToolbar />
</div>,
scope
)
}
@ -505,11 +499,9 @@ export const HybridToolbar = () => {
})
return withContextRoot(
<PdfPreviewProvider>
<div className="pdf">
<PdfPreviewHybridToolbar />
</div>
</PdfPreviewProvider>,
<div className="pdf">
<PdfPreviewHybridToolbar />
</div>,
scope
)
}
@ -530,21 +522,15 @@ export const FileList = () => {
export const Logs = () => {
useFetchMock(fetchMock => {
mockCompile(fetchMock, 0)
mockCompileError(fetchMock, 400, 0)
mockBuildFile(fetchMock)
mockClearCache(fetchMock)
})
useEffect(() => {
dispatchProjectJoined()
}, [])
return withContextRoot(
<PdfPreviewProvider>
<div className="pdf">
<PdfLogsViewer />
</div>
</PdfPreviewProvider>,
<div className="pdf">
<PdfLogsViewer />
</div>,
scope
)
}
@ -577,10 +563,5 @@ export const ValidationIssues = () => {
dispatchProjectJoined()
}, [])
return withContextRoot(
<PdfPreviewProvider>
<PdfPreviewPane />
</PdfPreviewProvider>,
scope
)
return withContextRoot(<PdfPreviewPane />, scope)
}