+ {t('free_accounts_have_timeout_upgrade_to_increase')}
+ {t('plus_upgraded_accounts_receive')}:
+
+
+ -
+
+
+ {t('unlimited_projects')}
+
+ -
+
+
+ {t('collabs_per_proj', { collabcount: 'Multiple' })}
+
+ -
+
+
+ {t('full_doc_history')}
+
+ -
+
+
+ {t('sync_to_dropbox')}
+
+ -
+
+
+ {t('sync_to_github')}
+
+ -
+
+
+ {t('compile_larger_projects')}
+
+
+
+ {isProjectOwner && (
+
+
+
+ )}
+ >
+ }
+ entryAriaLabel={
+ isProjectOwner
+ ? t('upgrade_for_longer_compiles')
+ : t('ask_proj_owner_to_upgrade_for_longer_compiles')
+ }
+ level="success"
+ />
+ )
+}
+
+export default memo(TimeoutUpgradePrompt)
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
new file mode 100644
index 0000000000..4d52dee992
--- /dev/null
+++ b/services/web/frontend/js/features/pdf-preview/contexts/pdf-preview-context.js
@@ -0,0 +1,659 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react'
+import PropTypes from 'prop-types'
+import useScopeValue from '../../../shared/context/util/scope-value-hook'
+import { useProjectContext } from '../../../shared/context/project-context'
+import getMeta from '../../../utils/meta'
+import { deleteJSON, postJSON } from '../../../infrastructure/fetch-json'
+import usePersistedState from '../../../shared/hooks/use-persisted-state'
+import {
+ buildLogEntryAnnotations,
+ handleOutputFiles,
+} from '../util/output-files'
+import { debounce } from 'lodash'
+import { useIdeContext } from '../../../shared/context/ide-context'
+import {
+ send,
+ sendMB,
+ sendMBSampled,
+} from '../../../infrastructure/event-tracking'
+import { useEditorContext } from '../../../shared/context/editor-context'
+import { isMainFile } from '../util/editor-files'
+import useAbortController from '../../../shared/hooks/use-abort-controller'
+
+const AUTO_COMPILE_MAX_WAIT = 5000
+// We add a 1 second debounce to sending user changes to server if they aren't
+// collaborating with anyone. This needs to be higher than that, and allow for
+// client to server latency, otherwise we compile before the op reaches the server
+// and then again on ack.
+const AUTO_COMPILE_DEBOUNCE = 2000
+
+const searchParams = new URLSearchParams(window.location.search)
+
+export const PdfPreviewContext = createContext(undefined)
+
+PdfPreviewProvider.propTypes = {
+ children: PropTypes.any,
+}
+
+export default function PdfPreviewProvider({ children }) {
+ const ide = useIdeContext()
+
+ const { _id: projectId, rootDoc_id: rootDocId } = useProjectContext()
+
+ const { hasPremiumCompile, isProjectOwner } = useEditorContext()
+
+ // the URL for loading the PDF in the preview pane
+ const [pdfUrl, setPdfUrl] = useScopeValue('pdf.url')
+
+ // the URL for downloading the PDF
+ const [pdfDownloadUrl, setPdfDownloadUrl] = useScopeValue('pdf.downloadUrl')
+
+ // the log entries parsed from the compile output log
+ const [logEntries, setLogEntries] = useScopeValue('pdf.logEntries')
+
+ // the project is considered to be "uncompiled" if a doc has changed since the last compile started
+ const [uncompiled, setUncompiled] = useScopeValue('pdf.uncompiled')
+
+ // annotations for display in the editor, built from the log entries
+ const [, setLogEntryAnnotations] = useScopeValue('pdf.logEntryAnnotations')
+
+ // the id of the CLSI server which ran the compile
+ const [clsiServerId, setClsiServerId] = useScopeValue('ide.clsiServerId')
+
+ // the compile group (standard or priority)
+ const [compileGroup, setCompileGroup] = useScopeValue('ide.compileGroup')
+
+ // whether to display the editor and preview side-by-side or full-width ("flat")
+ const [pdfLayout, setPdfLayout] = useScopeValue('ui.pdfLayout')
+
+ // what to show in the "flat" view (editor or pdf)
+ const [, setUiView] = useScopeValue('ui.view')
+
+ // whether a compile is in progress
+ const [compiling, setCompiling] = useState(false)
+
+ // 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 id of the currently open document in the editor
+ const [currentDocId] = useScopeValue('editor.open_doc_id')
+
+ // the Document currently open in the editor?
+ const [currentDoc] = useScopeValue('editor.sharejs_doc')
+
+ // whether the PDF view is hidden
+ const [pdfHidden] = useScopeValue('ui.pdfHidden')
+
+ // 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()
+
+ // 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]
+ )
+
+ // parse the text of the current doc in the editor
+ // if it contains "\documentclass" then use this as the root doc
+ const getRootDocOverrideId = useCallback(() => {
+ if (currentDocId === rootDocId) {
+ return null // no need to override when in the root doc itself
+ }
+
+ if (currentDoc) {
+ const doc = currentDoc.getSnapshot()
+
+ if (doc) {
+ return isMainFile(doc)
+ }
+ }
+
+ return null
+ }, [currentDoc, currentDocId, rootDocId])
+
+ // TODO: remove this?
+ const sendCompileMetrics = useCallback(() => {
+ if (compiledOnce && !error && !window.user.alphaProgram) {
+ const metadata = {
+ errors: logEntries.errors.length,
+ warnings: logEntries.warnings.length,
+ typesetting: logEntries.typesetting.length,
+ newPdfPreview: true,
+ }
+ sendMBSampled('compile-result', metadata, 0.01)
+ }
+ }, [compiledOnce, error, logEntries])
+
+ // handle the data returned from a compile request
+ const handleCompileData = useCallback(
+ (data, options) => {
+ if (data.clsiServerId) {
+ setClsiServerId(data.clsiServerId)
+ }
+
+ if (data.compileGroup) {
+ setCompileGroup(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)
+ })
+ }
+
+ switch (data.status) {
+ case 'success':
+ setError(undefined)
+ setShowLogs(false) // TODO: always?
+ 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 (!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
+ }
+ },
+ [
+ hasPremiumCompile,
+ ide.fileTreeManager,
+ isProjectOwner,
+ projectId,
+ setAutoCompile,
+ setClsiServerId,
+ setCompileGroup,
+ setLogEntries,
+ setLogEntryAnnotations,
+ setPdfDownloadUrl,
+ setPdfUrl,
+ ]
+ )
+
+ const buildCompileParams = useCallback(
+ options => {
+ const params = new URLSearchParams()
+
+ if (clsiServerId) {
+ params.set('clsiserverid', clsiServerId)
+ }
+
+ if (options.isAutoCompileOnLoad || options.isAutoCompileOnChange) {
+ params.set('auto_compile', 'true')
+ }
+
+ if (getMeta('ol-enablePdfCaching')) {
+ params.set('enable_pdf_caching', 'true')
+ }
+
+ if (searchParams.get('file_line_errors') === 'true') {
+ params.file_line_errors = 'true'
+ }
+
+ return params
+ },
+ [clsiServerId]
+ )
+
+ // run a compile
+ const recompile = useCallback(
+ (options = {}) => {
+ if (compiling) {
+ return
+ }
+
+ sendMBSampled('editor-recompile-sampled', options)
+
+ setChangedAt(0) // NOTE: this sets uncompiled to false
+ setCompiling(true)
+ setValidationIssues(undefined)
+
+ window.dispatchEvent(new CustomEvent('flush-changes')) // TODO: wait for this?
+
+ postJSON(`/project/${projectId}/compile?${buildCompileParams(options)}`, {
+ body: {
+ rootDoc_id: getRootDocOverrideId(),
+ draft,
+ check: 'silent', // NOTE: 'error' and 'validate' are possible, but unused
+ // use incremental compile for all users but revert to a full compile
+ // if there was previously a server error
+ incrementalCompilesEnabled: !error,
+ },
+ signal,
+ })
+ .then(data => {
+ handleCompileData(data, options)
+ })
+ .catch(error => {
+ // console.error(error)
+ setError(error.info?.statusCode === 429 ? 'rate-limited' : 'error')
+ })
+ .finally(() => {
+ setCompiling(false)
+ sendCompileMetrics()
+ })
+ },
+ [
+ compiling,
+ projectId,
+ buildCompileParams,
+ getRootDocOverrideId,
+ draft,
+ error,
+ handleCompileData,
+ sendCompileMetrics,
+ signal,
+ ]
+ )
+
+ // switch to logs if there's an error
+ useEffect(() => {
+ if (error) {
+ setShowLogs(true)
+ }
+ }, [error])
+
+ // recompile on key press
+ useEffect(() => {
+ const listener = event => {
+ recompile(event.detail)
+ }
+
+ window.addEventListener('pdf:recompile', listener)
+
+ return () => {
+ window.removeEventListener('pdf:recompile', listener)
+ }
+ }, [recompile])
+
+ // always compile the PDF once, when joining the project
+ useEffect(() => {
+ const listener = () => {
+ if (!compiledOnce) {
+ setCompiledOnce(true)
+ recompile({ isAutoCompileOnLoad: true })
+ }
+ }
+
+ window.addEventListener('project:joined', listener)
+
+ return () => {
+ window.removeEventListener('project:joined', listener)
+ }
+ }, [compiledOnce, recompile])
+
+ // whether there has been an autocompile linting error, if syntax validation is switched on
+ const autoCompileLintingError = Boolean(
+ autoCompile && syntaxValidation && hasLintingError
+ )
+
+ // the project has visible changes
+ const hasChanges = Boolean(
+ autoCompile &&
+ uncompiled &&
+ compiledOnce &&
+ !(stopOnValidationError && autoCompileLintingError)
+ )
+
+ // the project is available for auto-compiling
+ const canAutoCompile = Boolean(
+ autoCompile &&
+ !compiling &&
+ !pdfHidden &&
+ !(stopOnValidationError && autoCompileLintingError)
+ )
+
+ // a debounced wrapper around the recompile function, used for auto-compile
+ const [debouncedAutoCompile] = useState(() => {
+ return debounce(
+ () => {
+ recompile({ isAutoCompileOnChange: true })
+ },
+ AUTO_COMPILE_DEBOUNCE,
+ {
+ maxWait: AUTO_COMPILE_MAX_WAIT,
+ }
+ )
+ })
+
+ // call the debounced recompile function if the project is available for auto-compiling and it has changed
+ useEffect(() => {
+ if (canAutoCompile && changedAt > 0) {
+ debouncedAutoCompile()
+ } else {
+ debouncedAutoCompile.cancel()
+ }
+ }, [canAutoCompile, debouncedAutoCompile, recompile, changedAt])
+
+ // cancel debounced recompile on unmount
+ useEffect(() => {
+ return () => {
+ debouncedAutoCompile.cancel()
+ }
+ }, [debouncedAutoCompile])
+
+ // 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)
+ }
+ }, [])
+
+ // send a request to stop the current compile
+ const stopCompile = useCallback(() => {
+ // TODO: stoppingCompile state?
+
+ const params = new URLSearchParams()
+
+ if (clsiServerId) {
+ params.set('clsiserverid', clsiServerId)
+ }
+
+ return postJSON(`/project/${projectId}/compile/stop?${params}`, { signal })
+ .catch(error => {
+ setError(error)
+ })
+ .finally(() => {
+ setCompiling(false)
+ })
+ }, [projectId, clsiServerId, signal])
+
+ const clearCache = useCallback(() => {
+ setClearingCache(true)
+
+ const params = new URLSearchParams()
+
+ if (clsiServerId) {
+ params.set('clsiserverid', clsiServerId)
+ }
+
+ return deleteJSON(`/project/${projectId}/output?${params}`, { signal })
+ .catch(error => {
+ console.error(error)
+ setError('clear-cache')
+ })
+ .finally(() => {
+ setClearingCache(false)
+ })
+ }, [clsiServerId, projectId, setError, signal])
+
+ // clear the cache then run a compile, triggered by a menu item
+ const recompileFromScratch = useCallback(() => {
+ setClearingCache(true)
+ clearCache().then(() => {
+ setClearingCache(false)
+ recompile()
+ })
+ }, [clearCache, recompile])
+
+ // switch to either side-by-side or flat (full-width) layout
+ const switchLayout = useCallback(() => {
+ setPdfLayout(layout => {
+ const newLayout = layout === 'sideBySide' ? 'flat' : 'sideBySide'
+ setUiView(newLayout === 'sideBySide' ? 'editor' : 'pdf')
+ setPdfLayout(newLayout)
+ window.localStorage.setItem('pdf.layout', newLayout)
+ })
+ }, [setPdfLayout, setUiView])
+
+ // the context value, memoized to minimize re-rendering
+ const value = useMemo(() => {
+ return {
+ autoCompile,
+ autoCompileLintingError,
+ clearCache,
+ clearingCache,
+ clsiServerId,
+ compileGroup,
+ compiledOnce,
+ compiling,
+ draft,
+ error,
+ fileList,
+ hasChanges,
+ hasLintingError,
+ logEntries,
+ pdfDownloadUrl,
+ pdfLayout,
+ pdfUrl,
+ rawLog,
+ recompile,
+ recompileFromScratch,
+ setAutoCompile,
+ setClsiServerId,
+ setCompileGroup,
+ setCompiledOnce,
+ setDraft,
+ setError,
+ setHasLintingError, // for story
+ setLogEntries,
+ setPdfDownloadUrl,
+ setPdfLayout,
+ setPdfUrl,
+ setShowLogs,
+ setStopOnValidationError,
+ setUiView,
+ showLogs,
+ stopCompile,
+ stopOnValidationError,
+ switchLayout,
+ uncompiled,
+ validationIssues,
+ }
+ }, [
+ autoCompile,
+ autoCompileLintingError,
+ clearCache,
+ clearingCache,
+ clsiServerId,
+ compileGroup,
+ compiledOnce,
+ compiling,
+ draft,
+ error,
+ fileList,
+ hasChanges,
+ hasLintingError,
+ logEntries,
+ pdfDownloadUrl,
+ pdfLayout,
+ pdfUrl,
+ rawLog,
+ recompile,
+ recompileFromScratch,
+ setAutoCompile,
+ setClsiServerId,
+ setCompileGroup,
+ setCompiledOnce,
+ setDraft,
+ setError,
+ setHasLintingError,
+ setLogEntries,
+ setPdfDownloadUrl,
+ setPdfLayout,
+ setPdfUrl,
+ setStopOnValidationError,
+ setUiView,
+ showLogs,
+ stopCompile,
+ stopOnValidationError,
+ switchLayout,
+ uncompiled,
+ validationIssues,
+ ])
+
+ return (
+
+ {children}
+
+ )
+}
+
+PdfPreviewContext.Provider.propTypes = {
+ value: PropTypes.shape({
+ autoCompile: PropTypes.bool.isRequired,
+ autoCompileLintingError: PropTypes.bool.isRequired,
+ clearCache: PropTypes.func.isRequired,
+ clearingCache: PropTypes.bool.isRequired,
+ clsiServerId: PropTypes.string,
+ compileGroup: PropTypes.string,
+ compiledOnce: 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,
+ recompile: PropTypes.func.isRequired,
+ recompileFromScratch: PropTypes.func.isRequired,
+ setAutoCompile: PropTypes.func.isRequired,
+ setClsiServerId: PropTypes.func.isRequired,
+ setCompileGroup: PropTypes.func.isRequired,
+ setCompiledOnce: PropTypes.func.isRequired,
+ setDraft: PropTypes.func.isRequired,
+ setError: PropTypes.func.isRequired,
+ setHasLintingError: PropTypes.func.isRequired, // only for storybook
+ setLogEntries: PropTypes.func.isRequired,
+ setPdfDownloadUrl: PropTypes.func.isRequired,
+ setPdfLayout: PropTypes.func.isRequired,
+ setPdfUrl: PropTypes.func.isRequired,
+ setShowLogs: PropTypes.func.isRequired,
+ setStopOnValidationError: PropTypes.func.isRequired,
+ setUiView: PropTypes.func.isRequired,
+ showLogs: PropTypes.bool.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/controllers/pdf-preview-controller.js b/services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js
index b8e535fa23..7254e7d774 100644
--- a/services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js
+++ b/services/web/frontend/js/features/pdf-preview/controllers/pdf-preview-controller.js
@@ -1,6 +1,7 @@
import App from '../../../base'
import { react2angular } from 'react2angular'
-import PdfPreviewPane from '../components/pdf-preview-pane'
+import PdfPreview from '../components/pdf-preview'
+import { rootContext } from '../../../shared/context/root-context'
-App.component('pdfPreviewPane', react2angular(PdfPreviewPane, undefined))
+App.component('pdfPreview', react2angular(rootContext.use(PdfPreview), []))
diff --git a/services/web/frontend/js/features/pdf-preview/hooks/use-resize-observer.js b/services/web/frontend/js/features/pdf-preview/hooks/use-resize-observer.js
new file mode 100644
index 0000000000..e3d2ab30c3
--- /dev/null
+++ b/services/web/frontend/js/features/pdf-preview/hooks/use-resize-observer.js
@@ -0,0 +1,34 @@
+import { useCallback, useEffect, useRef } from 'react'
+
+export const useResizeObserver = callback => {
+ const resizeRef = useRef(null)
+
+ const elementRef = useCallback(
+ element => {
+ if (element) {
+ if (resizeRef.current) {
+ resizeRef.current.observer.unobserve(resizeRef.current.element)
+ }
+
+ const observer = new ResizeObserver(([entry]) => {
+ callback(entry)
+ })
+
+ resizeRef.current = { element, observer }
+
+ observer.observe(element)
+ }
+ },
+ [callback]
+ )
+
+ useEffect(() => {
+ return () => {
+ if (resizeRef.current) {
+ resizeRef.current.observer.unobserve(resizeRef.current.element)
+ }
+ }
+ }, [])
+
+ return elementRef
+}
diff --git a/services/web/frontend/js/features/pdf-preview/hooks/use-toolbar-breakpoint.js b/services/web/frontend/js/features/pdf-preview/hooks/use-toolbar-breakpoint.js
new file mode 100644
index 0000000000..252d929d0e
--- /dev/null
+++ b/services/web/frontend/js/features/pdf-preview/hooks/use-toolbar-breakpoint.js
@@ -0,0 +1,70 @@
+import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
+import classnames from 'classnames'
+
+const measureContentWidth = element =>
+ [...element.querySelectorAll('button')].reduce(
+ (output, item) => output + item.scrollWidth,
+ 0
+ )
+
+export default function useToolbarBreakpoint(element) {
+ const [breakpoint, setBreakpoint] = useState(2)
+ const [recalculate, setRecalculate] = useState(true)
+
+ const [resizeObserver] = useState(
+ () =>
+ new ResizeObserver(() => {
+ setBreakpoint(2)
+ setRecalculate(true)
+ })
+ )
+
+ const [mutationObserver] = useState(
+ () =>
+ new MutationObserver(() => {
+ setBreakpoint(2)
+ setRecalculate(true)
+ })
+ )
+
+ useEffect(() => {
+ if (element && mutationObserver && resizeObserver) {
+ resizeObserver.observe(element)
+
+ mutationObserver.observe(element, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ })
+
+ return () => {
+ mutationObserver.disconnect()
+ resizeObserver.disconnect()
+ }
+ }
+ }, [element, mutationObserver, resizeObserver])
+
+ useLayoutEffect(() => {
+ if (recalculate && element && breakpoint) {
+ const contentWidth = measureContentWidth(element) + 150 // NOTE: remove this constant?
+
+ if (contentWidth > element.clientWidth) {
+ setBreakpoint(value => value - 1)
+ } else {
+ setRecalculate(false)
+ }
+ }
+ }, [element, breakpoint, recalculate])
+
+ return useMemo(
+ () =>
+ classnames({
+ toolbar: true,
+ 'toolbar-pdf': true,
+ 'toolbar-large': breakpoint === 2,
+ 'toolbar-medium': breakpoint === 1,
+ 'toolbar-small': breakpoint === 0,
+ }),
+ [breakpoint]
+ )
+}
diff --git a/services/web/frontend/js/features/pdf-preview/util/chktex-log-parser.js b/services/web/frontend/js/features/pdf-preview/util/chktex-log-parser.js
new file mode 100644
index 0000000000..969776b755
--- /dev/null
+++ b/services/web/frontend/js/features/pdf-preview/util/chktex-log-parser.js
@@ -0,0 +1,28 @@
+export const ChkTeXParser = {
+ parse(log) {
+ const errors = []
+ const warnings = []
+
+ for (const line of log.split('\n')) {
+ const m = line.match(/^(\S+):(\d+):(\d+): (Error|Warning): (.*)/)
+
+ if (m) {
+ const result = {
+ file: m[1],
+ line: m[2],
+ column: m[3],
+ level: m[4].toLowerCase(),
+ message: `${m[4]}: ${m[5]}`,
+ }
+
+ if (result.level === 'error') {
+ errors.push(result)
+ } else {
+ warnings.push(result)
+ }
+ }
+ }
+
+ return { errors, warnings }
+ },
+}
diff --git a/services/web/frontend/js/features/pdf-preview/util/editor-files.js b/services/web/frontend/js/features/pdf-preview/util/editor-files.js
new file mode 100644
index 0000000000..a2f40858e9
--- /dev/null
+++ b/services/web/frontend/js/features/pdf-preview/util/editor-files.js
@@ -0,0 +1,4 @@
+const documentClassRe = /^[^%]*\\documentclass/
+
+export const isMainFile = doc =>
+ doc.split('\n').some(line => documentClassRe.test(line))
diff --git a/services/web/frontend/js/features/pdf-preview/util/file-list.js b/services/web/frontend/js/features/pdf-preview/util/file-list.js
new file mode 100644
index 0000000000..a887617548
--- /dev/null
+++ b/services/web/frontend/js/features/pdf-preview/util/file-list.js
@@ -0,0 +1,50 @@
+const topFileTypes = ['bbl', 'gls', 'ind']
+const ignoreFiles = ['output.fls', 'output.fdb_latexmk']
+
+export const buildFileList = (outputFiles, clsiServerId) => {
+ const files = { top: [], other: [] }
+
+ if (outputFiles) {
+ const params = new URLSearchParams()
+
+ if (clsiServerId) {
+ params.set('clsiserverid', clsiServerId)
+ }
+
+ const allFiles = []
+
+ // filter out ignored files and set some properties
+ for (const file of outputFiles.values()) {
+ if (!ignoreFiles.includes(file.path)) {
+ file.main = file.path.startsWith('output.')
+ file.url += `?${params}`
+
+ allFiles.push(file)
+ }
+ }
+
+ // sort main files first, then alphabetical
+ allFiles.sort((a, b) => {
+ if (a.main && !b.main) {
+ return a
+ }
+
+ if (b.main && !a.main) {
+ return b
+ }
+
+ return a.path.localeCompare(b.path)
+ })
+
+ // group files into "top" and "other"
+ for (const file of allFiles) {
+ if (topFileTypes.includes(file.type)) {
+ files.top.push(file)
+ } else if (!(file.type === 'pdf' && file.main === true)) {
+ files.other.push(file)
+ }
+ }
+ }
+
+ return files
+}
diff --git a/services/web/frontend/js/features/pdf-preview/util/highlights.js b/services/web/frontend/js/features/pdf-preview/util/highlights.js
new file mode 100644
index 0000000000..c8e0322540
--- /dev/null
+++ b/services/web/frontend/js/features/pdf-preview/util/highlights.js
@@ -0,0 +1,42 @@
+import * as PDFJS from 'pdfjs-dist/legacy/build/pdf'
+
+export function buildHighlightElement(highlight, viewer) {
+ const pageView = viewer.getPageView(highlight.page - 1)
+
+ const viewport = pageView.viewport
+
+ const height = viewport.viewBox[3]
+
+ const rect = viewport.convertToViewportRectangle([
+ highlight.h, // xMin
+ height - (highlight.v + highlight.height) + 10, // yMin
+ highlight.h + highlight.width, // xMax
+ height - highlight.v + 10, // yMax
+ ])
+
+ const [left, top, right, bottom] = PDFJS.Util.normalizeRect(rect)
+
+ const element = document.createElement('div')
+ element.style.left = Math.floor(left) + 'px'
+ element.style.top = Math.floor(top) + 'px'
+ element.style.width = Math.ceil(right - left) + 'px'
+ element.style.height = Math.ceil(bottom - top) + 'px'
+ element.style.backgroundColor = 'rgba(255,255,0)'
+ element.style.position = 'absolute'
+ element.style.display = 'inline-block'
+ element.style.scrollMargin = '72px'
+ element.style.pointerEvents = 'none'
+ element.style.opacity = '0'
+ element.style.transition = 'opacity 0.5s'
+ pageView.div.appendChild(element)
+
+ window.setTimeout(() => {
+ element.style.opacity = '0.3'
+
+ window.setTimeout(() => {
+ element.style.opacity = '0'
+ }, 1000)
+ }, 0)
+
+ return element
+}
diff --git a/services/web/frontend/js/features/pdf-preview/util/output-files.js b/services/web/frontend/js/features/pdf-preview/util/output-files.js
new file mode 100644
index 0000000000..4ecc4d1c5e
--- /dev/null
+++ b/services/web/frontend/js/features/pdf-preview/util/output-files.js
@@ -0,0 +1,168 @@
+import getMeta from '../../../utils/meta'
+import HumanReadableLogs from '../../../ide/human-readable-logs/HumanReadableLogs'
+import BibLogParser from '../../../ide/log-parser/bib-log-parser'
+import { ChkTeXParser } from './chktex-log-parser'
+import { buildFileList } from './file-list'
+
+const searchParams = new URLSearchParams(window.location.search)
+
+export const handleOutputFiles = async (projectId, data) => {
+ const result = {}
+
+ const outputFiles = new Map()
+
+ for (const outputFile of data.outputFiles) {
+ outputFiles.set(outputFile.path, outputFile)
+ }
+
+ const outputFile = outputFiles.get('output.pdf')
+
+ if (outputFile) {
+ // build the URL for viewing the PDF in the preview UI
+ const params = new URLSearchParams({
+ compileGroup: data.compileGroup,
+ })
+
+ if (data.clsiServerId) {
+ params.set('clsiserverid', data.clsiServerId)
+ }
+
+ if (searchParams.get('verify_chunks') === 'true') {
+ // Instruct the serviceWorker to verify composed ranges.
+ params.set('verify_chunks', 'true')
+ }
+
+ if (getMeta('ol-enablePdfCaching')) {
+ // Tag traffic that uses the pdf caching logic.
+ params.set('enable_pdf_caching', 'true')
+ }
+
+ result.pdfUrl = `${data.pdfDownloadDomain}${outputFile.url}?${params}`
+
+ // build the URL for downloading the PDF
+ params.set('popupDownload', 'true') // save PDF download as file
+
+ result.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/output.pdf?${params}`
+ }
+
+ const params = new URLSearchParams({
+ compileGroup: data.compileGroup,
+ })
+
+ if (data.clsiServerId) {
+ params.set('clsiserverid', data.clsiServerId)
+ }
+
+ result.logEntries = {
+ all: [],
+ errors: [],
+ warnings: [],
+ typesetting: [],
+ }
+
+ function accumulateResults(newEntries, type) {
+ for (const key in result.logEntries) {
+ if (newEntries[key]) {
+ for (const entry of newEntries[key]) {
+ if (type) {
+ entry.type = newEntries.type
+ }
+ if (entry.file) {
+ entry.file = normalizeFilePath(entry.file)
+ }
+ entry.key = `${entry.file}:${entry.line}:${entry.column}:${entry.message}`
+ }
+ result.logEntries[key].push(...newEntries[key])
+ result.logEntries.all.push(...newEntries[key])
+ }
+ }
+ }
+
+ const logFile = outputFiles.get('output.log')
+
+ if (logFile) {
+ const response = await fetch(`${logFile.url}?${params}`)
+
+ const log = await response.text()
+
+ result.log = log
+
+ const { errors, warnings, typesetting } = HumanReadableLogs.parse(log, {
+ ignoreDuplicates: true,
+ })
+
+ accumulateResults({ errors, warnings, typesetting })
+ }
+
+ const blgFile = outputFiles.get('output.blg')
+
+ if (blgFile) {
+ const response = await fetch(`${blgFile.url}?${params}`)
+
+ const log = await response.text()
+
+ const { errors, warnings } = new BibLogParser(log, {}).parse()
+
+ accumulateResults({ errors, warnings }, 'BibTeX:')
+ }
+
+ const chktexFile = outputFiles.get('output.chktex')
+
+ if (chktexFile) {
+ const response = await fetch(`${chktexFile.url}?${params}`)
+
+ const log = await response.text()
+
+ const { errors, warnings } = ChkTeXParser.parse(log)
+
+ accumulateResults({ errors, warnings }, 'Syntax')
+ }
+
+ result.fileList = buildFileList(outputFiles, data.clsiServerId)
+
+ return result
+}
+
+export function buildLogEntryAnnotations(entries, fileTreeManager) {
+ const rootDocDirname = fileTreeManager.getRootDocDirname()
+
+ const logEntryAnnotations = {}
+
+ for (const entry of entries) {
+ if (entry.file) {
+ entry.file = normalizeFilePath(entry.file, rootDocDirname)
+
+ const entity = fileTreeManager.findEntityByPath(entry.file)
+
+ if (entity) {
+ if (!(entity.id in logEntryAnnotations)) {
+ logEntryAnnotations[entity.id] = []
+ }
+
+ logEntryAnnotations[entity.id].push({
+ row: entry.line - 1,
+ type: entry.level === 'error' ? 'error' : 'warning',
+ text: entry.message,
+ source: 'compile',
+ })
+ }
+ }
+ }
+
+ return logEntryAnnotations
+}
+
+function normalizeFilePath(path, rootDocDirname) {
+ path = path.replace(
+ /^.*\/compiles\/[0-9a-f]{24}(-[0-9a-f]{24})?\/(\.\/)?/,
+ ''
+ )
+
+ path = path.replace(/^\/compile\//, '')
+
+ if (rootDocDirname) {
+ path = path.replace(/^\.\//, rootDocDirname + '/')
+ }
+
+ return path
+}
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
new file mode 100644
index 0000000000..ef3b073ad8
--- /dev/null
+++ b/services/web/frontend/js/features/pdf-preview/util/pdf-js-wrapper.js
@@ -0,0 +1,126 @@
+// NOTE: using "legacy" build as main build requires webpack v5
+// import PDFJS from 'pdfjs-dist/webpack'
+import * as PDFJS from 'pdfjs-dist/legacy/build/pdf'
+import * as PDFJSViewer from 'pdfjs-dist/legacy/web/pdf_viewer'
+import PDFJSWorker from 'pdfjs-dist/legacy/build/pdf.worker'
+import 'pdfjs-dist/legacy/web/pdf_viewer.css'
+import getMeta from '../../../utils/meta'
+
+if (typeof window !== 'undefined' && 'Worker' in window) {
+ PDFJS.GlobalWorkerOptions.workerPort = new PDFJSWorker()
+}
+
+const params = new URLSearchParams(window.location.search)
+const disableFontFace = params.get('disable-font-face') === 'true'
+const cMapUrl = getMeta('ol-pdfCMapsPath')
+const imageResourcesPath = getMeta('ol-pdfImageResourcesPath')
+
+const rangeChunkSize = 128 * 1024 // 128K chunks
+
+export default class PDFJSWrapper {
+ constructor(container) {
+ this.container = container
+
+ // create the event bus
+ const eventBus = new PDFJSViewer.EventBus()
+
+ // create the link service
+ const linkService = new PDFJSViewer.PDFLinkService({
+ eventBus,
+ externalLinkTarget: 2,
+ externalLinkRel: 'noopener',
+ })
+
+ // create the localization
+ const l10n = new PDFJSViewer.GenericL10n('en-GB') // TODO: locale mapping?
+
+ // create the viewer
+ const viewer = new PDFJSViewer.PDFViewer({
+ container,
+ eventBus,
+ imageResourcesPath,
+ linkService,
+ l10n,
+ enableScripting: false, // default is false, but set explicitly to be sure
+ renderInteractiveForms: false,
+ })
+
+ linkService.setViewer(viewer)
+
+ this.eventBus = eventBus
+ this.linkService = linkService
+ this.viewer = viewer
+ }
+
+ // load a document from a URL
+ async loadDocument(url) {
+ const doc = await PDFJS.getDocument({
+ url,
+ cMapUrl,
+ cMapPacked: true,
+ disableFontFace,
+ rangeChunkSize,
+ disableAutoFetch: true,
+ disableStream: true,
+ textLayerMode: 2, // PDFJSViewer.TextLayerMode.ENABLE,
+ }).promise
+
+ this.viewer.setDocument(doc)
+ this.linkService.setDocument(doc)
+
+ return doc
+ }
+
+ // update the current scale value if the container size changes
+ updateOnResize() {
+ const currentScaleValue = this.viewer.currentScaleValue
+
+ if (
+ currentScaleValue === 'auto' ||
+ currentScaleValue === 'page-fit' ||
+ currentScaleValue === 'page-width'
+ ) {
+ this.viewer.currentScaleValue = currentScaleValue
+ }
+
+ this.viewer.update()
+ }
+
+ // get the page and offset of a click event
+ clickPosition(event, pageElement, textLayer) {
+ const { viewport } = this.viewer.getPageView(textLayer.pageNumber - 1)
+
+ const pageRect = pageElement.querySelector('canvas').getBoundingClientRect()
+
+ const dx = event.clientX - pageRect.left
+ const dy = event.clientY - pageRect.top
+
+ const [left, top] = viewport.convertToPdfPoint(dx, dy)
+
+ return {
+ page: textLayer.pageNumber - 1,
+ offset: {
+ left,
+ top: viewport.viewBox[3] - top,
+ },
+ }
+ }
+
+ // get the current page, offset and page size
+ get currentPosition() {
+ const pageIndex = this.viewer.currentPageNumber - 1
+ const pageView = this.viewer.getPageView(pageIndex)
+ const pageRect = pageView.div.getBoundingClientRect()
+
+ const containerRect = this.container.getBoundingClientRect()
+ const dy = containerRect.top - pageRect.top
+ const [, , width, height] = pageView.viewport.viewBox
+ const [, top] = pageView.viewport.convertToPdfPoint(0, dy)
+
+ return {
+ page: pageIndex,
+ offset: { top, left: 0 },
+ pageSize: { height, width },
+ }
+ }
+}
diff --git a/services/web/frontend/js/ide.js b/services/web/frontend/js/ide.js
index bae0898d3d..71e5626fc9 100644
--- a/services/web/frontend/js/ide.js
+++ b/services/web/frontend/js/ide.js
@@ -314,6 +314,19 @@ If the project has been renamed please look in your project list for a new proje
$scope.switchToSideBySideLayout()
}
+ // note: { keyShortcut: true } exists only for tracking purposes.
+ $scope.recompileViaKey = () => {
+ if ($scope.recompile) {
+ $scope.recompile({ keyShortcut: true })
+ } else {
+ window.dispatchEvent(
+ new CustomEvent('pdf:recompile', {
+ detail: { keyShortcut: true },
+ })
+ )
+ }
+ }
+
$scope.handleKeyDown = event => {
if (event.shiftKey || event.altKey) {
return
diff --git a/services/web/frontend/js/ide/connection/ConnectionManager.js b/services/web/frontend/js/ide/connection/ConnectionManager.js
index 2ccfdb4dc5..db21c0a4dd 100644
--- a/services/web/frontend/js/ide/connection/ConnectionManager.js
+++ b/services/web/frontend/js/ide/connection/ConnectionManager.js
@@ -450,6 +450,9 @@ Something went wrong connecting to your project. Please refresh if this continue
this.$scope.project = { ...defaultProjectAttributes, ...project }
this.$scope.permissionsLevel = permissionsLevel
this.ide.loadingManager.socketLoaded()
+ window.dispatchEvent(
+ new CustomEvent('project:joined', { detail: this.$scope.project })
+ )
this.$scope.$broadcast('project:joined')
})
}
diff --git a/services/web/frontend/js/ide/editor/Document.js b/services/web/frontend/js/ide/editor/Document.js
index d9b454398c..9bfbfac57b 100644
--- a/services/web/frontend/js/ide/editor/Document.js
+++ b/services/web/frontend/js/ide/editor/Document.js
@@ -653,12 +653,18 @@ export default Document = (function () {
})
this.doc.on('change', (ops, oldSnapshot, msg) => {
this._applyOpsToRanges(ops, oldSnapshot, msg)
+ window.dispatchEvent(
+ new CustomEvent('doc:changed', { detail: { id: this.doc_id } })
+ )
return this.ide.$scope.$emit('doc:changed', { doc_id: this.doc_id })
})
this.doc.on('flipped_pending_to_inflight', () => {
return this.trigger('flipped_pending_to_inflight')
})
return this.doc.on('saved', () => {
+ window.dispatchEvent(
+ new CustomEvent('doc:saved', { detail: { id: this.doc_id } })
+ )
return this.ide.$scope.$emit('doc:saved', { doc_id: this.doc_id })
})
}
diff --git a/services/web/frontend/js/ide/editor/EditorManager.js b/services/web/frontend/js/ide/editor/EditorManager.js
index 10688cbea3..9ca4da9631 100644
--- a/services/web/frontend/js/ide/editor/EditorManager.js
+++ b/services/web/frontend/js/ide/editor/EditorManager.js
@@ -96,6 +96,12 @@ export default EditorManager = (function () {
this.$scope.$on('flush-changes', () => {
return Document.flushAll()
})
+
+ // event dispatched by pdf preview
+ window.addEventListener('flush-changes', () => {
+ Document.flushAll()
+ })
+
window.addEventListener('blur', () => {
// The browser may put the tab into sleep as it looses focus.
// Flushing the documents should help with keeping the documents in
@@ -112,6 +118,11 @@ export default EditorManager = (function () {
}
return this._syncTrackChangesState(this.$scope.editor.sharejs_doc)
})
+
+ window.addEventListener('editor:open-doc', event => {
+ const { doc, ...options } = event.detail
+ this.openDoc(doc, options)
+ })
}
showRichText() {
diff --git a/services/web/frontend/js/ide/pdf/controllers/PdfController.js b/services/web/frontend/js/ide/pdf/controllers/PdfController.js
index b4519751bc..f9b4ce017d 100644
--- a/services/web/frontend/js/ide/pdf/controllers/PdfController.js
+++ b/services/web/frontend/js/ide/pdf/controllers/PdfController.js
@@ -834,10 +834,6 @@ App.controller(
// This needs to be public.
ide.$scope.recompile = $scope.recompile
- // This method is a simply wrapper and exists only for tracking purposes.
- ide.$scope.recompileViaKey = function () {
- $scope.recompile({ keyShortcut: true })
- }
$scope.stop = function () {
if (!$scope.pdf.compiling) {
diff --git a/services/web/frontend/js/ide/pdf/controllers/PdfSynctexController.js b/services/web/frontend/js/ide/pdf/controllers/PdfSynctexController.js
index d574b9bc2f..5c41498c48 100644
--- a/services/web/frontend/js/ide/pdf/controllers/PdfSynctexController.js
+++ b/services/web/frontend/js/ide/pdf/controllers/PdfSynctexController.js
@@ -1,4 +1,5 @@
import App from '../../../base'
+import { sendMBOnce } from '../../../infrastructure/event-tracking'
App.controller('PdfSynctexController', function ($scope, synctex, ide) {
this.cursorPosition = null
@@ -27,17 +28,41 @@ App.controller('PdfSynctexController', function ($scope, synctex, ide) {
ide.$scope.$on('cursor:editor:syncToPdf', $scope.syncToPdf)
- $scope.syncToCode = function () {
- synctex
- .syncToCode($scope.pdf.position, {
- includeVisualOffset: true,
- fromPdfPosition: true,
- })
- .then(function (data) {
- const { doc, line } = data
- ide.editorManager.openDoc(doc, { gotoLine: line })
+ function syncToPosition(position, options) {
+ synctex.syncToCode(position, options).then(data => {
+ ide.editorManager.openDoc(data.doc, {
+ gotoLine: data.line,
})
+ })
}
+
+ $scope.syncToCode = function () {
+ syncToPosition($scope.pdf.position, {
+ includeVisualOffset: true,
+ fromPdfPosition: true,
+ })
+ }
+
+ window.addEventListener('synctex:sync-to-position', event => {
+ syncToPosition(event.detail, {
+ fromPdfPosition: true,
+ })
+ })
+
+ window.addEventListener('synctex:sync-to-entry', event => {
+ sendMBOnce('logs-jump-to-location-once')
+
+ const entry = event.detail
+
+ const entity = ide.fileTreeManager.findEntityByPath(entry.file)
+
+ if (entity && entity.type === 'doc') {
+ ide.editorManager.openDoc(entity, {
+ gotoLine: entry.line ?? undefined,
+ gotoColumn: entry.column ?? undefined,
+ })
+ }
+ })
})
App.factory('synctex', function (ide, $http, $q) {
diff --git a/services/web/frontend/stories/fixtures/context.js b/services/web/frontend/stories/fixtures/context.js
index 162599a2c0..3277b1c4ba 100644
--- a/services/web/frontend/stories/fixtures/context.js
+++ b/services/web/frontend/stories/fixtures/context.js
@@ -13,6 +13,7 @@ export function setupContext() {
user: window.user,
project: {},
$watch: () => {},
+ $applyAsync: () => {},
ui: {
chatOpen: true,
pdfLayout: 'flat',
@@ -27,6 +28,10 @@ export function setupContext() {
on: sinon.stub(),
removeListener: sinon.stub(),
},
+ fileTreeManager: {
+ findEntityByPath: () => null,
+ getRootDocDirname: () => undefined,
+ },
}
window.ExposedSettings = window.ExposedSettings || {}
window.ExposedSettings.appName = 'Overleaf'
diff --git a/services/web/frontend/stories/fixtures/storybook-example.pdf b/services/web/frontend/stories/fixtures/storybook-example.pdf
new file mode 100644
index 0000000000..4c2cc8512d
Binary files /dev/null and b/services/web/frontend/stories/fixtures/storybook-example.pdf differ
diff --git a/services/web/frontend/stories/pdf-js-viewer.stories.js b/services/web/frontend/stories/pdf-js-viewer.stories.js
new file mode 100644
index 0000000000..1e89fc6ea3
--- /dev/null
+++ b/services/web/frontend/stories/pdf-js-viewer.stories.js
@@ -0,0 +1,114 @@
+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/context/util/scope-value-hook'
+
+setupContext()
+
+export default {
+ title: 'PDF Viewer',
+ component: PdfJsViewer,
+}
+
+const project = {
+ _id: 'story-project',
+}
+
+const mockHighlights = [
+ {
+ page: 1,
+ h: 85.03936,
+ v: 509.999878,
+ width: 441.921265,
+ height: 8.855677,
+ },
+ {
+ page: 1,
+ h: 85.03936,
+ v: 486.089539,
+ width: 441.921265,
+ height: 8.855677,
+ },
+ {
+ page: 1,
+ h: 85.03936,
+ v: 498.044708,
+ width: 441.921265,
+ height: 8.855677,
+ },
+ {
+ page: 1,
+ h: 85.03936,
+ v: 521.955078,
+ width: 441.921265,
+ height: 8.855677,
+ },
+]
+
+export const Interactive = () => {
+ useFetchMock(fetchMock => {
+ fetchMock.get(
+ 'express:/build/output.pdf',
+ (url, options, request) => {
+ return new Promise(resolve => {
+ const xhr = new XMLHttpRequest()
+ xhr.addEventListener('load', () => {
+ resolve({
+ status: 200,
+ headers: {
+ 'Content-Length': xhr.getResponseHeader('Content-Length'),
+ 'Content-Type': xhr.getResponseHeader('Content-Type'),
+ 'Accept-Ranges': 'bytes',
+ },
+ body: xhr.response,
+ })
+ })
+ xhr.open('GET', examplePdf)
+ xhr.responseType = 'arraybuffer'
+ xhr.send()
+ })
+ },
+ { sendAsJson: false }
+ )
+ })
+
+ const Inner = () => {
+ const [, setHighlights] = useScopeValue('pdf.highlights')
+
+ const dispatchSyncFromCode = useCallback(() => {
+ setHighlights([])
+ window.setTimeout(() => {
+ setHighlights(mockHighlights)
+ }, 0)
+ }, [setHighlights])
+
+ return (
+
+
+
+
+
+ )
+ }
+
+ return withContextRoot(
+ ,
+ { scope: { project } }
+ )
+}
diff --git a/services/web/frontend/stories/pdf-preview.stories.js b/services/web/frontend/stories/pdf-preview.stories.js
new file mode 100644
index 0000000000..3824acfd4c
--- /dev/null
+++ b/services/web/frontend/stories/pdf-preview.stories.js
@@ -0,0 +1,562 @@
+import { withContextRoot } from './utils/with-context-root'
+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'
+import PdfFileList from '../js/features/pdf-preview/components/pdf-file-list'
+import { buildFileList } from '../js/features/pdf-preview/util/file-list'
+import PdfLogsViewer from '../js/features/pdf-preview/components/pdf-logs-viewer'
+import examplePdf from './fixtures/storybook-example.pdf'
+import PdfPreviewError from '../js/features/pdf-preview/components/pdf-preview-error'
+
+setupContext()
+
+export default {
+ title: 'PDF Preview',
+ component: PdfPreview,
+ subcomponents: {
+ PdfPreviewToolbar,
+ PdfFileList,
+ PdfPreviewError,
+ },
+}
+
+const project = {
+ _id: 'a-project',
+ name: 'A Project',
+ features: {},
+ tokens: {},
+ owner: {
+ _id: 'a-user',
+ email: 'stories@overleaf.com',
+ },
+ members: [],
+ invites: [],
+}
+
+const scope = {
+ project,
+ settings: {
+ syntaxValidation: true,
+ },
+ hasLintingError: false,
+ $applyAsync: () => {},
+}
+
+const dispatchProjectJoined = () => {
+ window.dispatchEvent(new CustomEvent('project:joined', { detail: project }))
+}
+
+const dispatchDocChanged = () => {
+ window.dispatchEvent(
+ new CustomEvent('doc:changed', { detail: { doc_id: 'foo' } })
+ )
+}
+
+const outputFiles = [
+ {
+ path: 'output.pdf',
+ build: '123',
+ url: '/build/output.pdf',
+ type: 'pdf',
+ },
+ {
+ path: 'output.bbl',
+ build: '123',
+ url: '/build/output.bbl',
+ type: 'bbl',
+ },
+ {
+ path: 'output.bib',
+ build: '123',
+ url: '/build/output.bib',
+ type: 'bib',
+ },
+ {
+ path: 'example.txt',
+ build: '123',
+ url: '/build/example.txt',
+ type: 'txt',
+ },
+ {
+ path: 'output.log',
+ build: '123',
+ url: '/build/output.log',
+ type: 'log',
+ },
+ {
+ path: 'output.blg',
+ build: '123',
+ url: '/build/output.blg',
+ type: 'blg',
+ },
+]
+
+const mockCompile = (fetchMock, delay = 1000) =>
+ fetchMock.post(
+ 'express:/project/:projectId/compile',
+ {
+ body: {
+ status: 'success',
+ clsiServerId: 'foo',
+ compileGroup: 'priority',
+ pdfDownloadDomain: '',
+ outputFiles,
+ },
+ },
+ { delay }
+ )
+
+const mockCompileError = (fetchMock, status = 'success', delay = 1000) =>
+ fetchMock.post(
+ 'express:/project/:projectId/compile',
+ {
+ body: {
+ status,
+ clsiServerId: 'foo',
+ compileGroup: 'priority',
+ },
+ },
+ { delay, overwriteRoutes: true }
+ )
+
+const mockCompileValidationIssues = (
+ fetchMock,
+ validationProblems,
+ delay = 1000
+) =>
+ fetchMock.post(
+ 'express:/project/:projectId/compile',
+ () => {
+ return {
+ body: {
+ status: 'validation-problems',
+ validationProblems,
+ clsiServerId: 'foo',
+ compileGroup: 'priority',
+ },
+ }
+ },
+ { delay, overwriteRoutes: true }
+ )
+
+const mockClearCache = fetchMock =>
+ fetchMock.delete('express:/project/:projectId/output', 204, {
+ delay: 1000,
+ })
+
+const mockBuildFile = fetchMock =>
+ fetchMock.get(
+ 'express:/build/:file',
+ (url, options, request) => {
+ const { pathname } = new URL(url, 'https://example.com')
+
+ switch (pathname) {
+ case '/build/output.blg':
+ return 'This is BibTeX, Version 4.0' // FIXME
+
+ case '/build/output.log':
+ return `
+The LaTeX compiler output
+ * With a lot of details
+
+Wrapped in an HTML element with
+ preformatted text which is to be presented exactly
+ as written in the HTML file
+
+ (whitespace included™)
+
+The text is typically rendered using a non-proportional ("monospace") font.
+
+LaTeX Font Info: External font \`cmex10' loaded for size
+(Font) <7> on input line 18.
+LaTeX Font Info: External font \`cmex10' loaded for size
+(Font) <5> on input line 18.
+! Undefined control sequence.
+ \\Zlpha
+
+ main.tex, line 23
+
+`
+
+ case '/build/output.pdf':
+ return new Promise(resolve => {
+ const xhr = new XMLHttpRequest()
+ xhr.addEventListener('load', () => {
+ resolve({
+ status: 200,
+ headers: {
+ 'Content-Length': xhr.getResponseHeader('Content-Length'),
+ 'Content-Type': xhr.getResponseHeader('Content-Type'),
+ },
+ body: xhr.response,
+ })
+ })
+ xhr.open('GET', examplePdf)
+ xhr.responseType = 'arraybuffer'
+ xhr.send()
+ })
+
+ default:
+ return 404
+ }
+ },
+ { sendAsJson: false }
+ )
+
+export const Interactive = () => {
+ useFetchMock(fetchMock => {
+ mockCompile(fetchMock)
+ mockBuildFile(fetchMock)
+ mockClearCache(fetchMock)
+ })
+
+ useEffect(() => {
+ dispatchProjectJoined()
+ }, [])
+
+ const Inner = () => {
+ const context = usePdfPreviewContext()
+
+ const { setHasLintingError } = context
+
+ const toggleLintingError = useCallback(() => {
+ setHasLintingError(value => !value)
+ }, [setHasLintingError])
+
+ const values = useMemo(() => {
+ const entries = Object.entries(context).sort((a, b) => {
+ return a[0].localeCompare(b[0])
+ })
+
+ const values = { boolean: [], other: [] }
+
+ for (const entry of entries) {
+ const type = typeof entry[1]
+
+ if (type === 'boolean') {
+ values.boolean.push(entry)
+ } else if (type !== 'function') {
+ values.other.push(entry)
+ }
+ }
+
+ return values
+ }, [context])
+
+ return (
+
+
+
+
+
+ {values.boolean.map(([key, value]) => {
+ return (
+
+ | {value ? '🟢' : '🔴'} |
+ {key} |
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {values.other.map(([key, value]) => {
+ return (
+
+ |
+ {key}
+ |
+
+
+ {JSON.stringify(value, null, 2)}
+
+ |
+
+ )
+ })}
+
+
+
+
+
+ )
+ }
+
+ return withContextRoot(
+ ,
+ scope
+ )
+}
+
+const compileStatuses = [
+ 'autocompile-backoff',
+ 'clear-cache',
+ 'clsi-maintenance',
+ 'compile-in-progress',
+ 'exited',
+ 'failure',
+ 'generic',
+ 'project-too-large',
+ 'rate-limited',
+ 'success',
+ 'terminated',
+ 'timedout',
+ 'too-recently-compiled',
+ 'unavailable',
+ 'validation-problems',
+ 'foo',
+]
+
+export const CompileError = () => {
+ const [status, setStatus] = useState('success')
+
+ useFetchMock(fetchMock => {
+ mockCompileError(fetchMock, status, 0)
+ mockBuildFile(fetchMock)
+ })
+
+ const Inner = () => {
+ const { recompile } = usePdfPreviewContext()
+
+ const handleStatusChange = useCallback(
+ event => {
+ setStatus(event.target.value)
+ window.setTimeout(() => {
+ recompile()
+ }, 0)
+ },
+ [recompile]
+ )
+
+ return (
+
+
+
+ )
+ }
+
+ return withContextRoot(
+
+
+
+ ,
+ scope
+ )
+}
+
+const compileErrors = [
+ 'autocompile-backoff',
+ 'clear-cache',
+ 'clsi-maintenance',
+ 'compile-in-progress',
+ 'exited',
+ 'failure',
+ 'generic',
+ 'project-too-large',
+ 'rate-limited',
+ 'success',
+ 'terminated',
+ 'timedout',
+ 'too-recently-compiled',
+ 'unavailable',
+ 'validation-problems',
+ 'foo',
+]
+
+export const DisplayError = () => {
+ return withContextRoot(
+
+ {compileErrors.map(error => (
+
+ ))}
+ ,
+ scope
+ )
+}
+
+export const Toolbar = () => {
+ useFetchMock(fetchMock => mockCompile(fetchMock, 500))
+
+ return withContextRoot(
+
+
+ ,
+ scope
+ )
+}
+
+export const FileList = () => {
+ const fileList = useMemo(() => {
+ return buildFileList(outputFiles)
+ }, [])
+
+ return (
+
+ )
+}
+
+export const Logs = () => {
+ useFetchMock(fetchMock => {
+ mockCompile(fetchMock, 0)
+ mockBuildFile(fetchMock)
+ mockClearCache(fetchMock)
+ })
+
+ useEffect(() => {
+ dispatchProjectJoined()
+ }, [])
+
+ return withContextRoot(
+
+
+ ,
+ scope
+ )
+}
+
+const validationProblems = {
+ sizeCheck: {
+ resources: [
+ { path: 'foo/bar', kbSize: 76221 },
+ { path: 'bar/baz', kbSize: 2342 },
+ ],
+ },
+ mainFile: true,
+ conflictedPaths: [
+ {
+ path: 'foo/bar',
+ },
+ {
+ path: 'foo/baz',
+ },
+ ],
+}
+
+export const ValidationIssues = () => {
+ useFetchMock(fetchMock => {
+ mockCompileValidationIssues(fetchMock, validationProblems, 0)
+ mockBuildFile(fetchMock)
+ })
+
+ useEffect(() => {
+ dispatchProjectJoined()
+ }, [])
+
+ return withContextRoot(
+
+
+ ,
+ scope
+ )
+}
diff --git a/services/web/frontend/stories/utils/with-context-root.js b/services/web/frontend/stories/utils/with-context-root.js
index 31f8db93b3..8e7b0fb420 100644
--- a/services/web/frontend/stories/utils/with-context-root.js
+++ b/services/web/frontend/stories/utils/with-context-root.js
@@ -1,15 +1,29 @@
import { ContextRoot } from '../../js/shared/context/root-context'
+import _ from 'lodash'
// Unfortunately, we cannot currently use decorators here, since we need to
// set a value on window, before the contexts are rendered.
// When using decorators, the contexts are rendered before the story, so we
// don't have the opportunity to set the window value first.
export function withContextRoot(Story, scope) {
+ const scopeWatchers = []
+
const ide = {
...window._ide,
$scope: {
...window._ide.$scope,
...scope,
+ $watch: (key, callback) => {
+ scopeWatchers.push([key, callback])
+ },
+ $applyAsync: callback => {
+ window.setTimeout(() => {
+ callback()
+ for (const [key, watcher] of scopeWatchers) {
+ watcher(_.get(ide.$scope, key))
+ }
+ }, 0)
+ },
},
}
diff --git a/services/web/frontend/stylesheets/app/editor/pdf.less b/services/web/frontend/stylesheets/app/editor/pdf.less
index f0840a63b2..f6080fce63 100644
--- a/services/web/frontend/stylesheets/app/editor/pdf.less
+++ b/services/web/frontend/stylesheets/app/editor/pdf.less
@@ -146,6 +146,17 @@
box-sizing: content-box;
user-select: none;
}
+ .page {
+ box-sizing: content-box;
+ margin-left: auto;
+ margin-right: auto;
+ }
+ .pdfjs-viewer-inner {
+ position: absolute;
+ overflow: auto;
+ width: 100%;
+ height: 100%;
+ }
&:focus-within {
outline: none;
}
@@ -189,6 +200,12 @@
border-bottom: 2px solid white;
}
}
+ .pdfjs-error {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ right: 10px;
+ }
}
.pdf-logs {
diff --git a/services/web/frontend/stylesheets/components/button-groups.less b/services/web/frontend/stylesheets/components/button-groups.less
index 1397404033..a700d2bfdb 100755
--- a/services/web/frontend/stylesheets/components/button-groups.less
+++ b/services/web/frontend/stylesheets/components/button-groups.less
@@ -225,3 +225,22 @@
[data-toggle='buttons'] > .btn > input[type='checkbox'] {
display: none;
}
+
+// button group as toolbar item
+.btn-group.toolbar-item {
+ display: flex;
+ flex-wrap: nowrap;
+}
+
+// allow hiding toolbar content at various breakpoints
+.toolbar-large .toolbar-hide-large {
+ display: none !important;
+}
+
+.toolbar-medium .toolbar-hide-medium {
+ display: none !important;
+}
+
+.toolbar-small .toolbar-hide-small {
+ display: none !important;
+}
diff --git a/services/web/package-lock.json b/services/web/package-lock.json
index dbe3317c56..3315c7a597 100644
--- a/services/web/package-lock.json
+++ b/services/web/package-lock.json
@@ -11609,7 +11609,8 @@
"ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
- "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="
+ "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+ "dev": true
},
"algoliasearch": {
"version": "3.35.1",
@@ -13805,7 +13806,8 @@
"big.js": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz",
- "integrity": "sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q=="
+ "integrity": "sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==",
+ "dev": true
},
"bignumber.js": {
"version": "9.0.1",
@@ -17970,7 +17972,8 @@
"emojis-list": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz",
- "integrity": "sha512-knHEZMgs8BB+MInokmNTg/OyPlAddghe1YBgNwJBc5zsJi/uyIcXoSDsL/W9ymOsBoBGdPIHXYJ9+qKFwRwDng=="
+ "integrity": "sha512-knHEZMgs8BB+MInokmNTg/OyPlAddghe1YBgNwJBc5zsJi/uyIcXoSDsL/W9ymOsBoBGdPIHXYJ9+qKFwRwDng==",
+ "dev": true
},
"emotion-theming": {
"version": "10.0.27",
@@ -25161,6 +25164,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.1.0.tgz",
"integrity": "sha512-gkD9aSEG9UGglyPcDJqY9YBTUtCLKaBK6ihD2VP1d1X60lTfFspNZNulGBBbUZLkPygy4LySYHyxBpq+VhjObQ==",
+ "dev": true,
"requires": {
"big.js": "^3.1.3",
"emojis-list": "^2.0.0",
@@ -25170,7 +25174,8 @@
"json5": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz",
- "integrity": "sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw=="
+ "integrity": "sha512-4xrs1aW+6N5DalkqSVA8fxh458CXvR99WU8WLKmq4v8eWAL86Xo3BVqyd3SkA9wEVjCMqyvvRRkshAdOnBp5rw==",
+ "dev": true
}
}
},
@@ -27021,11 +27026,6 @@
"minimatch": "^3.0.2"
}
},
- "node-ensure": {
- "version": "0.0.0",
- "resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
- "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw=="
- },
"node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
@@ -28974,13 +28974,9 @@
}
},
"pdfjs-dist": {
- "version": "2.2.228",
- "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.2.228.tgz",
- "integrity": "sha512-W5LhYPMS2UKX0ELIa4u+CFCMoox5qQNQElt0bAK2mwz1V8jZL0rvLao+0tBujce84PK6PvWG36Nwr7agCCWFGQ==",
- "requires": {
- "node-ensure": "^0.0.0",
- "worker-loader": "^2.0.0"
- }
+ "version": "2.9.359",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-2.9.359.tgz",
+ "integrity": "sha512-P2nYtkacdlZaNNwrBLw1ZyMm0oE2yY/5S/GDCAmMJ7U4+ciL/D0mrlEC/o4HZZc/LNE3w8lEVzBEyVgEQlPVKQ=="
},
"pend": {
"version": "1.2.0",
@@ -33090,6 +33086,7 @@
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz",
"integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==",
+ "dev": true,
"requires": {
"ajv": "^6.1.0",
"ajv-keywords": "^3.1.0"
@@ -38635,6 +38632,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/worker-loader/-/worker-loader-2.0.0.tgz",
"integrity": "sha512-tnvNp4K3KQOpfRnD20m8xltE3eWh89Ye+5oj7wXEEHKac1P4oZ6p9oTj8/8ExqoSBnk9nu5Pr4nKfQ1hn2APJw==",
+ "dev": true,
"requires": {
"loader-utils": "^1.0.0",
"schema-utils": "^0.4.0"
diff --git a/services/web/package.json b/services/web/package.json
index afc493e38a..e91dd21f6b 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -143,7 +143,7 @@
"passport-orcid": "0.0.4",
"passport-saml": "https://github.com/overleaf/passport-saml/releases/download/v3.0.0-overleaf/passport-saml-3.0.0-overleaf.tar.gz",
"passport-twitter": "^1.0.4",
- "pdfjs-dist": "^2.2.228",
+ "pdfjs-dist": "^2.9.359",
"prop-types": "^15.7.2",
"pug": "^3.0.1",
"pug-runtime": "^3.0.1",
@@ -261,6 +261,7 @@
"webpack-assets-manifest": "^4.0.6",
"webpack-cli": "^3.3.11",
"webpack-dev-server": "^3.11.0",
- "webpack-merge": "^4.2.2"
+ "webpack-merge": "^4.2.2",
+ "worker-loader": "^2.0.0"
}
}
diff --git a/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js b/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js
new file mode 100644
index 0000000000..1142db5043
--- /dev/null
+++ b/services/web/test/frontend/features/pdf-preview/components/pdf-preview.test.js
@@ -0,0 +1,382 @@
+import { expect } from 'chai'
+import sinon from 'sinon'
+import fetchMock from 'fetch-mock'
+import { screen, fireEvent, waitFor } from '@testing-library/react'
+import PdfPreview from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview'
+import { renderWithEditorContext } from '../../../helpers/render-with-context'
+
+const outputFiles = [
+ {
+ path: 'output.pdf',
+ build: '123',
+ // url: 'about:blank', // TODO: PDF URL to render
+ 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.post('express:/project/:projectId/compile', {
+ body: {
+ status: 'success',
+ clsiServerId: 'foo',
+ compileGroup: 'priority',
+ pdfDownloadDomain: '',
+ outputFiles,
+ },
+ })
+
+const mockCompileError = status =>
+ fetchMock.post('express:/project/:projectId/compile', {
+ body: {
+ status,
+ clsiServerId: 'foo',
+ compileGroup: 'priority',
+ },
+ })
+
+const mockValidationProblems = validationProblems =>
+ fetchMock.post('express:/project/:projectId/compile', {
+ body: {
+ status: 'validation-problems',
+ validationProblems,
+ clsiServerId: 'foo',
+ compileGroup: 'priority',
+ },
+ })
+
+const mockClearCache = () =>
+ fetchMock.delete('express:/project/:projectId/output', 204)
+
+const defaultFileResponses = {
+ '/build/output.blg': 'This is BibTeX, Version 4.0', // FIXME
+ '/build/output.log': `
+The LaTeX compiler output
+ * With a lot of details
+
+Wrapped in an HTML element with
+ preformatted text which is to be presented exactly
+ as written in the HTML file
+
+ (whitespace included™)
+
+The text is typically rendered using a non-proportional ("monospace") font.
+
+LaTeX Font Info: External font \`cmex10' loaded for size
+(Font) <7> on input line 18.
+LaTeX Font Info: External font \`cmex10' loaded for size
+(Font) <5> on input line 18.
+! Undefined control sequence.
+ \\Zlpha
+
+ main.tex, line 23
+
+`,
+}
+
+const mockBuildFile = (responses = defaultFileResponses) =>
+ fetchMock.get('express:/build/:file', (_url, options, request) => {
+ const url = new URL(_url, 'https://example.com')
+
+ if (url.pathname in responses) {
+ return responses[url.pathname]
+ }
+
+ return 404
+ })
+
+const storeAndFireEvent = (key, value) => {
+ localStorage.setItem(key, value)
+ fireEvent(window, new StorageEvent('storage', { key }))
+}
+
+describe('', function () {
+ var clock
+
+ beforeEach(function () {
+ clock = sinon.useFakeTimers({
+ shouldAdvanceTime: true,
+ now: Date.now(),
+ })
+ // xhrMock.setup()
+ })
+
+ afterEach(function () {
+ clock.runAll()
+ clock.restore()
+ // xhrMock.teardown()
+ fetchMock.reset()
+ localStorage.clear()
+ })
+
+ it('renders the PDF preview', function () {
+ renderWithEditorContext()
+
+ screen.getByRole('button', { name: 'Recompile' })
+ })
+
+ it('runs a compile only on the first project:joined event', async function () {
+ mockCompile()
+ mockBuildFile()
+
+ renderWithEditorContext()
+
+ // fire a project:joined event => compile
+ screen.getByRole('button', { name: 'Recompile' })
+ fireEvent(window, new CustomEvent('project:joined'))
+ screen.getByRole('button', { name: 'Compiling…' })
+ await screen.findByRole('button', { name: 'Recompile' })
+
+ // fire another project:joined event => no compile
+ fireEvent(window, new CustomEvent('project:joined'))
+ screen.getByRole('button', { name: 'Recompile' })
+
+ expect(fetchMock.calls()).to.have.length(3)
+
+ expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
+ expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path
+ })
+
+ it('runs a compile when the Recompile button is pressed', async function () {
+ mockCompile()
+ mockBuildFile()
+
+ renderWithEditorContext()
+
+ // fire a project:joined event => compile
+ screen.getByRole('button', { name: 'Recompile' })
+ fireEvent(window, new CustomEvent('project:joined'))
+ screen.getByRole('button', { name: 'Compiling…' })
+ await screen.findByRole('button', { name: 'Recompile' })
+
+ // press the Recompile button => compile
+ const button = screen.getByRole('button', { name: 'Recompile' })
+ button.click()
+ screen.getByRole('button', { name: 'Compiling…' })
+ await screen.findByRole('button', { name: 'Recompile' })
+
+ expect(fetchMock.calls()).to.have.length(6)
+ })
+
+ it('runs a compile on doc change if autocompile is enabled', async function () {
+ mockCompile()
+ mockBuildFile()
+
+ renderWithEditorContext()
+
+ // fire a project:joined event => compile
+ screen.getByRole('button', { name: 'Recompile' })
+ fireEvent(window, new CustomEvent('project:joined'))
+ screen.getByRole('button', { name: 'Compiling…' })
+ await screen.findByRole('button', { name: 'Recompile' })
+
+ // switch on auto compile
+ storeAndFireEvent('autocompile_enabled:project123', true)
+
+ // fire a doc:changed event => compile
+ fireEvent(window, new CustomEvent('doc:changed'))
+ clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
+
+ await screen.findByText('Compiling…')
+ await screen.findByRole('button', { name: 'Recompile' })
+
+ expect(fetchMock.calls()).to.have.length(6)
+ })
+
+ it('does not run a compile on doc change if autocompile is disabled', async function () {
+ mockCompile()
+ mockBuildFile()
+
+ renderWithEditorContext()
+
+ // fire a project:joined event => compile
+ screen.getByRole('button', { name: 'Recompile' })
+ fireEvent(window, new CustomEvent('project:joined'))
+ screen.getByRole('button', { name: 'Compiling…' })
+ await screen.findByRole('button', { name: 'Recompile' })
+
+ // make sure auto compile is switched off
+ storeAndFireEvent('autocompile_enabled:project123', false)
+ screen.getByRole('button', { name: 'Recompile' })
+
+ // fire a doc:changed event => no compile
+ fireEvent(window, new CustomEvent('doc:changed'))
+ clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
+ screen.getByRole('button', { name: 'Recompile' })
+
+ expect(fetchMock.calls()).to.have.length(3)
+ })
+
+ it('does not run a compile on doc change if autocompile is blocked by syntax check', async function () {
+ mockCompile()
+ mockBuildFile()
+
+ renderWithEditorContext(, {
+ scope: {
+ 'settings.syntaxValidation': true, // enable linting in the editor
+ hasLintingError: true, // mock a linting error
+ },
+ })
+
+ // fire a project:joined event => compile
+ screen.getByRole('button', { name: 'Recompile' })
+ fireEvent(window, new CustomEvent('project:joined'))
+ screen.getByRole('button', { name: 'Compiling…' })
+ await screen.findByRole('button', { name: 'Recompile' })
+
+ // switch on auto compile and syntax checking
+ storeAndFireEvent('autocompile_enabled:project123', true)
+ storeAndFireEvent('stop_on_validation_error:project123', true)
+ screen.getByRole('button', { name: 'Recompile' })
+
+ // fire a doc:changed event => no compile
+ fireEvent(window, new CustomEvent('doc:changed'))
+ clock.tick(2000) // AUTO_COMPILE_DEBOUNCE
+ screen.getByRole('button', { name: 'Recompile' })
+ await screen.findByText('Code check failed')
+
+ expect(fetchMock.calls()).to.have.length(3)
+ })
+
+ it('displays an error message if there was a compile error', async function () {
+ mockCompileError('compile-in-progress')
+
+ renderWithEditorContext()
+
+ // fire a project:joined event => compile
+ screen.getByRole('button', { name: 'Recompile' })
+ fireEvent(window, new CustomEvent('project:joined'))
+ screen.getByRole('button', { name: 'Compiling…' })
+ await screen.findByRole('button', { name: 'Recompile' })
+ screen.getByText(
+ 'Please wait for your other compile to finish before trying again.'
+ )
+
+ expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
+ expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path
+ })
+
+ it('disables the view logs button if there is a compile error', async function () {
+ mockCompileError()
+ mockBuildFile()
+
+ renderWithEditorContext()
+
+ const logsButton = screen.getByRole('button', { name: 'View logs' })
+ expect(logsButton.hasAttribute('disabled')).to.be.false
+
+ // fire a project:joined event => compile
+ screen.getByRole('button', { name: 'Recompile' })
+ fireEvent(window, new CustomEvent('project:joined'))
+ screen.getByRole('button', { name: 'Compiling…' })
+ expect(logsButton.hasAttribute('disabled')).to.be.false
+ await screen.findByRole('button', { name: 'Recompile' })
+ expect(logsButton.hasAttribute('disabled')).to.be.true
+
+ expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
+ expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path
+ })
+
+ it('displays error messages if there were validation problems', async function () {
+ const validationProblems = {
+ sizeCheck: {
+ resources: [
+ { path: 'foo/bar', kbSize: 76221 },
+ { path: 'bar/baz', kbSize: 2342 },
+ ],
+ },
+ mainFile: true,
+ conflictedPaths: [
+ {
+ path: 'foo/bar',
+ },
+ {
+ path: 'foo/baz',
+ },
+ ],
+ }
+
+ mockValidationProblems(validationProblems)
+
+ renderWithEditorContext()
+
+ // fire a project:joined event => compile
+ screen.getByRole('button', { name: 'Recompile' })
+ fireEvent(window, new CustomEvent('project:joined'))
+ screen.getByRole('button', { name: 'Compiling…' })
+ await screen.findByRole('button', { name: 'Recompile' })
+ screen.getByText('Project too large')
+ screen.getByText('Unknown main document')
+ screen.getByText('Conflicting Paths Found')
+
+ expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
+ expect(fetchMock.called('express:/build/:file')).to.be.false // TODO: actual path
+ })
+
+ it('sends a clear cache request when the button is pressed', async function () {
+ mockCompile()
+ mockBuildFile()
+ mockClearCache()
+
+ renderWithEditorContext()
+
+ const logsButton = screen.getByRole('button', { name: 'View logs' })
+ logsButton.click()
+
+ let clearCacheButton = screen.getByRole('button', {
+ name: 'Clear cached files',
+ })
+ expect(clearCacheButton.hasAttribute('disabled')).to.be.false
+
+ // fire a project:joined event => compile
+ screen.getByRole('button', { name: 'Recompile' })
+ fireEvent(window, new CustomEvent('project:joined'))
+ screen.getByRole('button', { name: 'Compiling…' })
+ expect(clearCacheButton.hasAttribute('disabled')).to.be.true
+ await screen.findByRole('button', { name: 'Recompile' })
+ logsButton.click()
+ clearCacheButton = await screen.findByRole('button', {
+ name: 'Clear cached files',
+ })
+ expect(clearCacheButton.hasAttribute('disabled')).to.be.false
+
+ // click the button
+ clearCacheButton.click()
+ expect(clearCacheButton.hasAttribute('disabled')).to.be.true
+ await waitFor(() => {
+ expect(clearCacheButton.hasAttribute('disabled')).to.be.false
+ })
+
+ expect(fetchMock.called('express:/project/:projectId/compile')).to.be.true // TODO: auto_compile query param
+ expect(fetchMock.called('express:/build/:file')).to.be.true // TODO: actual path
+ })
+})
diff --git a/services/web/test/frontend/features/pdf-preview/fixtures/test-example.pdf b/services/web/test/frontend/features/pdf-preview/fixtures/test-example.pdf
new file mode 100644
index 0000000000..4c2cc8512d
Binary files /dev/null and b/services/web/test/frontend/features/pdf-preview/fixtures/test-example.pdf differ
diff --git a/services/web/test/frontend/helpers/render-with-context.js b/services/web/test/frontend/helpers/render-with-context.js
index f4ab9b71cf..2057657273 100644
--- a/services/web/test/frontend/helpers/render-with-context.js
+++ b/services/web/test/frontend/helpers/render-with-context.js
@@ -11,6 +11,7 @@ import { IdeProvider } from '../../../frontend/js/shared/context/ide-context'
import { get } from 'lodash'
import { ProjectProvider } from '../../../frontend/js/shared/context/project-context'
import { SplitTestProvider } from '../../../frontend/js/shared/context/split-test-context'
+import { CompileProvider } from '../../../frontend/js/shared/context/compile-context'
export function EditorProviders({
user = { id: '123abd' },
@@ -52,7 +53,17 @@ export function EditorProviders({
...scope,
}
- window._ide = { $scope, socket, clsiServerId }
+ const fileTreeManager = {
+ findEntityByPath: () => null,
+ getRootDocDirname: () => '',
+ }
+
+ window._ide = {
+ $scope,
+ socket,
+ clsiServerId,
+ fileTreeManager,
+ }
return (
@@ -60,7 +71,9 @@ export function EditorProviders({
- {children}
+
+ {children}
+
diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js
index ec973f21c8..e19fdfa9cb 100644
--- a/services/web/webpack.config.js
+++ b/services/web/webpack.config.js
@@ -159,6 +159,21 @@ module.exports = {
},
],
},
+ {
+ // Load images (static files)
+ test: /\.(svg|gif|png|jpg|pdf)$/,
+ use: [
+ {
+ loader: 'file-loader',
+ options: {
+ // Output to public/images
+ outputPath: 'images',
+ publicPath: '/images/',
+ name: '[name].[ext]',
+ },
+ },
+ ],
+ },
{
// These options are necessary for handlebars to have access to helper
// methods
@@ -288,9 +303,13 @@ module.exports = {
from: 'node_modules/ace-builds/src-min-noconflict',
to: `js/ace-${PackageVersions.version.ace}/`,
},
- // Copy CMap files from pdfjs-dist package to build output. These are used
- // to provide support for non-Latin characters
+ // Copy CMap files (used to provide support for non-Latin characters)
+ // and static images from pdfjs-dist package to build output.
{ from: 'node_modules/pdfjs-dist/cmaps', to: 'js/cmaps' },
+ {
+ from: 'node_modules/pdfjs-dist/legacy/web/images',
+ to: 'images',
+ },
]),
],
}