Merge pull request #7034 from overleaf/ta-pdf-detach-full

PDF Detach v2

GitOrigin-RevId: 3deb76474185f9176cde23ab32ef51b90df6e8e9
This commit is contained in:
Timothée Alby 2022-03-31 13:22:36 +02:00 committed by Copybot
parent 4d18dcb377
commit 3c01402bbd
63 changed files with 1636 additions and 891 deletions

View file

@ -948,11 +948,16 @@ const ProjectController = {
!Features.hasFeature('saas') ||
(user.features && user.features.symbolPalette)
res.render('project/editor', {
const template =
detachRole === 'detached'
? 'project/editor_detached'
: 'project/editor'
res.render(template, {
title: project.name,
priority_title: true,
bodyClasses: ['editor'],
project_id: project._id,
projectName: project.name,
user: {
id: userId,
email: user.email,

View file

@ -65,11 +65,7 @@ block content
span.sr-only #{translate("close")}
.system-message-content(ng-bind-html="htmlContent")
if detachRole === 'detached'
div.full-size
pdf-preview()
else
include ./editor/main
include ./editor/main
script(type="text/ng-template", id="genericMessageModalTemplate")
.modal-header

View file

@ -1,5 +1,6 @@
meta(name="ol-useV2History" data-type="boolean" content=useV2History)
meta(name="ol-project_id" content=project_id)
meta(name="ol-projectName" content=projectName)
meta(name="ol-userSettings" data-type="json" content=userSettings)
meta(name="ol-user" data-type="json" content=user)
meta(name="ol-learnedWords" data-type="json" content=learnedWords)

View file

@ -0,0 +1,16 @@
extends ../layout
block entrypointVar
- entrypoint = 'ide-detached'
block vars
- var suppressNavbar = true
- var suppressFooter = true
- var suppressSkipToContent = true
- metadata.robotsNoindexNofollow = true
block content
#pdf-preview-detached-root()
block append meta
include ./editor/meta

View file

@ -342,6 +342,7 @@
"sync_project_to_github_explanation": "",
"sync_to_dropbox": "",
"sync_to_github": "",
"tab_connecting": "",
"tab_no_longer_connected": "",
"tags": "",
"template_approved_by_publisher": "",

View file

@ -7,7 +7,6 @@ import IconChecked from '../../../shared/components/icon-checked'
import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import IconEditorOnly from './icon-editor-only'
import IconPdfOnly from './icon-pdf-only'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useLayoutContext } from '../../../shared/context/layout-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
@ -59,13 +58,10 @@ function LayoutDropdownButton() {
pdfLayout,
} = useLayoutContext(layoutContextPropTypes)
const { stopCompile } = useCompileContext()
const handleDetach = useCallback(() => {
detach()
stopCompile()
eventTracking.sendMB('project-layout-detach')
}, [detach, stopCompile])
}, [detach])
const handleReattach = useCallback(() => {
if (detachRole !== 'detacher') {

View file

@ -1,26 +1,13 @@
import { memo, useCallback } from 'react'
import { memo } from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
import { useLayoutContext } from '../../../shared/context/layout-context'
import { useCompileContext } from '../../../shared/context/compile-context'
import useDetachAction from '../../../shared/hooks/use-detach-action'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import PdfCompileButtonInner from './pdf-compile-button-inner'
export function DetachCompileButton() {
const { compiling, hasChanges, startCompile } = useCompileContext()
const startOrTriggerCompile = useDetachAction(
'start-compile',
startCompile,
'detacher',
'detached'
)
const handleStartCompile = useCallback(
() => startOrTriggerCompile(),
[startOrTriggerCompile]
)
return (
<div
className={classnames({
@ -29,7 +16,7 @@ export function DetachCompileButton() {
})}
>
<PdfCompileButtonInner
startCompile={handleStartCompile}
startCompile={startCompile}
compiling={compiling}
/>
</div>

View file

@ -2,7 +2,7 @@ import Icon from '../../../shared/components/icon'
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfClearCacheButton() {
const { compiling, clearCache, clearingCache } = useCompileContext()
@ -14,7 +14,7 @@ function PdfClearCacheButton() {
bsSize="small"
bsStyle="danger"
className="logs-pane-actions-clear-cache"
onClick={clearCache}
onClick={() => clearCache()}
disabled={clearingCache || compiling}
>
{clearingCache ? <Icon type="refresh" spin /> : <Icon type="trash-o" />}

View file

@ -4,7 +4,7 @@ import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import classnames from 'classnames'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import PdfCompileButtonInner from './pdf-compile-button-inner'
function PdfCompileButton() {
@ -84,7 +84,7 @@ function PdfCompileButton() {
<MenuItem divider />
<MenuItem
onSelect={stopCompile}
onSelect={() => stopCompile()}
disabled={!compiling}
aria-disabled={!compiling}
>
@ -92,7 +92,7 @@ function PdfCompileButton() {
</MenuItem>
<MenuItem
onSelect={recompileFromScratch}
onSelect={() => recompileFromScratch()}
disabled={compiling}
aria-disabled={compiling}
>

View file

@ -3,7 +3,7 @@ import PdfFileList from './pdf-file-list'
import ControlledDropdown from '../../../shared/components/controlled-dropdown'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfDownloadFilesButton() {
const { compiling, fileList } = useCompileContext()

View file

@ -1,24 +1,17 @@
import { memo, useCallback } from 'react'
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'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfHybridCodeCheckButton() {
const { codeCheckFailed, error, setShowLogs } = useCompileContext()
const { codeCheckFailed, error, toggleLogs } = useCompileContext()
const { t } = useTranslation()
const handleClick = useCallback(() => {
setShowLogs(value => {
if (!value) {
sendMBOnce('ide-open-logs-once')
}
return !value
})
}, [setShowLogs])
toggleLogs()
}, [toggleLogs])
if (!codeCheckFailed) {
return null

View file

@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'
import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
import Icon from '../../../shared/components/icon'
import { memo } from 'react'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfHybridDownloadButton() {
const { pdfDownloadUrl } = useCompileContext()

View file

@ -1,24 +1,17 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Button, Label, OverlayTrigger, Tooltip } from 'react-bootstrap'
import { sendMBOnce } from '../../../infrastructure/event-tracking'
import Icon from '../../../shared/components/icon'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfHybridLogsButton() {
const { error, logEntries, setShowLogs, showLogs } = useCompileContext()
const { error, logEntries, toggleLogs, showLogs } = useCompileContext()
const { t } = useTranslation()
const handleClick = useCallback(() => {
setShowLogs(value => {
if (!value) {
sendMBOnce('ide-open-logs-once')
}
return !value
})
}, [setShowLogs])
toggleLogs()
}, [toggleLogs])
const errorCount = Number(logEntries?.errors?.length)
const warningCount = Number(logEntries?.warnings?.length)

View file

@ -8,7 +8,7 @@ import { buildHighlightElement } from '../util/highlights'
import PDFJSWrapper from '../util/pdf-js-wrapper'
import withErrorBoundary from '../../../infrastructure/error-boundary'
import ErrorBoundaryFallback from './error-boundary-fallback'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import getMeta from '../../../utils/meta'
function PdfJsViewer({ url }) {

View file

@ -3,8 +3,10 @@ import classNames from 'classnames'
import { memo, useCallback } from 'react'
import PreviewLogEntryHeader from '../../preview/components/preview-log-entry-header'
import PdfLogEntryContent from './pdf-log-entry-content'
import HumanReadableLogsHints from '../../../ide/human-readable-logs/HumanReadableLogsHints'
function PdfLogEntry({
ruleId,
headerTitle,
headerIcon,
rawContent,
@ -20,6 +22,12 @@ function PdfLogEntry({
onSourceLocationClick,
onClose,
}) {
if (ruleId && HumanReadableLogsHints[ruleId]) {
const hint = HumanReadableLogsHints[ruleId]
formattedContent = hint.formattedContent
extraInfoURL = hint.extraInfoURL
}
const handleLogEntryLinkClick = useCallback(
event => {
event.preventDefault()
@ -56,6 +64,7 @@ function PdfLogEntry({
}
PdfLogEntry.propTypes = {
ruleId: PropTypes.string,
sourceLocation: PreviewLogEntryHeader.propTypes.sourceLocation,
headerTitle: PropTypes.string,
headerIcon: PropTypes.element,

View file

@ -48,11 +48,10 @@ function PdfLogsEntries({ entries, hasErrors }) {
{logEntries.map(logEntry => (
<PdfLogEntry
key={logEntry.key}
ruleId={logEntry.ruleId}
headerTitle={logEntry.message}
rawContent={logEntry.content}
logType={logEntry.type}
formattedContent={logEntry.humanReadableHintComponent}
extraInfoURL={logEntry.extraInfoURL}
level={logEntry.level}
entryAriaLabel={t('log_entry_description', {
level: logEntry.level,

View file

@ -11,7 +11,7 @@ import withErrorBoundary from '../../../infrastructure/error-boundary'
import ErrorBoundaryFallback from './error-boundary-fallback'
import PdfCodeCheckFailedNotice from './pdf-code-check-failed-notice'
import PdfLogsPaneInfoNotice from './pdf-logs-pane-info-notice'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import PdfLogEntry from './pdf-log-entry'
function PdfLogsViewer() {

View file

@ -0,0 +1,18 @@
import ReactDOM from 'react-dom'
import PdfPreview from './pdf-preview'
import { ContextRoot } from '../../../shared/context/root-context'
function PdfPreviewDetachedRoot() {
return (
<ContextRoot>
<PdfPreview />
</ContextRoot>
)
}
export default PdfPreviewDetachedRoot // for testing
const element = document.getElementById('pdf-preview-detached-root')
if (element) {
ReactDOM.render(<PdfPreviewDetachedRoot />, element)
}

View file

@ -1,4 +1,4 @@
import { memo } from 'react'
import { memo, useState, useEffect, useRef } from 'react'
import { ButtonToolbar } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useLayoutContext } from '../../../shared/context/layout-context'
@ -9,19 +9,46 @@ import PdfHybridDownloadButton from './pdf-hybrid-download-button'
import PdfHybridCodeCheckButton from './pdf-hybrid-code-check-button'
import PdfOrphanRefreshButton from './pdf-orphan-refresh-button'
import { DetachedSynctexControl } from './detach-synctex-control'
import Icon from '../../../shared/components/icon'
const ORPHAN_UI_TIMEOUT_MS = 5000
function PdfPreviewHybridToolbar() {
const { detachRole, detachIsLinked } = useLayoutContext()
const uiTimeoutRef = useRef()
const [orphanPdfTabAfterDelay, setOrphanPdfTabAfterDelay] = useState(false)
const orphanPdfTab = !detachIsLinked && detachRole === 'detached'
useEffect(() => {
if (uiTimeoutRef.current) {
clearTimeout(uiTimeoutRef.current)
}
if (orphanPdfTab) {
uiTimeoutRef.current = setTimeout(() => {
setOrphanPdfTabAfterDelay(true)
}, ORPHAN_UI_TIMEOUT_MS)
} else {
setOrphanPdfTabAfterDelay(false)
}
}, [orphanPdfTab])
let ToolbarInner = null
if (orphanPdfTabAfterDelay) {
// when the detached tab has been orphan for a while
ToolbarInner = <PdfPreviewHybridToolbarOrphanInner />
} else if (orphanPdfTab) {
ToolbarInner = <PdfPreviewHybridToolbarConnectingInner />
} else {
// tab is not detached or not orphan
ToolbarInner = <PdfPreviewHybridToolbarInner />
}
return (
<ButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
{orphanPdfTab ? (
<PdfPreviewHybridToolbarOrphanInner />
) : (
<PdfPreviewHybridToolbarInner />
)}
{ToolbarInner}
</ButtonToolbar>
)
}
@ -55,4 +82,16 @@ function PdfPreviewHybridToolbarOrphanInner() {
)
}
function PdfPreviewHybridToolbarConnectingInner() {
const { t } = useTranslation()
return (
<>
<div className="toolbar-pdf-orphan">
<Icon type="refresh" fw spin />
{t('tab_connecting')}
</div>
</>
)
}
export default memo(PdfPreviewHybridToolbar)

View file

@ -4,7 +4,7 @@ import PdfLogsViewer from './pdf-logs-viewer'
import PdfViewer from './pdf-viewer'
import LoadingSpinner from '../../../shared/components/loading-spinner'
import PdfHybridPreviewToolbar from './pdf-preview-hybrid-toolbar'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfPreviewPane() {
const { pdfUrl } = useCompileContext()

View file

@ -1,10 +1,10 @@
import classNames from 'classnames'
import { memo, useCallback, useEffect, useState, useMemo } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { useIdeContext } from '../../../shared/context/ide-context'
import { useProjectContext } from '../../../shared/context/project-context'
import { getJSON } from '../../../infrastructure/fetch-json'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useLayoutContext } from '../../../shared/context/layout-context'
import useScopeValue from '../../../shared/hooks/use-scope-value'
import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
@ -134,32 +134,19 @@ function PdfSynctexControls() {
const { signal } = useAbortController()
// for detacher editor tab, which cannot access pdfUrl in a scope value in
// detached state
const [pdfExists, setPdfExists] = useDetachState(
'pdf-exists',
!!pdfUrl,
'detached',
'detacher'
)
useEffect(() => {
setPdfExists(!!pdfUrl)
}, [pdfUrl, setPdfExists])
useEffect(() => {
const listener = event => setCursorPosition(event.detail)
window.addEventListener('cursor:editor:update', listener)
return () => window.removeEventListener('cursor:editor:update', listener)
}, [ide])
const [syncToPdfInFlight, setSyncToPdfInFlight] = useDetachState(
'sync-to-pdf-inflight',
const [syncToPdfInFlight, setSyncToPdfInFlight] = useState(false)
const [syncToCodeInFlight, setSyncToCodeInFlight] = useDetachState(
'sync-to-code-inflight',
false,
'detached',
'detacher'
'detacher',
'detached'
)
const [syncToCodeInFlight, setSyncToCodeInFlight] = useState(false)
const [, setSynctexError] = useScopeValue('sync_tex_error')
@ -179,7 +166,7 @@ function PdfSynctexControls() {
return path
}, [ide])
const _goToCodeLine = useCallback(
const goToCodeLine = useCallback(
(file, line) => {
if (file) {
const doc = ide.fileTreeManager.findEntityByPath(file)
@ -200,14 +187,7 @@ function PdfSynctexControls() {
[ide, isMounted, setSynctexError]
)
const goToCodeLine = useDetachAction(
'go-to-code-line',
_goToCodeLine,
'detached',
'detacher'
)
const _goToPdfLocation = useCallback(
const goToPdfLocation = useCallback(
params => {
setSyncToPdfInFlight(true)
@ -240,13 +220,6 @@ function PdfSynctexControls() {
]
)
const goToPdfLocation = useDetachAction(
'go-to-pdf-location',
_goToPdfLocation,
'detacher',
'detached'
)
const syncToPdf = useCallback(
cursorPosition => {
const params = new URLSearchParams({
@ -260,7 +233,7 @@ function PdfSynctexControls() {
[getCurrentFilePath, goToPdfLocation]
)
const syncToCode = useCallback(
const _syncToCode = useCallback(
(position, visualOffset = 0) => {
setSyncToCodeInFlight(true)
// FIXME: this actually works better if it's halfway across the
@ -317,6 +290,13 @@ function PdfSynctexControls() {
]
)
const syncToCode = useDetachAction(
'sync-to-code',
_syncToCode,
'detached',
'detacher'
)
useEffect(() => {
const listener = event => syncToCode(event.detail)
window.addEventListener('synctex:sync-to-position', listener)
@ -325,22 +305,32 @@ function PdfSynctexControls() {
}
}, [syncToCode])
const hasSingleSelectedDoc = useMemo(() => {
const [hasSingleSelectedDoc, setHasSingleSelectedDoc] = useDetachState(
'has-single-selected-doc',
false,
'detacher',
'detached'
)
useEffect(() => {
if (selectedEntities.length !== 1) {
return false
setHasSingleSelectedDoc(false)
return
}
if (selectedEntities[0].type !== 'doc') {
return false
setHasSingleSelectedDoc(false)
return
}
return true
}, [selectedEntities])
setHasSingleSelectedDoc(true)
}, [selectedEntities, setHasSingleSelectedDoc])
if (!position) {
return null
}
if (!pdfExists || pdfViewer === 'native') {
if (!pdfUrl || pdfViewer === 'native') {
return null
}

View file

@ -1,5 +1,5 @@
import { lazy, memo } from 'react'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
const PdfJsViewer = lazy(() =>
import(/* webpackChunkName: "pdf-js-viewer" */ './pdf-js-viewer')

View file

@ -1,18 +1,13 @@
import { useCallback, useEffect } from 'react'
import { useCallback } from 'react'
import getMeta from '../../../utils/meta'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useDetachContext } from '../../../shared/context/detach-context'
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import useEventListener from '../../../shared/hooks/use-event-listener'
import useDetachAction from '../../../shared/hooks/use-detach-action'
import usePreviousValue from '../../../shared/hooks/use-previous-value'
const showPdfDetach = getMeta('ol-showPdfDetach')
const debugPdfDetach = getMeta('ol-debugPdfDetach')
export default function useCompileTriggers() {
const { startCompile, setChangedAt, cleanupCompileResult, setError } =
useCompileContext()
const { role: detachRole } = useDetachContext()
const { startCompile, setChangedAt } = useCompileContext()
// recompile on key press
const startOrTriggerCompile = useDetachAction(
@ -43,23 +38,4 @@ export default function useCompileTriggers() {
}, [setOrTriggerChangedAt, setChangedAt])
useEventListener('doc:changed', setChangedAtHandler)
useEventListener('doc:saved', setChangedAtHandler)
// clear preview and recompile when the detach role is reset
const previousDetachRole = usePreviousValue(detachRole)
useEffect(() => {
if (previousDetachRole && !detachRole) {
if (debugPdfDetach) {
console.log('Recompile on reattach', { previousDetachRole, detachRole })
}
cleanupCompileResult()
setError()
startCompile()
}
}, [
cleanupCompileResult,
setError,
startCompile,
previousDetachRole,
detachRole,
])
}

View file

@ -0,0 +1,5 @@
import './utils/meta'
import './utils/webpack-public-path'
import './infrastructure/error-reporter'
import './i18n'
import './features/pdf-preview/components/pdf-preview-detached-root'

View file

@ -28,11 +28,6 @@ export default {
let type
if (ruleDetails.ruleId != null) {
entry.ruleId = ruleDetails.ruleId
} else if (ruleDetails.regexToMatch != null) {
entry.ruleId = `hint_${ruleDetails.regexToMatch
.toString()
.replace(/\s/g, '_')
.slice(1, -1)}`
}
if (ruleDetails.newMessage != null) {
entry.message = entry.message.replace(
@ -54,19 +49,6 @@ export default {
seenErrorTypes[type] = true
}
}
if (ruleDetails.humanReadableHint != null) {
entry.humanReadableHint = ruleDetails.humanReadableHint
}
if (ruleDetails.humanReadableHintComponent != null) {
entry.humanReadableHintComponent =
ruleDetails.humanReadableHintComponent
}
if (ruleDetails.extraInfoURL != null) {
entry.extraInfoURL = ruleDetails.extraInfoURL
}
}
}

View file

@ -0,0 +1,449 @@
import PropTypes from 'prop-types'
function WikiLink({ url, children }) {
if (window.wikiEnabled) {
return (
<a href={url} target="_blank" rel="noopener">
{children}
</a>
)
} else {
return <>{children}</>
}
}
WikiLink.propTypes = {
url: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
}
const hints = {
hint_misplaced_alignment_tab_character: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Misplaced_alignment_tab_character_%26',
formattedContent: (
<>
You have placed an alignment tab character '&' in the wrong place. If
you want to align something, you must write it inside an align
environment such as \begin
{'{align}'} \end
{'{align}'}, \begin
{'{tabular}'} \end
{'{tabular}'}, etc. If you want to write an ampersand '&' in text, you
must write \& instead.
</>
),
},
hint_extra_alignment_tab_has_been_changed: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr',
formattedContent: (
<>
You have written too many alignment tabs in a table, causing one of them
to be turned into a line break. Make sure you have specified the correct
number of columns in your{' '}
<WikiLink url="https://www.overleaf.com/learn/Tables">table</WikiLink>.
</>
),
},
hint_display_math_should_end_with: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Display_math_should_end_with_$$',
formattedContent: (
<>
You have forgotten a $ sign at the end of 'display math' mode. When
writing in display math mode, you must always math write inside $$ $$.
Check that the number of $s match around each math expression.
</>
),
},
hint_missing_inserted: {
extraInfoURL: 'https://www.overleaf.com/learn/Errors/Missing_$_inserted',
formattedContent: (
<>
<p>
You need to enclose all mathematical expressions and symbols with
special markers. These special markers create a math mode.
</p>
<p>
Use <code>$...$</code> for inline math mode, and <code>\[...\]</code>
or one of the mathematical environments (e.g. equation) for display
math mode.
</p>
<p>
This applies to symbols such as subscripts ( <code>_</code> ),
integrals ( <code>\int</code> ), Greek letters ( <code>\alpha</code>,{' '}
<code>\beta</code>, <code>\delta</code> ) and modifiers{' '}
<code>{'(\\vec{x}'}</code>, <code>{'\\tilde{x}'})</code>.
</p>
</>
),
},
hint_reference_undefined: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/There_were_undefined_references',
formattedContent: (
<>
You have referenced something which has not yet been labelled. If you
have labelled it already, make sure that what is written inside \ref
{'{...}'} is the same as what is written inside \label
{'{...}'}.
</>
),
},
hint_there_were_undefined_references: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/There_were_undefined_references',
formattedContent: (
<>
You have referenced something which has not yet been labelled. If you
have labelled it already, make sure that what is written inside \ref
{'{...}'} is the same as what is written inside \label
{'{...}'}.
</>
),
},
hint_citation_on_page_undefined_on_input_line: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX',
formattedContent: (
<>
You have cited something which is not included in your bibliography.
Make sure that the citation (\cite
{'{...}'}) has a corresponding key in your bibliography, and that both
are spelled the same way.
</>
),
},
hint_label_multiply_defined_labels: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/There_were_multiply-defined_labels',
formattedContent: (
<>
You have used the same label more than once. Check that each \label
{'{...}'} labels only one item.
</>
),
},
hint_float_specifier_changed: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27',
formattedContent: (
<>
The float specifier 'h' is too strict of a demand for LaTeX to place
your float in a nice way here. Try relaxing it by using 'ht', or even
'htbp' if necessary. If you want to try keep the float here anyway,
check out the{' '}
<WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
float package
</WikiLink>
.
</>
),
},
hint_no_positions_in_optional_float_specifier: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/No_positions_in_optional_float_specifier',
formattedContent: (
<>
You have forgotten to include a float specifier, which tells LaTeX where
to position your figure. To fix this, either insert a float specifier
inside the square brackets (e.g. \begin
{'{figure}'}
[h]), or remove the square brackets (e.g. \begin
{'{figure}'}
). Find out more about float specifiers{' '}
<WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
here
</WikiLink>
.
</>
),
},
hint_undefined_control_sequence: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Undefined_control_sequence',
formattedContent: (
<>
The compiler is having trouble understanding a command you have used.
Check that the command is spelled correctly. If the command is part of a
package, make sure you have included the package in your preamble using
\usepackage
{'{...}'}.
</>
),
},
hint_file_not_found: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX',
formattedContent: (
<>
The compiler cannot find the file you want to include. Make sure that
you have{' '}
<WikiLink url="https://www.overleaf.com/learn/Including_images_in_ShareLaTeX">
uploaded the file
</WikiLink>{' '}
and{' '}
<WikiLink url="https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX.">
specified the file location correctly
</WikiLink>
.
</>
),
},
hint_unknown_graphics_extension: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX',
formattedContent: (
<>
The compiler does not recognise the file type of one of your images.
Make sure you are using a{' '}
<WikiLink url="https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif.">
supported image format
</WikiLink>{' '}
for your choice of compiler, and check that there are no periods (.) in
the name of your image.
</>
),
},
hint_unknown_float_option_h: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27',
formattedContent: (
<>
The compiler isn't recognizing the float option 'H'. Include \usepackage
{'{float}'} in your preamble to fix this.
</>
),
},
hint_unknown_float_option_q: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27',
formattedContent: (
<>
You have used a float specifier which the compiler does not understand.
You can learn more about the different float options available for
placing figures{' '}
<WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
here
</WikiLink>{' '}
.
</>
),
},
hint_math_allowed_only_in_math_mode: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode',
formattedContent: (
<>
You have used a font command which is only available in math mode. To
use this command, you must be in maths mode (E.g. $ $ or \begin
{'{math}'} \end
{'{math}'}
). If you want to use it outside of math mode, use the text version
instead: \textrm, \textit, etc.
</>
),
},
hint_mismatched_environment: {
formattedContent: (
<>
You have used \begin
{'{...}'} without a corresponding \end
{'{...}'}.
</>
),
},
hint_mismatched_brackets: {
formattedContent: (
<>You have used an open bracket without a corresponding close bracket.</>
),
},
hint_can_be_used_only_in_preamble: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Can_be_used_only_in_preamble',
formattedContent: (
<>
You have used a command in the main body of your document which should
be used in the preamble. Make sure that \documentclass[]
{'{…}'} and all \usepackage
{'{…}'} commands are written before \begin
{'{document}'}.
</>
),
},
hint_missing_right_inserted: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Missing_%5Cright_insertede',
formattedContent: (
<>
You have started an expression with a \left command, but have not
included a corresponding \right command. Make sure that your \left and
\right commands balance everywhere, or else try using \Biggl and \Biggr
commands instead as shown{' '}
<WikiLink url="https://www.overleaf.com/learn/Errors/Missing_%5Cright_inserted">
here
</WikiLink>
.
</>
),
},
hint_double_superscript: {
extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_superscript',
formattedContent: (
<>
You have written a double superscript incorrectly as a^b^c, or else you
have written a prime with a superscript. Remember to include {'{and}'}{' '}
when using multiple superscripts. Try a^
{'{b ^ c}'} instead.
</>
),
},
hint_double_subscript: {
extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_subscript',
formattedContent: (
<>
You have written a double subscript incorrectly as a_b_c. Remember to
include {'{and}'} when using multiple subscripts. Try a_
{'{b_c}'} instead.
</>
),
},
hint_no_author_given: {
extraInfoURL: 'https://www.overleaf.com/learn/Errors/No_%5Cauthor_given',
formattedContent: (
<>
You have used the \maketitle command, but have not specified any
\author. To fix this, include an author in your preamble using the
\author
{'{…}'} command.
</>
),
},
hint_environment_undefined: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Environment%20XXX%20undefined',
formattedContent: (
<>
You have created an environment (using \begin
{'{…}'} and \end
{'{…}'} commands) which is not recognized. Make sure you have included
the required package for that environment in your preamble, and that the
environment is spelled correctly.
</>
),
},
hint_somethings_wrong_perhaps_a_missing_item: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Something%27s_wrong--perhaps_a_missing_%5Citem',
formattedContent: (
<>
There are no entries found in a list you have created. Make sure you
label list entries using the \item command, and that you have not used a
list inside a table.
</>
),
},
hint_misplaced_noalign: {
extraInfoURL: 'https://www.overleaf.com/learn/Errors/Misplaced_%5Cnoalign',
formattedContent: (
<>
You have used a \hline command in the wrong place, probably outside a
table. If the \hline command is written inside a table, try including \\
before it.
</>
),
},
hint_no_line_here_to_end: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_There%27s_no_line_here_to_end',
formattedContent: (
<>
You have used a \\ or \newline command where LaTeX was not expecting
one. Make sure that you only use line breaks after blocks of text, and
be careful using linebreaks inside lists and other environments.\
</>
),
},
hint_verb_ended_by_end_of_line: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cverb_ended_by_end_of_line',
formattedContent: (
<>
You have used a \verb command incorrectly. Try replacling the \verb
command with \begin
{'{verbatim}'}
\end
{'{verbatim}'}
.\
</>
),
},
hint_illegal_unit_of_measure_pt_inserted: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors%2FIllegal%20unit%20of%20measure%20(pt%20inserted)',
formattedContent: (
<>
You have written a length, but have not specified the appropriate units
(pt, mm, cm etc.). If you have not written a length, check that you have
not witten a linebreak \\ followed by square brackets [] anywhere.
</>
),
},
hint_extra_right: {
extraInfoURL: 'https://www.overleaf.com/learn/Errors/Extra_%5Cright',
formattedContent: (
<>
You have written a \right command without a corresponding \left command.
Check that all \left and \right commands balance everywhere.
</>
),
},
hint_missing_begin_document_: {
extraInfoURL:
'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Missing%20%5Cbegin%20document',
formattedContent: (
<>
No \begin
{'{document}'} command was found. Make sure you have included \begin
{'{document}'} in your preamble, and that your main document is set
correctly.
</>
),
},
hint_mismatched_environment2: {
formattedContent: (
<>
You have used \begin
{'{}'} without a corresponding \end
{'{}'}.
</>
),
},
hint_mismatched_environment3: {
formattedContent: (
<>
You have used \begin
{'{}'} without a corresponding \end
{'{}'}.
</>
),
},
hint_mismatched_environment4: {
formattedContent: (
<>
You have used \begin
{'{}'} without a corresponding \end
{'{}'}.
</>
),
},
}
if (!window.wikiEnabled) {
Object.keys(hints).forEach(ruleId => {
hints[ruleId].extraInfoURL = null
})
}
export default hints

View file

@ -1,515 +1,133 @@
/* eslint-disable no-useless-escape */
import PropTypes from 'prop-types'
function WikiLink({ url, children }) {
if (window.wikiEnabled) {
return (
<a href={url} target="_blank" rel="noopener">
{children}
</a>
)
} else {
return <>{children}</>
}
}
WikiLink.propTypes = {
url: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
}
const rules = [
{
ruleId: 'hint_misplaced_alignment_tab_character',
regexToMatch: /Misplaced alignment tab character \&/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Misplaced_alignment_tab_character_%26',
humanReadableHintComponent: (
<>
You have placed an alignment tab character '&' in the wrong place. If
you want to align something, you must write it inside an align
environment such as \begin
{'{align}'} \end
{'{align}'}, \begin
{'{tabular}'} \end
{'{tabular}'}, etc. If you want to write an ampersand '&' in text, you
must write \& instead.
</>
),
humanReadableHint:
'You have placed an alignment tab character &#x27;&amp;&#x27; in the wrong place. If you want to align something, you must write it inside an align environment such as \\begin{align} … \\end{align}, \\begin{tabular} … \\end{tabular}, etc. If you want to write an ampersand &#x27;&amp;&#x27; in text, you must write \\&amp; instead.',
},
{
ruleId: 'hint_extra_alignment_tab_has_been_changed',
regexToMatch: /Extra alignment tab has been changed to \\cr/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Extra_alignment_tab_has_been_changed_to_%5Ccr',
humanReadableHintComponent: (
<>
You have written too many alignment tabs in a table, causing one of them
to be turned into a line break. Make sure you have specified the correct
number of columns in your{' '}
<WikiLink url="https://www.overleaf.com/learn/Tables">table</WikiLink>.
</>
),
humanReadableHint:
'You have written too many alignment tabs in a table, causing one of them to be turned into a line break. Make sure you have specified the correct number of columns in your <a href="https://www.overleaf.com/learn/Tables" target="_blank">table</a>.',
},
{
ruleId: 'hint_display_math_should_end_with',
regexToMatch: /Display math should end with \$\$/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Display_math_should_end_with_$$',
humanReadableHintComponent: (
<>
You have forgotten a $ sign at the end of 'display math' mode. When
writing in display math mode, you must always math write inside $$ $$.
Check that the number of $s match around each math expression.
</>
),
humanReadableHint:
'You have forgotten a $ sign at the end of &#x27;display math&#x27; mode. When writing in display math mode, you must always math write inside $$ … $$. Check that the number of $s match around each math expression.',
},
{
ruleId: 'hint_missing_inserted',
regexToMatch: /Missing [{$] inserted./,
extraInfoURL: 'https://www.overleaf.com/learn/Errors/Missing_$_inserted',
humanReadableHintComponent: (
<>
<p>
You need to enclose all mathematical expressions and symbols with
special markers. These special markers create a math mode.
</p>
<p>
Use <code>$...$</code> for inline math mode, and <code>\[...\]</code>
or one of the mathematical environments (e.g. equation) for display
math mode.
</p>
<p>
This applies to symbols such as subscripts ( <code>_</code> ),
integrals ( <code>\int</code> ), Greek letters ( <code>\alpha</code>,{' '}
<code>\beta</code>, <code>\delta</code> ) and modifiers{' '}
<code>{'(\\vec{x}'}</code>, <code>{'\\tilde{x}'})</code>.
</p>
</>
),
humanReadableHint:
'You need to enclose all mathematical expressions and symbols with special markers. These special markers create a math mode. Use $...$ for inline math mode, and \\[...\\] or one of the mathematical environments (e.g. equation) for display math mode. This applies to symbols such as subscripts ( _ ), integrals ( \\int ), Greek letters ( \\alpha, \\beta, \\delta ) and modifiers (\\vec{x}, \\tilde{x} ).',
},
{
ruleId: 'hint_reference_undefined',
regexToMatch: /Reference.+undefined/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/There_were_undefined_references',
humanReadableHintComponent: (
<>
You have referenced something which has not yet been labelled. If you
have labelled it already, make sure that what is written inside \ref
{'{...}'} is the same as what is written inside \label
{'{...}'}.
</>
),
humanReadableHint:
'You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.',
},
{
ruleId: 'hint_there_were_undefined_references',
regexToMatch: /There were undefined references/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/There_were_undefined_references',
humanReadableHintComponent: (
<>
You have referenced something which has not yet been labelled. If you
have labelled it already, make sure that what is written inside \ref
{'{...}'} is the same as what is written inside \label
{'{...}'}.
</>
),
humanReadableHint:
'You have referenced something which has not yet been labelled. If you have labelled it already, make sure that what is written inside \\ref{...} is the same as what is written inside \\label{...}.',
},
{
ruleId: 'hint_citation_on_page_undefined_on_input_line',
regexToMatch: /Citation .+ on page .+ undefined on input line .+/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Citation_XXX_on_page_XXX_undefined_on_input_line_XXX',
humanReadableHintComponent: (
<>
You have cited something which is not included in your bibliography.
Make sure that the citation (\cite
{'{...}'}) has a corresponding key in your bibliography, and that both
are spelled the same way.
</>
),
humanReadableHint:
'You have cited something which is not included in your bibliography. Make sure that the citation (\\cite{...}) has a corresponding key in your bibliography, and that both are spelled the same way.',
},
{
ruleId: 'hint_label_multiply_defined_labels',
regexToMatch: /(Label .+)? multiply[ -]defined( labels)?/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/There_were_multiply-defined_labels',
humanReadableHintComponent: (
<>
You have used the same label more than once. Check that each \label
{'{...}'} labels only one item.
</>
),
humanReadableHint:
'You have used the same label more than once. Check that each \\label{...} labels only one item.',
},
{
ruleId: 'hint_float_specifier_changed',
regexToMatch: /`!?h' float specifier changed to `!?ht'/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/%60!h%27_float_specifier_changed_to_%60!ht%27',
humanReadableHintComponent: (
<>
The float specifier 'h' is too strict of a demand for LaTeX to place
your float in a nice way here. Try relaxing it by using 'ht', or even
'htbp' if necessary. If you want to try keep the float here anyway,
check out the{' '}
<WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
float package
</WikiLink>
.
</>
),
humanReadableHint:
'The float specifier &#x27;h&#x27; is too strict of a demand for LaTeX to place your float in a nice way here. Try relaxing it by using &#x27;ht&#x27;, or even &#x27;htbp&#x27; if necessary. If you want to try keep the float here anyway, check out the <a href="https://www.overleaf.com/learn/Positioning_of_Figures" target="_blank">float package</a>.',
},
{
ruleId: 'hint_no_positions_in_optional_float_specifier',
regexToMatch: /No positions in optional float specifier/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/No_positions_in_optional_float_specifier',
humanReadableHintComponent: (
<>
You have forgotten to include a float specifier, which tells LaTeX where
to position your figure. To fix this, either insert a float specifier
inside the square brackets (e.g. \begin
{'{figure}'}
[h]), or remove the square brackets (e.g. \begin
{'{figure}'}
). Find out more about float specifiers{' '}
<WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
here
</WikiLink>
.
</>
),
humanReadableHint:
'You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \\begin{figure}[h]), or remove the square brackets (e.g. \\begin{figure}). Find out more about float specifiers <a href="https://www.overleaf.com/learn/Positioning_of_Figures" target="_blank">here</a>.',
},
{
ruleId: 'hint_undefined_control_sequence',
regexToMatch: /Undefined control sequence/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Undefined_control_sequence',
humanReadableHintComponent: (
<>
The compiler is having trouble understanding a command you have used.
Check that the command is spelled correctly. If the command is part of a
package, make sure you have included the package in your preamble using
\usepackage
{'{...}'}.
</>
),
humanReadableHint:
'The compiler is having trouble understanding a command you have used. Check that the command is spelled correctly. If the command is part of a package, make sure you have included the package in your preamble using \\usepackage{...}.',
},
{
ruleId: 'hint_file_not_found',
regexToMatch: /File .+ not found/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX',
humanReadableHintComponent: (
<>
The compiler cannot find the file you want to include. Make sure that
you have{' '}
<WikiLink url="https://www.overleaf.com/learn/Including_images_in_ShareLaTeX">
uploaded the file
</WikiLink>{' '}
and{' '}
<WikiLink url="https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX.">
specified the file location correctly
</WikiLink>
.
</>
),
humanReadableHint:
'The compiler cannot find the file you want to include. Make sure that you have <a href="https://www.overleaf.com/learn/Including_images_in_ShareLaTeX" target="_blank">uploaded the file</a> and <a href="https://www.overleaf.com/learn/Errors/File_XXX_not_found_on_input_line_XXX." target="_blank">specified the file location correctly</a>.',
},
{
ruleId: 'hint_unknown_graphics_extension',
regexToMatch: /LaTeX Error: Unknown graphics extension: \..+/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.XXX',
humanReadableHintComponent: (
<>
The compiler does not recognise the file type of one of your images.
Make sure you are using a{' '}
<WikiLink url="https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif.">
supported image format
</WikiLink>{' '}
for your choice of compiler, and check that there are no periods (.) in
the name of your image.
</>
),
humanReadableHint:
'The compiler does not recognise the file type of one of your images. Make sure you are using a <a href="https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_graphics_extension:_.gif." target="_blank">supported image format</a> for your choice of compiler, and check that there are no periods (.) in the name of your image.',
},
{
ruleId: 'hint_unknown_float_option_h',
regexToMatch: /LaTeX Error: Unknown float option `H'/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60H%27',
humanReadableHintComponent: (
<>
The compiler isn't recognizing the float option 'H'. Include \usepackage
{'{float}'} in your preamble to fix this.
</>
),
humanReadableHint:
'The compiler isn&#x27;t recognizing the float option &#x27;H&#x27;. Include \\usepackage{float} in your preamble to fix this.',
},
{
ruleId: 'hint_unknown_float_option_q',
regexToMatch: /LaTeX Error: Unknown float option `q'/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Unknown_float_option_%60q%27',
humanReadableHintComponent: (
<>
You have used a float specifier which the compiler does not understand.
You can learn more about the different float options available for
placing figures{' '}
<WikiLink url="https://www.overleaf.com/learn/Positioning_of_Figures">
here
</WikiLink>{' '}
.
</>
),
humanReadableHint:
'You have used a float specifier which the compiler does not understand. You can learn more about the different float options available for placing figures <a href="https://www.overleaf.com/learn/Positioning_of_Figures" target="_blank">here</a> .',
},
{
ruleId: 'hint_math_allowed_only_in_math_mode',
regexToMatch: /LaTeX Error: \\math.+ allowed only in math mode/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cmathrm_allowed_only_in_math_mode',
humanReadableHintComponent: (
<>
You have used a font command which is only available in math mode. To
use this command, you must be in maths mode (E.g. $ $ or \begin
{'{math}'} \end
{'{math}'}
). If you want to use it outside of math mode, use the text version
instead: \textrm, \textit, etc.
</>
),
humanReadableHint:
'You have used a font command which is only available in math mode. To use this command, you must be in maths mode (E.g. $ … $ or \\begin{math} … \\end{math}). If you want to use it outside of math mode, use the text version instead: \\textrm, \\textit, etc.',
},
{
ruleId: 'hint_mismatched_environment',
types: ['environment'],
regexToMatch: /Error: `([^']{2,})' expected, found `([^']{2,})'.*/,
newMessage: 'Error: environment does not match \\begin{$1} ... \\end{$2}',
humanReadableHintComponent: (
<>
You have used \begin
{'{...}'} without a corresponding \end
{'{...}'}.
</>
),
humanReadableHint:
'You have used \\begin{...} without a corresponding \\end{...}.',
},
{
ruleId: 'hint_mismatched_brackets',
types: ['environment'],
regexToMatch: /Error: `([^a-zA-Z0-9])' expected, found `([^a-zA-Z0-9])'.*/,
newMessage: "Error: brackets do not match, found '$2' instead of '$1'",
humanReadableHintComponent: (
<>You have used an open bracket without a corresponding close bracket.</>
),
humanReadableHint:
'You have used an open bracket without a corresponding close bracket.',
},
{
ruleId: 'hint_can_be_used_only_in_preamble',
regexToMatch: /LaTeX Error: Can be used only in preamble/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Can_be_used_only_in_preamble',
humanReadableHintComponent: (
<>
You have used a command in the main body of your document which should
be used in the preamble. Make sure that \documentclass[]
{'{…}'} and all \usepackage
{'{…}'} commands are written before \begin
{'{document}'}.
</>
),
humanReadableHint:
'You have used a command in the main body of your document which should be used in the preamble. Make sure that \\documentclass[…]{…} and all \\usepackage{…} commands are written before \\begin{document}.',
},
{
ruleId: 'hint_missing_right_inserted',
regexToMatch: /Missing \\right inserted/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/Missing_%5Cright_insertede',
humanReadableHintComponent: (
<>
You have started an expression with a \left command, but have not
included a corresponding \right command. Make sure that your \left and
\right commands balance everywhere, or else try using \Biggl and \Biggr
commands instead as shown{' '}
<WikiLink url="https://www.overleaf.com/learn/Errors/Missing_%5Cright_inserted">
here
</WikiLink>
.
</>
),
humanReadableHint:
'You have started an expression with a \\left command, but have not included a corresponding \\right command. Make sure that your \\left and \\right commands balance everywhere, or else try using \\Biggl and \\Biggr commands instead as shown <a href="https://www.overleaf.com/learn/Errors/Missing_%5Cright_inserted" target="_blank">here</a>.',
},
{
ruleId: 'hint_double_superscript',
regexToMatch: /Double superscript/,
extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_superscript',
humanReadableHintComponent: (
<>
You have written a double superscript incorrectly as a^b^c, or else you
have written a prime with a superscript. Remember to include {'{and}'}{' '}
when using multiple superscripts. Try a^
{'{b ^ c}'} instead.
</>
),
humanReadableHint:
'You have written a double superscript incorrectly as a^b^c, or else you have written a prime with a superscript. Remember to include {and} when using multiple superscripts. Try a^{b ^ c} instead.',
},
{
ruleId: 'hint_double_subscript',
regexToMatch: /Double subscript/,
extraInfoURL: 'https://www.overleaf.com/learn/Errors/Double_subscript',
humanReadableHintComponent: (
<>
You have written a double subscript incorrectly as a_b_c. Remember to
include {'{and}'} when using multiple subscripts. Try a_
{'{b_c}'} instead.
</>
),
humanReadableHint:
'You have written a double subscript incorrectly as a_b_c. Remember to include {and} when using multiple subscripts. Try a_{b_c} instead.',
},
{
ruleId: 'hint_no_author_given',
regexToMatch: /No \\author given/,
extraInfoURL: 'https://www.overleaf.com/learn/Errors/No_%5Cauthor_given',
humanReadableHintComponent: (
<>
You have used the \maketitle command, but have not specified any
\author. To fix this, include an author in your preamble using the
\author
{'{…}'} command.
</>
),
humanReadableHint:
'You have used the \\maketitle command, but have not specified any \\author. To fix this, include an author in your preamble using the \\author{…} command.',
},
{
ruleId: 'hint_environment_undefined',
regexToMatch: /LaTeX Error: Environment .+ undefined/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Environment%20XXX%20undefined',
humanReadableHintComponent: (
<>
You have created an environment (using \begin
{'{…}'} and \end
{'{…}'} commands) which is not recognized. Make sure you have included
the required package for that environment in your preamble, and that the
environment is spelled correctly.
</>
),
humanReadableHint:
'You have created an environment (using \\begin{…} and \\end{…} commands) which is not recognized. Make sure you have included the required package for that environment in your preamble, and that the environment is spelled correctly.',
},
{
ruleId: 'hint_somethings_wrong_perhaps_a_missing_item',
regexToMatch: /LaTeX Error: Something's wrong--perhaps a missing \\item/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_Something%27s_wrong--perhaps_a_missing_%5Citem',
humanReadableHintComponent: (
<>
There are no entries found in a list you have created. Make sure you
label list entries using the \item command, and that you have not used a
list inside a table.
</>
),
humanReadableHint:
'There are no entries found in a list you have created. Make sure you label list entries using the \\item command, and that you have not used a list inside a table.',
},
{
ruleId: 'hint_misplaced_noalign',
regexToMatch: /Misplaced \\noalign/,
extraInfoURL: 'https://www.overleaf.com/learn/Errors/Misplaced_%5Cnoalign',
humanReadableHintComponent: (
<>
You have used a \hline command in the wrong place, probably outside a
table. If the \hline command is written inside a table, try including \\
before it.
</>
),
humanReadableHint:
'You have used a \\hline command in the wrong place, probably outside a table. If the \\hline command is written inside a table, try including \\\\ before it.',
},
{
ruleId: 'hint_no_line_here_to_end',
regexToMatch: /LaTeX Error: There's no line here to end/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_There%27s_no_line_here_to_end',
humanReadableHintComponent: (
<>
You have used a \\ or \newline command where LaTeX was not expecting
one. Make sure that you only use line breaks after blocks of text, and
be careful using linebreaks inside lists and other environments.\
</>
),
humanReadableHint:
'You have used a \\\\ or \\newline command where LaTeX was not expecting one. Make sure that you only use line breaks after blocks of text, and be careful using linebreaks inside lists and other environments.\\',
},
{
ruleId: 'hint_verb_ended_by_end_of_line',
regexToMatch: /LaTeX Error: \\verb ended by end of line/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors/LaTeX_Error:_%5Cverb_ended_by_end_of_line',
humanReadableHintComponent: (
<>
You have used a \verb command incorrectly. Try replacling the \verb
command with \begin
{'{verbatim}'}
\end
{'{verbatim}'}
.\
</>
),
humanReadableHint:
'You have used a \\verb command incorrectly. Try replacling the \\verb command with \\begin{verbatim}…\\end{verbatim}.\\',
},
{
ruleId: 'hint_illegal_unit_of_measure_pt_inserted',
regexToMatch: /Illegal unit of measure (pt inserted)/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors%2FIllegal%20unit%20of%20measure%20(pt%20inserted)',
humanReadableHintComponent: (
<>
You have written a length, but have not specified the appropriate units
(pt, mm, cm etc.). If you have not written a length, check that you have
not witten a linebreak \\ followed by square brackets [] anywhere.
</>
),
humanReadableHint:
'You have written a length, but have not specified the appropriate units (pt, mm, cm etc.). If you have not written a length, check that you have not witten a linebreak \\\\ followed by square brackets […] anywhere.',
},
{
ruleId: 'hint_extra_right',
regexToMatch: /Extra \\right/,
extraInfoURL: 'https://www.overleaf.com/learn/Errors/Extra_%5Cright',
humanReadableHintComponent: (
<>
You have written a \right command without a corresponding \left command.
Check that all \left and \right commands balance everywhere.
</>
),
humanReadableHint:
'You have written a \\right command without a corresponding \\left command. Check that all \\left and \\right commands balance everywhere.',
},
{
ruleId: 'hint_missing_begin_document_',
regexToMatch: /Missing \\begin{document}/,
extraInfoURL:
'https://www.overleaf.com/learn/Errors%2FLaTeX%20Error%3A%20Missing%20%5Cbegin%20document',
humanReadableHintComponent: (
<>
No \begin
{'{document}'} command was found. Make sure you have included \begin
{'{document}'} in your preamble, and that your main document is set
correctly.
</>
),
humanReadableHint:
'No \\begin{document} command was found. Make sure you have included \\begin{document} in your preamble, and that your main document is set correctly.',
},
{
ruleId: 'hint_mismatched_environment2',
@ -518,15 +136,6 @@ const rules = [
regexToMatch:
/Error: `\\end\{([^\}]+)\}' expected but found `\\end\{([^\}]+)\}'.*/,
newMessage: 'Error: environments do not match: \\begin{$1} ... \\end{$2}',
humanReadableHintComponent: (
<>
You have used \begin
{'{}'} without a corresponding \end
{'{}'}.
</>
),
humanReadableHint:
'You have used \\begin{} without a corresponding \\end{}.',
},
{
ruleId: 'hint_mismatched_environment3',
@ -535,15 +144,6 @@ const rules = [
regexToMatch:
/Warning: No matching \\end found for `\\begin\{([^\}]+)\}'.*/,
newMessage: 'Warning: No matching \\end found for \\begin{$1}',
humanReadableHintComponent: (
<>
You have used \begin
{'{}'} without a corresponding \end
{'{}'}.
</>
),
humanReadableHint:
'You have used \\begin{} without a corresponding \\end{}.',
},
{
ruleId: 'hint_mismatched_environment4',
@ -552,29 +152,7 @@ const rules = [
regexToMatch:
/Error: Found `\\end\{([^\}]+)\}' without corresponding \\begin.*/,
newMessage: 'Error: found \\end{$1} without a corresponding \\begin{$1}',
humanReadableHintComponent: (
<>
You have used \begin
{'{}'} without a corresponding \end
{'{}'}.
</>
),
humanReadableHint:
'You have used \\begin{} without a corresponding \\end{}.',
},
]
if (!window.wikiEnabled) {
rules.forEach(rule => {
rule.extraInfoURL = null
rule.humanReadableHint = stripHTMLFromString(rule.humanReadableHint)
})
}
function stripHTMLFromString(htmlStr) {
const tmp = document.createElement('DIV')
tmp.innerHTML = htmlStr
return tmp.textContent || tmp.innerText || ''
}
export default rules

View file

@ -0,0 +1,386 @@
import { createContext, useContext, useMemo } from 'react'
import PropTypes from 'prop-types'
import {
useLocalCompileContext,
CompileContextPropTypes,
} from './local-compile-context'
import useDetachStateWatcher from '../hooks/use-detach-state-watcher'
import useDetachAction from '../hooks/use-detach-action'
export const DetachCompileContext = createContext()
DetachCompileContext.Provider.propTypes = CompileContextPropTypes
export function DetachCompileProvider({ children }) {
const localCompileContext = useLocalCompileContext()
if (!localCompileContext) {
throw new Error(
'DetachCompileProvider is only available inside LocalCompileProvider'
)
}
const {
autoCompile: _autoCompile,
clearingCache: _clearingCache,
clsiServerId: _clsiServerId,
codeCheckFailed: _codeCheckFailed,
compiling: _compiling,
draft: _draft,
error: _error,
fileList: _fileList,
hasChanges: _hasChanges,
highlights: _highlights,
logEntries: _logEntries,
logEntryAnnotations: _logEntryAnnotations,
pdfDownloadUrl: _pdfDownloadUrl,
pdfUrl: _pdfUrl,
pdfViewer: _pdfViewer,
position: _position,
rawLog: _rawLog,
setAutoCompile: _setAutoCompile,
setDraft: _setDraft,
setError: _setError,
setHasLintingError: _setHasLintingError,
setHighlights: _setHighlights,
setPosition: _setPosition,
setShowLogs: _setShowLogs,
toggleLogs: _toggleLogs,
setStopOnValidationError: _setStopOnValidationError,
showLogs: _showLogs,
stopOnValidationError: _stopOnValidationError,
uncompiled: _uncompiled,
validationIssues: _validationIssues,
firstRenderDone: _firstRenderDone,
cleanupCompileResult: _cleanupCompileResult,
recompileFromScratch: _recompileFromScratch,
setCompiling: _setCompiling,
startCompile: _startCompile,
stopCompile: _stopCompile,
setChangedAt: _setChangedAt,
clearCache: _clearCache,
} = localCompileContext
const [autoCompile] = useDetachStateWatcher(
'autoCompile',
_autoCompile,
'detacher',
'detached'
)
const [clearingCache] = useDetachStateWatcher(
'clearingCache',
_clearingCache,
'detacher',
'detached'
)
const [clsiServerId] = useDetachStateWatcher(
'clsiServerId',
_clsiServerId,
'detacher',
'detached'
)
const [codeCheckFailed] = useDetachStateWatcher(
'codeCheckFailed',
_codeCheckFailed,
'detacher',
'detached'
)
const [compiling] = useDetachStateWatcher(
'compiling',
_compiling,
'detacher',
'detached'
)
const [draft] = useDetachStateWatcher('draft', _draft, 'detacher', 'detached')
const [error] = useDetachStateWatcher('error', _error, 'detacher', 'detached')
const [fileList] = useDetachStateWatcher(
'fileList',
_fileList,
'detacher',
'detached'
)
const [hasChanges] = useDetachStateWatcher(
'hasChanges',
_hasChanges,
'detacher',
'detached'
)
const [highlights] = useDetachStateWatcher(
'highlights',
_highlights,
'detacher',
'detached'
)
const [logEntries] = useDetachStateWatcher(
'logEntries',
_logEntries,
'detacher',
'detached'
)
const [logEntryAnnotations] = useDetachStateWatcher(
'logEntryAnnotations',
_logEntryAnnotations,
'detacher',
'detached'
)
const [pdfDownloadUrl] = useDetachStateWatcher(
'pdfDownloadUrl',
_pdfDownloadUrl,
'detacher',
'detached'
)
const [pdfUrl] = useDetachStateWatcher(
'pdfUrl',
_pdfUrl,
'detacher',
'detached'
)
const [pdfViewer] = useDetachStateWatcher(
'pdfViewer',
_pdfViewer,
'detacher',
'detached'
)
const [position] = useDetachStateWatcher(
'position',
_position,
'detacher',
'detached'
)
const [rawLog] = useDetachStateWatcher(
'rawLog',
_rawLog,
'detacher',
'detached'
)
const [showLogs] = useDetachStateWatcher(
'showLogs',
_showLogs,
'detacher',
'detached'
)
const [stopOnValidationError] = useDetachStateWatcher(
'stopOnValidationError',
_stopOnValidationError,
'detacher',
'detached'
)
const [uncompiled] = useDetachStateWatcher(
'uncompiled',
_uncompiled,
'detacher',
'detached'
)
const [validationIssues] = useDetachStateWatcher(
'validationIssues',
_validationIssues,
'detacher',
'detached'
)
const setAutoCompile = useDetachAction(
'setAutoCompile',
_setAutoCompile,
'detached',
'detacher'
)
const setDraft = useDetachAction(
'setDraft',
_setDraft,
'detached',
'detacher'
)
const setError = useDetachAction(
'setError',
_setError,
'detacher',
'detached'
)
const setPosition = useDetachAction(
'setPosition',
_setPosition,
'detached',
'detacher'
)
const firstRenderDone = useDetachAction(
'firstRenderDone',
_firstRenderDone,
'detacher',
'detached'
)
const setHasLintingError = useDetachAction(
'setHasLintingError',
_setHasLintingError,
'detacher',
'detached'
)
const setHighlights = useDetachAction(
'setHighlights',
_setHighlights,
'detacher',
'detached'
)
const setShowLogs = useDetachAction(
'setShowLogs',
_setShowLogs,
'detached',
'detacher'
)
const toggleLogs = useDetachAction(
'toggleLogs',
_toggleLogs,
'detached',
'detacher'
)
const setStopOnValidationError = useDetachAction(
'setStopOnValidationError',
_setStopOnValidationError,
'detached',
'detacher'
)
const cleanupCompileResult = useDetachAction(
'cleanupCompileResult',
_cleanupCompileResult,
'detached',
'detacher'
)
const recompileFromScratch = useDetachAction(
'recompileFromScratch',
_recompileFromScratch,
'detached',
'detacher'
)
const setCompiling = useDetachAction(
'setCompiling',
_setCompiling,
'detacher',
'detached'
)
const startCompile = useDetachAction(
'startCompile',
_startCompile,
'detached',
'detacher'
)
const stopCompile = useDetachAction(
'stopCompile',
_stopCompile,
'detached',
'detacher'
)
const setChangedAt = useDetachAction(
'setChangedAt',
_setChangedAt,
'detached',
'detacher'
)
const clearCache = useDetachAction(
'clearCache',
_clearCache,
'detached',
'detacher'
)
const value = useMemo(
() => ({
autoCompile,
clearCache,
clearingCache,
clsiServerId,
codeCheckFailed,
compiling,
draft,
error,
fileList,
hasChanges,
highlights,
logEntryAnnotations,
logEntries,
pdfDownloadUrl,
pdfUrl,
pdfViewer,
position,
rawLog,
recompileFromScratch,
setAutoCompile,
setCompiling,
setDraft,
setError,
setHasLintingError,
setHighlights,
setPosition,
setShowLogs,
toggleLogs,
setStopOnValidationError,
showLogs,
startCompile,
stopCompile,
stopOnValidationError,
uncompiled,
validationIssues,
firstRenderDone,
setChangedAt,
cleanupCompileResult,
}),
[
autoCompile,
clearCache,
clearingCache,
clsiServerId,
codeCheckFailed,
compiling,
draft,
error,
fileList,
hasChanges,
highlights,
logEntryAnnotations,
logEntries,
pdfDownloadUrl,
pdfUrl,
pdfViewer,
position,
rawLog,
recompileFromScratch,
setAutoCompile,
setCompiling,
setDraft,
setError,
setHasLintingError,
setHighlights,
setPosition,
setShowLogs,
toggleLogs,
setStopOnValidationError,
showLogs,
startCompile,
stopCompile,
stopOnValidationError,
uncompiled,
validationIssues,
firstRenderDone,
setChangedAt,
cleanupCompileResult,
]
)
return (
<DetachCompileContext.Provider value={value}>
{children}
</DetachCompileContext.Provider>
)
}
DetachCompileProvider.propTypes = {
children: PropTypes.any,
}
export function useDetachCompileContext(propTypes) {
const data = useContext(DetachCompileContext)
PropTypes.checkPropTypes(
propTypes,
data,
'data',
'DetachCompileContext.Provider'
)
return data
}

View file

@ -29,6 +29,7 @@ const debugPdfDetach = getMeta('ol-debugPdfDetach')
const SYSEND_CHANNEL = `detach-${getMeta('ol-project_id')}`
export function DetachProvider({ children }) {
const [lastDetachedConnectedAt, setLastDetachedConnectedAt] = useState()
const [role, setRole] = useState(() => getMeta('ol-detachRole') || null)
const {
addHandler: addEventHandler,
@ -94,15 +95,33 @@ export function DetachProvider({ children }) {
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [broadcastEvent])
useEffect(() => {
const updateLastDetachedConnectedAt = message => {
if (message.role === 'detached' && message.event === 'connected') {
setLastDetachedConnectedAt(new Date())
}
}
addEventHandler(updateLastDetachedConnectedAt)
return () => deleteEventHandler(updateLastDetachedConnectedAt)
}, [addEventHandler, deleteEventHandler])
const value = useMemo(
() => ({
role,
setRole,
broadcastEvent,
lastDetachedConnectedAt,
addEventHandler,
deleteEventHandler,
}),
[role, setRole, broadcastEvent, addEventHandler, deleteEventHandler]
[
role,
setRole,
broadcastEvent,
lastDetachedConnectedAt,
addEventHandler,
deleteEventHandler,
]
)
return (

View file

@ -155,7 +155,7 @@ export function EditorProvider({ children, settings }) {
EditorProvider.propTypes = {
children: PropTypes.any,
settings: PropTypes.any.isRequired,
settings: PropTypes.object,
}
export function useEditorContext(propTypes) {

View file

@ -1,5 +1,6 @@
import { createContext, useContext } from 'react'
import { createContext, useContext, useState } from 'react'
import PropTypes from 'prop-types'
import { getMockIde } from './mock/mock-ide'
const IdeContext = createContext()
@ -20,11 +21,13 @@ export function useIdeContext() {
}
export function IdeProvider({ ide, children }) {
return <IdeContext.Provider value={ide}>{children}</IdeContext.Provider>
const [value] = useState(() => ide || getMockIde())
return <IdeContext.Provider value={value}>{children}</IdeContext.Provider>
}
IdeProvider.propTypes = {
children: PropTypes.any.isRequired,
ide: PropTypes.shape({
$scope: PropTypes.object.isRequired,
}).isRequired,
}),
}

View file

@ -108,11 +108,13 @@ export function LayoutProvider({ children }) {
isLinking: detachIsLinking,
isLinked: detachIsLinked,
role: detachRole,
isRedundant: detachIsRedundant,
} = useDetachLayout()
useEffect(() => {
if (debugPdfDetach) {
console.log('Layout Effect', {
detachIsRedundant,
detachRole,
detachIsLinking,
detachIsLinked,
@ -121,12 +123,23 @@ export function LayoutProvider({ children }) {
if (detachRole !== 'detacher') return // not in a PDF detacher layout
if (detachIsRedundant) {
changeLayout('sideBySide')
return
}
if (detachIsLinking || detachIsLinked) {
// the tab is linked to a detached tab (or about to be linked); show
// editor only
changeLayout('flat', 'editor')
}
}, [detachRole, detachIsLinking, detachIsLinked, changeLayout])
}, [
detachIsRedundant,
detachRole,
detachIsLinking,
detachIsLinked,
changeLayout,
])
const value = useMemo(
() => ({

View file

@ -13,7 +13,11 @@ import useScopeValueSetterOnly from '../hooks/use-scope-value-setter-only'
import usePersistedState from '../hooks/use-persisted-state'
import useAbortController from '../hooks/use-abort-controller'
import DocumentCompiler from '../../features/pdf-preview/util/compiler'
import { send, sendMBSampled } from '../../infrastructure/event-tracking'
import {
send,
sendMBOnce,
sendMBSampled,
} from '../../infrastructure/event-tracking'
import {
buildLogEntryAnnotations,
handleLogFiles,
@ -24,9 +28,9 @@ import { useProjectContext } from './project-context'
import { useEditorContext } from './editor-context'
import { buildFileList } from '../../features/pdf-preview/util/file-list'
export const CompileContext = createContext()
export const LocalCompileContext = createContext()
CompileContext.Provider.propTypes = {
export const CompileContextPropTypes = {
value: PropTypes.shape({
autoCompile: PropTypes.bool.isRequired,
clearingCache: PropTypes.bool.isRequired,
@ -52,6 +56,7 @@ CompileContext.Provider.propTypes = {
setHighlights: PropTypes.func.isRequired,
setPosition: PropTypes.func.isRequired,
setShowLogs: PropTypes.func.isRequired,
toggleLogs: PropTypes.func.isRequired,
setStopOnValidationError: PropTypes.func.isRequired,
showLogs: PropTypes.bool.isRequired,
stopOnValidationError: PropTypes.bool.isRequired,
@ -62,7 +67,9 @@ CompileContext.Provider.propTypes = {
}),
}
export function CompileProvider({ children }) {
LocalCompileContext.Provider.propTypes = CompileContextPropTypes
export function LocalCompileProvider({ children }) {
const ide = useIdeContext()
const { hasPremiumCompile, isProjectOwner } = useEditorContext()
@ -111,6 +118,15 @@ export function CompileProvider({ children }) {
// whether the logs should be visible
const [showLogs, setShowLogs] = useState(false)
const toggleLogs = useCallback(() => {
setShowLogs(prev => {
if (!prev) {
sendMBOnce('ide-open-logs-once')
}
return !prev
})
}, [setShowLogs])
// an error that occurred
const [error, setError] = useState()
@ -445,6 +461,7 @@ export function CompileProvider({ children }) {
setHighlights,
setPosition,
setShowLogs,
toggleLogs,
setStopOnValidationError,
showLogs,
startCompile,
@ -492,20 +509,29 @@ export function CompileProvider({ children }) {
firstRenderDone,
setChangedAt,
cleanupCompileResult,
setShowLogs,
toggleLogs,
]
)
return (
<CompileContext.Provider value={value}>{children}</CompileContext.Provider>
<LocalCompileContext.Provider value={value}>
{children}
</LocalCompileContext.Provider>
)
}
CompileProvider.propTypes = {
LocalCompileProvider.propTypes = {
children: PropTypes.any,
}
export function useCompileContext(propTypes) {
const data = useContext(CompileContext)
PropTypes.checkPropTypes(propTypes, data, 'data', 'CompileContext.Provider')
export function useLocalCompileContext(propTypes) {
const data = useContext(LocalCompileContext)
PropTypes.checkPropTypes(
propTypes,
data,
'data',
'LocalCompileContext.Provider'
)
return data
}

View file

@ -0,0 +1,65 @@
import getMeta from '../../../utils/meta'
// When rendered without Angular, ide isn't defined. In that case we use
// a mock object that only has the required properties to pass proptypes
// checks and the values needed for the app. In the longer term, the mock
// object will replace ide completely.
export const getMockIde = () => {
return {
_id: getMeta('ol-project_id'),
$scope: {
$on: () => {},
$watch: () => {},
$applyAsync: () => {},
user: {},
project: {
_id: getMeta('ol-project_id'),
name: getMeta('ol-projectName'),
rootDocId: '',
members: [],
invites: [],
features: {
collaborators: 0,
compileGroup: 'standard',
trackChangesVisible: false,
references: false,
mendeley: false,
zotero: false,
},
publicAccessLevel: '',
tokens: {
readOnly: '',
readAndWrite: '',
},
owner: {
_id: '',
email: '',
},
},
state: { loading: false },
permissionsLevel: 'readOnly',
editor: {
sharejs_doc: null,
showSymbolPalette: false,
toggleSymbolPalette: () => {},
},
ui: {
view: 'pdf',
chatOpen: false,
reviewPanelOpen: false,
leftMenuShown: false,
pdfLayout: 'flat',
},
pdf: {
uncompiled: true,
logEntryAnnotations: {},
},
settings: { syntaxValidation: false, pdfViewer: 'pdfjs' },
hasLintingError: false,
},
editorManager: {
openDoc: () => {},
getCurrentDocId: () => {},
},
}
}

View file

@ -4,7 +4,8 @@ import createSharedContext from 'react2angular-shared-context'
import { UserProvider } from './user-context'
import { IdeProvider } from './ide-context'
import { EditorProvider } from './editor-context'
import { CompileProvider } from './compile-context'
import { LocalCompileProvider } from './local-compile-context'
import { DetachCompileProvider } from './detach-compile-context'
import { LayoutProvider } from './layout-context'
import { DetachProvider } from './detach-context'
import { ChatProvider } from '../../features/chat/context/chat-context'
@ -22,9 +23,11 @@ export function ContextRoot({ children, ide, settings }) {
<EditorProvider settings={settings}>
<DetachProvider>
<LayoutProvider>
<CompileProvider>
<ChatProvider>{children}</ChatProvider>
</CompileProvider>
<LocalCompileProvider>
<DetachCompileProvider>
<ChatProvider>{children}</ChatProvider>
</DetachCompileProvider>
</LocalCompileProvider>
</LayoutProvider>
</DetachProvider>
</EditorProvider>
@ -38,8 +41,8 @@ export function ContextRoot({ children, ide, settings }) {
ContextRoot.propTypes = {
children: PropTypes.any,
ide: PropTypes.any.isRequired,
settings: PropTypes.any.isRequired,
ide: PropTypes.object,
settings: PropTypes.object,
}
export const rootContext = createSharedContext(ContextRoot)

View file

@ -1,6 +1,6 @@
import { createContext, useContext } from 'react'
import PropTypes from 'prop-types'
import useScopeValue from '../hooks/use-scope-value'
import getMeta from '../../utils/meta'
export const UserContext = createContext()
@ -18,7 +18,7 @@ UserContext.Provider.propTypes = {
}
export function UserProvider({ children }) {
const [user] = useScopeValue('user', true)
const user = getMeta('ol-user')
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
}

View file

@ -1,33 +1,21 @@
import { useCallback, useState } from 'react'
import { useCallback, useRef } from 'react'
export default function useCallbackHandlers() {
const [handlers, setHandlers] = useState(new Set())
const handlersRef = useRef(new Set())
const addHandler = useCallback(
handler => {
setHandlers(prev => new Set(prev.add(handler)))
},
[setHandlers]
)
const addHandler = useCallback(handler => {
handlersRef.current.add(handler)
}, [])
const deleteHandler = useCallback(
handler => {
setHandlers(prev => {
prev.delete(handler)
return new Set(prev)
})
},
[setHandlers]
)
const deleteHandler = useCallback(handler => {
handlersRef.current.delete(handler)
}, [])
const callHandlers = useCallback(
(...args) => {
for (const handler of handlers) {
handler(...args)
}
},
[handlers]
)
const callHandlers = useCallback((...args) => {
for (const handler of handlersRef.current) {
handler(...args)
}
}, [])
return { addHandler, deleteHandler, callHandlers }
}

View file

@ -20,6 +20,10 @@ export default function useDetachLayout() {
// isLinked: when the tab is linked to another tab (of different role)
const [isLinked, setIsLinked] = useState(false)
// isRedundant: when a second detacher tab is opened, the first becomes
// redundant
const [isRedundant, setIsRedundant] = useState(false)
const uiTimeoutRef = useRef()
useEffect(() => {
@ -76,12 +80,24 @@ export default function useDetachLayout() {
}, [setRole, setIsLinked, broadcastEvent])
const detach = useCallback(() => {
setIsRedundant(false)
setRole('detacher')
setIsLinking(true)
window.open(buildUrlWithDetachRole('detached').toString(), '_blank')
}, [setRole, setIsLinking])
const handleEventForDetacherFromDetacher = useCallback(() => {
if (debugPdfDetach) {
console.log(
'Duplicate detacher detected, turning into a regular editor again'
)
}
setIsRedundant(true)
setIsLinked(false)
setRole(null)
}, [setRole, setIsLinked])
const handleEventForDetacherFromDetached = useCallback(
message => {
switch (message.event) {
@ -122,7 +138,7 @@ export default function useDetachLayout() {
[setIsLinked, broadcastEvent]
)
const handleEventFromSelf = useCallback(
const handleEventForDetachedFromDetached = useCallback(
message => {
switch (message.event) {
case 'closed':
@ -137,7 +153,7 @@ export default function useDetachLayout() {
message => {
if (role === 'detacher') {
if (message.role === 'detacher') {
handleEventFromSelf(message)
handleEventForDetacherFromDetacher(message)
} else if (message.role === 'detached') {
handleEventForDetacherFromDetached(message)
}
@ -145,15 +161,16 @@ export default function useDetachLayout() {
if (message.role === 'detacher') {
handleEventForDetachedFromDetacher(message)
} else if (message.role === 'detached') {
handleEventFromSelf(message)
handleEventForDetachedFromDetached(message)
}
}
},
[
role,
handleEventForDetacherFromDetacher,
handleEventForDetacherFromDetached,
handleEventForDetachedFromDetacher,
handleEventFromSelf,
handleEventForDetachedFromDetached,
]
)
@ -168,5 +185,6 @@ export default function useDetachLayout() {
isLinked,
isLinking,
role,
isRedundant,
}
}

View file

@ -0,0 +1,22 @@
import { useEffect } from 'react'
import useDetachState from './use-detach-state'
export default function useDetachStateWatcher(
key,
stateValue,
senderRole,
targetRole
) {
const [value, setValue] = useDetachState(
key,
stateValue,
senderRole,
targetRole
)
useEffect(() => {
setValue(stateValue)
}, [setValue, stateValue])
return [value, setValue]
}

View file

@ -12,16 +12,30 @@ export default function useDetachState(
) {
const [value, setValue] = useState(defaultValue)
const { role, broadcastEvent, addEventHandler, deleteEventHandler } =
useDetachContext()
const {
role,
broadcastEvent,
lastDetachedConnectedAt,
addEventHandler,
deleteEventHandler,
} = useDetachContext()
const eventName = `state-${key}`
// lastDetachedConnectedAt is added as a dependency in order to re-broadcast
// all states when a new detached tab connects
useEffect(() => {
if (role === senderRole) {
broadcastEvent(eventName, { value })
}
}, [role, senderRole, eventName, value, broadcastEvent])
}, [
role,
senderRole,
eventName,
value,
broadcastEvent,
lastDetachedConnectedAt,
])
const handleStateEvent = useCallback(
message => {

View file

@ -37,7 +37,7 @@ function usePersistedState(key, defaultValue, listen = false) {
if (event.key === key) {
// note: this value is read via getItem rather than from event.newValue
// because getItem handles deserializing the JSON that's stored in localStorage.
setValue(localStorage.getItem(key))
setValue(localStorage.getItem(key) ?? defaultValue)
}
}
@ -47,7 +47,7 @@ function usePersistedState(key, defaultValue, listen = false) {
window.removeEventListener('storage', listener)
}
}
}, [key, listen])
}, [key, listen, defaultValue])
return [value, updateFunction]
}

View file

@ -10,7 +10,7 @@ import { buildFileList } from '../js/features/pdf-preview/util/file-list'
import PdfLogsViewer from '../js/features/pdf-preview/components/pdf-logs-viewer'
import PdfPreviewError from '../js/features/pdf-preview/components/pdf-preview-error'
import PdfPreviewHybridToolbar from '../js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
import { useCompileContext } from '../js/shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../js/shared/context/detach-compile-context'
import {
dispatchDocChanged,
mockBuildFile,

View file

@ -1580,6 +1580,7 @@
"project_layout_sharing_submission": "Project Layout, Sharing, and Submission",
"pdf_in_separate_tab": "PDF in separate tab",
"tab_no_longer_connected": "This tab is no longer connected with the editor",
"tab_connecting": "Connecting with the editor",
"redirect_to_editor": "Redirect to editor",
"layout_processing": "Layout processing",
"show_in_code": "Show in code",

View file

@ -15,24 +15,27 @@ import { stubMathJax, tearDownMathJaxStubs } from './stubs'
import sinon from 'sinon'
describe('<ChatPane />', function () {
const user = {
id: 'fake_user',
first_name: 'fake_user_first_name',
email: 'fake@example.com',
}
beforeEach(function () {
this.clock = sinon.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
})
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user', user)
})
afterEach(function () {
this.clock.runAll()
this.clock.restore()
fetchMock.reset()
window.metaAttributesCache = new Map()
})
const user = {
id: 'fake_user',
first_name: 'fake_user_first_name',
email: 'fake@example.com',
}
const testMessages = [
{
id: 'msg_1',

View file

@ -25,10 +25,15 @@ describe('ChatContext', function () {
cleanUpContext()
stubMathJax()
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user', user)
})
afterEach(function () {
tearDownMathJaxStubs()
window.metaAttributesCache = new Map()
})
describe('socket connection', function () {
@ -42,7 +47,7 @@ describe('ChatContext', function () {
it('subscribes when mounted', function () {
const socket = new EventEmitter()
renderChatContextHook({ user, socket })
renderChatContextHook({ socket })
// Assert that there is 1 listener
expect(socket.rawListeners('new-chat-message').length).to.equal(1)
@ -50,7 +55,7 @@ describe('ChatContext', function () {
it('unsubscribes when unmounted', function () {
const socket = new EventEmitter()
const { unmount } = renderChatContextHook({ user, socket })
const { unmount } = renderChatContextHook({ socket })
unmount()
@ -62,7 +67,6 @@ describe('ChatContext', function () {
// Mock socket: we only need to emit events, not mock actual connections
const socket = new EventEmitter()
const { result, waitForNextUpdate } = renderChatContextHook({
user,
socket,
})
@ -93,7 +97,6 @@ describe('ChatContext', function () {
it("doesn't add received messages from the current user if a message was just sent", async function () {
const socket = new EventEmitter()
const { result, waitForNextUpdate } = renderChatContextHook({
user,
socket,
})
@ -123,7 +126,6 @@ describe('ChatContext', function () {
it('adds the new message from the current user if another message was received after sending', async function () {
const socket = new EventEmitter()
const { result, waitForNextUpdate } = renderChatContextHook({
user,
socket,
})
@ -187,7 +189,7 @@ describe('ChatContext', function () {
})
it('adds messages to the list', async function () {
const { result, waitForNextUpdate } = renderChatContextHook({ user })
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadInitialMessages()
await waitForNextUpdate()
@ -196,7 +198,7 @@ describe('ChatContext', function () {
})
it("won't load messages a second time", async function () {
const { result, waitForNextUpdate } = renderChatContextHook({ user })
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadInitialMessages()
await waitForNextUpdate()
@ -211,7 +213,7 @@ describe('ChatContext', function () {
it('provides an error on failure', async function () {
fetchMock.reset()
fetchMock.get('express:/project/:projectId/messages', 500)
const { result, waitForNextUpdate } = renderChatContextHook({ user })
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadInitialMessages()
await waitForNextUpdate()
@ -233,7 +235,7 @@ describe('ChatContext', function () {
},
])
const { result, waitForNextUpdate } = renderChatContextHook({ user })
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadMoreMessages()
await waitForNextUpdate()
@ -267,7 +269,7 @@ describe('ChatContext', function () {
{ overwriteRoutes: false }
)
const { result, waitForNextUpdate } = renderChatContextHook({ user })
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadMoreMessages()
await waitForNextUpdate()
@ -297,7 +299,7 @@ describe('ChatContext', function () {
createMessages(49, user)
)
const { result, waitForNextUpdate } = renderChatContextHook({ user })
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadMoreMessages()
await waitForNextUpdate()
@ -322,7 +324,6 @@ describe('ChatContext', function () {
const socket = new EventEmitter()
const { result, waitForNextUpdate } = renderChatContextHook({
user,
socket,
})
@ -367,7 +368,7 @@ describe('ChatContext', function () {
it('provides an error on failures', async function () {
fetchMock.reset()
fetchMock.get('express:/project/:projectId/messages', 500)
const { result, waitForNextUpdate } = renderChatContextHook({ user })
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.loadMoreMessages()
await waitForNextUpdate()
@ -387,7 +388,7 @@ describe('ChatContext', function () {
})
it('optimistically adds the message to the list', function () {
const { result } = renderChatContextHook({ user })
const { result } = renderChatContextHook({})
result.current.sendMessage('sent message')
@ -397,7 +398,7 @@ describe('ChatContext', function () {
})
it('POSTs the message to the backend', function () {
const { result } = renderChatContextHook({ user })
const { result } = renderChatContextHook({})
result.current.sendMessage('sent message')
@ -409,7 +410,7 @@ describe('ChatContext', function () {
})
it("doesn't send if the content is empty", function () {
const { result } = renderChatContextHook({ user })
const { result } = renderChatContextHook({})
result.current.sendMessage('')
@ -426,7 +427,7 @@ describe('ChatContext', function () {
fetchMock
.get('express:/project/:projectId/messages', [])
.postOnce('express:/project/:projectId/messages', 500)
const { result, waitForNextUpdate } = renderChatContextHook({ user })
const { result, waitForNextUpdate } = renderChatContextHook({})
result.current.sendMessage('sent message')
await waitForNextUpdate()
@ -444,7 +445,7 @@ describe('ChatContext', function () {
it('increments unreadMessageCount when a new message is received', function () {
const socket = new EventEmitter()
const { result } = renderChatContextHook({ user, socket })
const { result } = renderChatContextHook({ socket })
// Receive a new message from the socket
socket.emit('new-chat-message', {
@ -459,7 +460,7 @@ describe('ChatContext', function () {
it('resets unreadMessageCount when markMessagesAsRead is called', function () {
const socket = new EventEmitter()
const { result } = renderChatContextHook({ user, socket })
const { result } = renderChatContextHook({ socket })
// Receive a new message from the socket, incrementing unreadMessageCount
// by 1

View file

@ -18,7 +18,6 @@ describe('<LayoutDropdownButton />', function () {
beforeEach(function () {
openStub = sinon.stub(window, 'open')
window.metaAttributesCache = new Map()
fetchMock.post('express:/project/:projectId/compile/stop', () => 204)
})
afterEach(function () {
@ -101,11 +100,6 @@ describe('<LayoutDropdownButton />', function () {
screen.getByText('Layout processing')
})
it('should stop compile when detaching', function () {
expect(fetchMock.called('express:/project/:projectId/compile/stop')).to.be
.true
})
it('should record event', function () {
sinon.assert.calledWith(eventTrackingSpy.sendMB, 'project-layout-detach')
})

View file

@ -15,6 +15,8 @@ describe('<FileTreeRoot/>', function () {
beforeEach(function () {
global.requestAnimationFrame = sinon.stub()
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user', { id: 'user1' })
})
afterEach(function () {
@ -24,6 +26,7 @@ describe('<FileTreeRoot/>', function () {
onInit.reset()
cleanUpContext()
global.localStorage.clear()
window.metaAttributesCache = new Map()
})
it('renders', function () {

View file

@ -12,10 +12,16 @@ describe('FileTree Context Menu Flow', function () {
const onSelect = sinon.stub()
const onInit = sinon.stub()
beforeEach(function () {
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user', { id: 'user1' })
})
afterEach(function () {
onSelect.reset()
onInit.reset()
cleanUpContext()
window.metaAttributesCache = new Map()
})
it('opens on contextMenu event', async function () {

View file

@ -16,6 +16,8 @@ describe('FileTree Create Folder Flow', function () {
beforeEach(function () {
global.requestAnimationFrame = sinon.stub()
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user', { id: 'user1' })
})
afterEach(function () {
@ -24,6 +26,7 @@ describe('FileTree Create Folder Flow', function () {
onSelect.reset()
onInit.reset()
cleanUpContext()
window.metaAttributesCache = new Map()
})
it('add to root when no files are selected', async function () {

View file

@ -14,11 +14,17 @@ describe('FileTree Delete Entity Flow', function () {
const onSelect = sinon.stub()
const onInit = sinon.stub()
beforeEach(function () {
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user', { id: 'user1' })
})
afterEach(function () {
fetchMock.restore()
onSelect.reset()
onInit.reset()
cleanUpContext()
window.metaAttributesCache = new Map()
})
describe('single entity', function () {

View file

@ -16,6 +16,8 @@ describe('FileTree Rename Entity Flow', function () {
beforeEach(function () {
global.requestAnimationFrame = sinon.stub()
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user', { id: 'user1' })
})
afterEach(function () {
@ -24,6 +26,7 @@ describe('FileTree Rename Entity Flow', function () {
onSelect.reset()
onInit.reset()
cleanUpContext()
window.metaAttributesCache = new Map()
})
beforeEach(function () {

View file

@ -1,6 +1,6 @@
import DetachCompileButton from '../../../../../frontend/js/features/pdf-preview/components/detach-compile-button'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import { screen, fireEvent } from '@testing-library/react'
import { screen } from '@testing-library/react'
import sysendTestHelper from '../../../helpers/sysend'
import { expect } from 'chai'
@ -48,23 +48,4 @@ describe('<DetachCompileButton/>', function () {
})
).to.not.exist
})
it('send compile clicks via detached action', async function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
renderWithEditorContext(<DetachCompileButton />)
sysendTestHelper.receiveMessage({
role: 'detached',
event: 'connected',
})
const compileButton = await screen.getByRole('button', {
name: 'Recompile',
})
fireEvent.click(compileButton)
expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
role: 'detacher',
event: 'action-start-compile',
data: { args: [] },
})
})
})

View file

@ -17,9 +17,7 @@ describe('<PdfLogsEntries/>', function () {
message: 'LaTeX Error',
content: 'See the LaTeX manual',
raw: '',
ruleId: 'latex_error',
humanReadableHint: '',
humanReadableHintComponent: <></>,
ruleId: 'hint_misplaced_alignment_tab_character',
key: '',
},
]
@ -36,6 +34,14 @@ describe('<PdfLogsEntries/>', function () {
fileTreeManager.findEntityByPath.resetHistory()
})
it('displays human readable hint', async function () {
renderWithEditorContext(<PdfLogsEntries entries={logEntries} />, {
fileTreeManager,
editorManager,
})
screen.getByText(/You have placed an alignment tab character/)
})
it('opens doc on click', async function () {
renderWithEditorContext(<PdfLogsEntries entries={logEntries} />, {
fileTreeManager,

View file

@ -0,0 +1,70 @@
import { expect } from 'chai'
import { render, screen, fireEvent } from '@testing-library/react'
import sysendTestHelper from '../../../helpers/sysend'
import PdfPreviewDetachedRoot from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview-detached-root'
describe('<PdfPreviewDetachedRoot/>', function () {
beforeEach(function () {
const user = { id: 'user1' }
window.user = user
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user', user)
window.metaAttributesCache.set('ol-project_id', 'project1')
window.metaAttributesCache.set('ol-detachRole', 'detached')
window.metaAttributesCache.set('ol-projectName', 'Project Name')
})
afterEach(function () {
window.metaAttributesCache = new Map()
})
it('syncs compiling state', async function () {
render(<PdfPreviewDetachedRoot />)
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'connected',
})
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'state-compiling',
data: { value: true },
})
await screen.findByRole('button', { name: 'Compiling…' })
expect(screen.queryByRole('button', { name: 'Recompile' })).to.not.exist
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'state-compiling',
data: { value: false },
})
await screen.findByRole('button', { name: 'Recompile' })
expect(screen.queryByRole('button', { name: 'Compiling…' })).to.not.exist
})
it('sends a clear cache request when the button is pressed', async function () {
render(<PdfPreviewDetachedRoot />)
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'state-showLogs',
data: { value: true },
})
const clearCacheButton = await screen.findByRole('button', {
name: 'Clear cached files',
})
expect(clearCacheButton.hasAttribute('disabled')).to.be.false
fireEvent.click(clearCacheButton)
expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
role: 'detached',
event: 'action-clearCache',
data: {
args: [],
},
})
})
})

View file

@ -1,10 +1,21 @@
import sinon from 'sinon'
import PdfPreviewHybridToolbar from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
import { renderWithEditorContext } from '../../../helpers/render-with-context'
import { screen } from '@testing-library/react'
import sysendTestHelper from '../../../helpers/sysend'
describe('<PdfPreviewHybridToolbar/>', function () {
let clock
beforeEach(function () {
clock = sinon.useFakeTimers()
})
afterEach(function () {
window.metaAttributesCache = new Map()
sysendTestHelper.resetHistory()
clock.runAll()
clock.restore()
})
it('shows normal mode', async function () {
@ -15,12 +26,49 @@ describe('<PdfPreviewHybridToolbar/>', function () {
})
})
it('shows orphan mode', async function () {
window.metaAttributesCache.set('ol-detachRole', 'detached')
renderWithEditorContext(<PdfPreviewHybridToolbar />)
describe('orphan mode', async function () {
it('shows connecting message on load', async function () {
window.metaAttributesCache.set('ol-detachRole', 'detached')
renderWithEditorContext(<PdfPreviewHybridToolbar />)
await screen.getByRole('button', {
name: 'Redirect to editor',
await screen.getByText(/Connecting with the editor/)
})
it('shows compile UI when connected', async function () {
window.metaAttributesCache.set('ol-detachRole', 'detached')
renderWithEditorContext(<PdfPreviewHybridToolbar />)
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'connected',
})
await screen.getByRole('button', {
name: 'Recompile',
})
})
it('shows connecting message when disconnected', async function () {
window.metaAttributesCache.set('ol-detachRole', 'detached')
renderWithEditorContext(<PdfPreviewHybridToolbar />)
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'connected',
})
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'closed',
})
await screen.getByText(/Connecting with the editor/)
})
it('shows redirect button after timeout', async function () {
window.metaAttributesCache.set('ol-detachRole', 'detached')
renderWithEditorContext(<PdfPreviewHybridToolbar />)
clock.tick(6000)
await screen.getByRole('button', {
name: 'Redirect to editor',
})
})
})
})

View file

@ -350,7 +350,10 @@ describe('<PdfPreview/>', function () {
// click the button
clearCacheButton.click()
expect(clearCacheButton.hasAttribute('disabled')).to.be.true
await waitFor(() => {
expect(clearCacheButton.hasAttribute('disabled')).to.be.true
})
await waitFor(() => {
expect(clearCacheButton.hasAttribute('disabled')).to.be.false
})
@ -382,7 +385,7 @@ describe('<PdfPreview/>', function () {
expect(clearCacheButton.hasAttribute('disabled')).to.be.false
mockValidPdf()
mockClearCache()
const finishClearCache = mockDelayed(mockClearCache)
const recompileFromScratch = screen.getByRole('menuitem', {
name: 'Recompile from scratch',
@ -390,7 +393,11 @@ describe('<PdfPreview/>', function () {
})
recompileFromScratch.click()
expect(clearCacheButton.hasAttribute('disabled')).to.be.true
await waitFor(() => {
expect(clearCacheButton.hasAttribute('disabled')).to.be.true
})
finishClearCache()
// wait for compile to finish
await screen.findByRole('button', { name: 'Compiling…' })

View file

@ -7,7 +7,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import fs from 'fs'
import path from 'path'
import { expect } from 'chai'
import { useCompileContext } from '../../../../../frontend/js/shared/context/compile-context'
import { useDetachCompileContext as useCompileContext } from '../../../../../frontend/js/shared/context/detach-compile-context'
import { useFileTreeData } from '../../../../../frontend/js/shared/context/file-tree-data-context'
import { useEffect } from 'react'
@ -122,7 +122,6 @@ const WithSelectedEntities = ({ mockSelectedEntities = [] }) => {
return null
}
describe('<PdfSynctexControls/>', function () {
beforeEach(function () {
window.metaAttributesCache = new Map()
@ -185,7 +184,6 @@ describe('<PdfSynctexControls/>', function () {
.true
})
})
it('disables button when multiple entities are selected', async function () {
renderWithEditorContext(
<>
@ -236,9 +234,14 @@ describe('<PdfSynctexControls/>', function () {
})
it('does not have go to PDF location button nor arrow icon', async function () {
const { container } = renderWithEditorContext(<PdfSynctexControls />, {
scope,
})
const { container } = renderWithEditorContext(
<>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</>,
{ scope }
)
expect(
await screen.queryByRole('button', {
@ -249,7 +252,50 @@ describe('<PdfSynctexControls/>', function () {
expect(container.querySelector('.synctex-control-icon')).to.not.exist
})
it('send go to PDF location action', async function () {
it('send set highlights action', async function () {
renderWithEditorContext(
<>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</>,
{ scope }
)
sysendTestHelper.resetHistory()
const syncToPdfButton = await screen.findByRole('button', {
name: 'Go to code location in PDF',
})
// mock editor cursor position update
fireEvent(
window,
new CustomEvent('cursor:editor:update', {
detail: { row: 100, column: 10 },
})
)
expect(syncToPdfButton.disabled).to.be.false
fireEvent.click(syncToPdfButton)
expect(syncToPdfButton.disabled).to.be.true
await waitFor(() => {
expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be
.true
})
// synctex is called locally and the result are broadcast for the detached
// tab
expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
role: 'detacher',
event: 'action-setHighlights',
data: { args: [mockHighlights] },
})
})
it('reacts to sync to code action', async function () {
renderWithEditorContext(
<>
<WithPosition mockPosition={mockPosition} />
@ -259,74 +305,23 @@ describe('<PdfSynctexControls/>', function () {
{ scope }
)
sysendTestHelper.resetHistory()
const syncToPdfButton = await screen.findByRole('button', {
name: 'Go to code location in PDF',
await waitFor(() => {
expect(fetchMock.called('express:/project/:projectId/compile')).to.be
.true
})
// mock editor cursor position update
fireEvent(
window,
new CustomEvent('cursor:editor:update', {
detail: { row: 100, column: 10 },
})
)
fireEvent.click(syncToPdfButton)
// the button is only disabled when the state is updated via sysend
expect(syncToPdfButton.disabled).to.be.false
expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
role: 'detacher',
event: 'action-go-to-pdf-location',
data: { args: ['file=&line=101&column=10'] },
})
})
it('update inflight state', async function () {
const { container } = renderWithEditorContext(
<>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</>,
{ scope }
)
sysendTestHelper.resetHistory()
const syncToPdfButton = await screen.findByRole('button', {
name: 'Go to code location in PDF',
})
// mock editor cursor position update
fireEvent(
window,
new CustomEvent('cursor:editor:update', {
detail: { row: 100, column: 10 },
})
)
sysendTestHelper.receiveMessage({
role: 'detached',
event: 'state-sync-to-pdf-inflight',
data: { value: true },
event: 'action-sync-to-code',
data: {
args: [mockPosition],
},
})
expect(syncToPdfButton.disabled).to.be.true
expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
1
)
sysendTestHelper.receiveMessage({
role: 'detached',
event: 'state-sync-to-pdf-inflight',
data: { value: false },
await waitFor(() => {
expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be
.true
})
expect(syncToPdfButton.disabled).to.be.false
expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
0
)
})
})
@ -336,9 +331,13 @@ describe('<PdfSynctexControls/>', function () {
})
it('does not have go to code location button nor arrow icon', async function () {
const { container } = renderWithEditorContext(<PdfSynctexControls />, {
scope,
})
const { container } = renderWithEditorContext(
<>
<WithPosition mockPosition={mockPosition} />
<PdfSynctexControls />
</>,
{ scope }
)
expect(
await screen.queryByRole('button', {
@ -349,102 +348,90 @@ describe('<PdfSynctexControls/>', function () {
expect(container.querySelector('.synctex-control-icon')).to.not.exist
})
it('send go to code line action and update inflight state', async function () {
it('send go to code line action', async function () {
const { container } = renderWithEditorContext(
<>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</>,
{ scope }
)
sysendTestHelper.resetHistory()
const syncToCodeButton = await screen.findByRole('button', {
name: /Go to PDF location in code/,
})
expect(syncToCodeButton.disabled).to.be.true
sysendTestHelper.receiveMessage({
role: 'detached',
event: 'state-has-single-selected-doc',
data: { value: true },
})
expect(syncToCodeButton.disabled).to.be.false
sysendTestHelper.resetHistory()
fireEvent.click(syncToCodeButton)
// the button is only disabled when the state is updated via sysend
expect(syncToCodeButton.disabled).to.be.false
expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
0
)
expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
role: 'detached',
event: 'action-sync-to-code',
data: {
args: [mockPosition, 72],
},
})
})
it('update inflight state', async function () {
const { container } = renderWithEditorContext(
<>
<WithPosition mockPosition={mockPosition} />
<PdfSynctexControls />
</>,
{ scope }
)
sysendTestHelper.receiveMessage({
role: 'detached',
event: 'state-has-single-selected-doc',
data: { value: true },
})
const syncToCodeButton = await screen.findByRole('button', {
name: /Go to PDF location in code/,
})
expect(syncToCodeButton.disabled).to.be.false
expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
0
)
fireEvent.click(syncToCodeButton)
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'state-sync-to-code-inflight',
data: { value: true },
})
expect(syncToCodeButton.disabled).to.be.true
expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
1
)
await waitFor(() => {
expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be
.true
})
expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
role: 'detached',
event: 'action-go-to-code-line',
data: { args: ['main.tex', 100] },
})
})
it('sends PDF exists state', async function () {
renderWithEditorContext(
<>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</>,
{ scope }
)
sysendTestHelper.resetHistory()
await waitFor(() => {
expect(fetchMock.called('express:/project/:projectId/compile')).to.be
.true
})
expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
role: 'detached',
event: 'state-pdf-exists',
data: { value: true },
})
})
it('reacts to go to PDF location action', async function () {
renderWithEditorContext(
<>
<WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</>,
{ scope }
)
sysendTestHelper.resetHistory()
await waitFor(() => {
expect(fetchMock.called('express:/project/:projectId/compile')).to.be
.true
})
sysendTestHelper.spy.broadcast.resetHistory()
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'action-go-to-pdf-location',
data: { args: ['file=&line=101&column=10'] },
})
await waitFor(() => {
expect(fetchMock.called('express:/project/:projectId/sync/code')).to.be
.true
})
expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
role: 'detached',
event: 'state-sync-to-pdf-inflight',
event: 'state-sync-to-code-inflight',
data: { value: false },
})
expect(syncToCodeButton.disabled).to.be.false
expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
0
)
})
})
})

View file

@ -82,8 +82,15 @@ export const mockValidationProblems = validationProblems =>
},
})
export const mockClearCache = () =>
fetchMock.delete('express:/project/:projectId/output', 204)
export const mockClearCache = (delayPromise = Promise.resolve()) =>
fetchMock.delete(
'express:/project/:projectId/output',
delayPromise.then(() => ({
body: {
status: 204,
},
}))
)
export const mockValidPdf = () => {
nock('https://clsi.test-overleaf.com')

View file

@ -83,11 +83,14 @@ describe('<ShareProjectModal/>', function () {
beforeEach(function () {
fetchMock.get('/user/contacts', { contacts })
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
})
afterEach(function () {
fetchMock.restore()
cleanUpContext()
window.metaAttributesCache = new Map()
})
it('renders the modal', async function () {
@ -179,7 +182,7 @@ describe('<ShareProjectModal/>', function () {
]
// render as admin: actions should be present
const { rerender } = render(
render(
<EditorProviders
scope={{
project: {
@ -197,7 +200,7 @@ describe('<ShareProjectModal/>', function () {
await screen.findByRole('button', { name: 'Resend' })
// render as non-admin (non-owner), link sharing on: actions should be missing and message should be present
rerender(
render(
<EditorProviders
scope={{
project: {
@ -222,7 +225,7 @@ describe('<ShareProjectModal/>', function () {
expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
// render as non-admin (non-owner), link sharing off: actions should be missing and message should be present
rerender(
render(
<EditorProviders
scope={{
project: {
@ -619,10 +622,6 @@ describe('<ShareProjectModal/>', function () {
)
renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
user: {
id: '123abd',
allowedFreeTrial: true,
},
scope: {
project: {
...project,

View file

@ -10,7 +10,8 @@ import { FileTreeDataProvider } from '../../../frontend/js/shared/context/file-t
import { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
import { DetachProvider } from '../../../frontend/js/shared/context/detach-context'
import { LayoutProvider } from '../../../frontend/js/shared/context/layout-context'
import { CompileProvider } from '../../../frontend/js/shared/context/compile-context'
import { LocalCompileProvider } from '../../../frontend/js/shared/context/local-compile-context'
import { DetachCompileProvider } from '../../../frontend/js/shared/context/detach-compile-context'
// these constants can be imported in tests instead of
// using magic strings
@ -110,7 +111,9 @@ export function EditorProviders({
<EditorProvider settings={{}}>
<DetachProvider>
<LayoutProvider>
<CompileProvider>{children}</CompileProvider>
<LocalCompileProvider>
<DetachCompileProvider>{children}</DetachCompileProvider>
</LocalCompileProvider>
</LayoutProvider>
</DetachProvider>
</EditorProvider>

View file

@ -25,6 +25,10 @@ function getLastBroacastMessage() {
return getLastDetachCall('broadcast').args[1]
}
function getAllBroacastMessages() {
return getDetachCalls('broadcast')
}
// this fakes receiving a message by calling the handler add to `on`. A bit
// funky, but works for now
function receiveMessage(message) {
@ -37,5 +41,6 @@ export default {
getDetachCalls,
getLastDetachCall,
getLastBroacastMessage,
getAllBroacastMessages,
receiveMessage,
}

View file

@ -46,6 +46,7 @@ describe('useDetachLayout', function () {
})
it('detacher role', async function () {
sysendTestHelper.spy.broadcast.resetHistory()
window.metaAttributesCache.set('ol-detachRole', 'detacher')
// 1. create hook in detacher mode
@ -55,6 +56,8 @@ describe('useDetachLayout', function () {
expect(result.current.isLinked).to.be.false
expect(result.current.isLinking).to.be.false
expect(result.current.role).to.equal('detacher')
const broadcastMessagesCount =
sysendTestHelper.getAllBroacastMessages().length
// 2. simulate connected detached tab
sysendTestHelper.spy.broadcast.resetHistory()
@ -70,6 +73,12 @@ describe('useDetachLayout', function () {
expect(result.current.isLinking).to.be.false
expect(result.current.role).to.equal('detacher')
// check that all message were re-broadcast for the new tab
await nextTick() // necessary to ensure all event handler have run
const reBroadcastMessagesCount =
sysendTestHelper.getAllBroacastMessages().length
expect(reBroadcastMessagesCount).to.equal(broadcastMessagesCount)
// 3. simulate closed detached tab
sysendTestHelper.spy.broadcast.resetHistory()
sysendTestHelper.receiveMessage({
@ -90,21 +99,7 @@ describe('useDetachLayout', function () {
expect(result.current.isLinking).to.be.false
expect(result.current.role).to.equal('detacher')
// 5. simulate closed detacher tab
sysendTestHelper.spy.broadcast.resetHistory()
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'closed',
})
expect(result.current.isLinked).to.be.true
expect(result.current.isLinking).to.be.false
expect(result.current.role).to.equal('detacher')
expect(sysendTestHelper.getLastBroacastMessage()).to.deep.equal({
role: 'detacher',
event: 'up',
})
// 6. reattach
// 5. reattach
sysendTestHelper.spy.broadcast.resetHistory()
act(() => {
result.current.reattach()
@ -118,6 +113,26 @@ describe('useDetachLayout', function () {
})
})
it('reset detacher role when other detacher tab connects', function () {
window.metaAttributesCache.set('ol-detachRole', 'detacher')
// 1. create hook in detacher mode
const { result } = renderHookWithEditorContext(() => useDetachLayout())
expect(result.current.reattach).to.be.a('function')
expect(result.current.detach).to.be.a('function')
expect(result.current.isLinked).to.be.false
expect(result.current.isLinking).to.be.false
expect(result.current.role).to.equal('detacher')
// 2. simulate other detacher tab
sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'up',
})
expect(result.current.isRedundant).to.be.true
expect(result.current.role).to.equal(null)
})
it('detached role', async function () {
window.metaAttributesCache.set('ol-detachRole', 'detached')
@ -185,3 +200,9 @@ describe('useDetachLayout', function () {
sinon.assert.called(closeStub)
})
})
const nextTick = () => {
return new Promise(resolve => {
setTimeout(resolve)
})
}

View file

@ -12,6 +12,7 @@ const entryPoints = {
serviceWorker: './frontend/js/serviceWorker.js',
main: './frontend/js/main.js',
ide: './frontend/js/ide.js',
'ide-detached': './frontend/js/ide-detached.js',
marketing: './frontend/js/marketing.js',
style: './frontend/stylesheets/style.less',
'ieee-style': './frontend/stylesheets/ieee-style.less',