diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug index f8c0451ebd..9cb9c71585 100644 --- a/services/web/app/views/project/editor/editor.pug +++ b/services/web/app/views/project/editor/editor.pug @@ -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( diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js index cc76e9463b..d28028e394 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-clear-cache-button.js @@ -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() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js index 4b4694ab71..cccec748cb 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-compile-button.js @@ -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() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-download-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-download-button.js index 4814416f74..e00ffe97ec 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-download-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-download-button.js @@ -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() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js index e7c8de1884..86640c73ff 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-download-files-button.js @@ -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() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-expand-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-expand-button.js index 3534e43904..78f047cac5 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-expand-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-expand-button.js @@ -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() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-code-check-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-code-check-button.js index ccab8740ab..92b02c2875 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-code-check-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-code-check-button.js @@ -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() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.js index 5567adf427..afd1d5ed10 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-download-button.js @@ -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() diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-logs-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-logs-button.js index 5c414c826d..f843b16a61 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-logs-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-hybrid-logs-button.js @@ -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() 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 7f13e0f010..75998c2f62 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 @@ -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(() => { diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js index 56186251f6..5f9943b314 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-button.js @@ -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) { diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js index 54416d1552..bac0a4c553 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-logs-viewer.js @@ -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 ( -
+
diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js index 7317a364f6..c0d31e6780 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-pane.js @@ -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 (
{newPreviewToolbar ? : } @@ -21,7 +18,7 @@ function PdfPreviewPane() {
- {showLogs && } +
) } diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview.js b/services/web/frontend/js/features/pdf-preview/components/pdf-preview.js index e671b06034..7c828b1ddd 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview.js +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-preview.js @@ -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 ( - - - - ) + return } export default withErrorBoundary(memo(PdfPreview), () => ( 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 36f9cca10c..91209b2db8 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,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 diff --git a/services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js b/services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js deleted file mode 100644 index abd3fc5102..0000000000 --- a/services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js +++ /dev/null @@ -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 ( - - {children} - - ) -} - -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 -} diff --git a/services/web/frontend/js/features/pdf-preview/util/compiler.js b/services/web/frontend/js/features/pdf-preview/util/compiler.js index d29e71a683..1e9f23dd3e 100644 --- a/services/web/frontend/js/features/pdf-preview/util/compiler.js +++ b/services/web/frontend/js/features/pdf-preview/util/compiler.js @@ -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 = {}) { 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 68d59492c8..a1a230d3ff 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 @@ -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 } diff --git a/services/web/frontend/js/shared/context/compile-context.js b/services/web/frontend/js/shared/context/compile-context.js index 36c694ab3a..11b6fb62fe 100644 --- a/services/web/frontend/js/shared/context/compile-context.js +++ b/services/web/frontend/js/shared/context/compile-context.js @@ -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, ] ) diff --git a/services/web/frontend/js/shared/context/layout-context.js b/services/web/frontend/js/shared/context/layout-context.js index 035d4a07f6..57a5162dc6 100644 --- a/services/web/frontend/js/shared/context/layout-context.js +++ b/services/web/frontend/js/shared/context/layout-context.js @@ -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, ] ) diff --git a/services/web/frontend/js/shared/context/root-context.js b/services/web/frontend/js/shared/context/root-context.js index 906761bcbe..eb35c981d7 100644 --- a/services/web/frontend/js/shared/context/root-context.js +++ b/services/web/frontend/js/shared/context/root-context.js @@ -17,11 +17,11 @@ export function ContextRoot({ children, ide, settings }) { - - + + {children} - - + + diff --git a/services/web/frontend/stories/pdf-preview.stories.js b/services/web/frontend/stories/pdf-preview.stories.js index a0725978c1..51633ebc43 100644 --- a/services/web/frontend/stories/pdf-preview.stories.js +++ b/services/web/frontend/stories/pdf-preview.stories.js @@ -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(
- - - - + +
, 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( - + <> - , + , scope ) } @@ -470,7 +466,7 @@ const compileErrors = [ export const DisplayError = () => { return withContextRoot( - + <> {compileErrors.map(error => (
{
))} -
, + , scope ) } @@ -489,11 +485,9 @@ export const Toolbar = () => { useFetchMock(fetchMock => mockCompile(fetchMock, 500)) return withContextRoot( - -
- -
-
, +
+ +
, scope ) } @@ -505,11 +499,9 @@ export const HybridToolbar = () => { }) return withContextRoot( - -
- -
-
, +
+ +
, 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( - -
- -
-
, +
+ +
, scope ) } @@ -577,10 +563,5 @@ export const ValidationIssues = () => { dispatchProjectJoined() }, []) - return withContextRoot( - - - , - scope - ) + return withContextRoot(, scope) }