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') || !Features.hasFeature('saas') ||
(user.features && user.features.symbolPalette) (user.features && user.features.symbolPalette)
res.render('project/editor', { const template =
detachRole === 'detached'
? 'project/editor_detached'
: 'project/editor'
res.render(template, {
title: project.name, title: project.name,
priority_title: true, priority_title: true,
bodyClasses: ['editor'], bodyClasses: ['editor'],
project_id: project._id, project_id: project._id,
projectName: project.name,
user: { user: {
id: userId, id: userId,
email: user.email, email: user.email,

View file

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

View file

@ -1,5 +1,6 @@
meta(name="ol-useV2History" data-type="boolean" content=useV2History) meta(name="ol-useV2History" data-type="boolean" content=useV2History)
meta(name="ol-project_id" content=project_id) 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-userSettings" data-type="json" content=userSettings)
meta(name="ol-user" data-type="json" content=user) meta(name="ol-user" data-type="json" content=user)
meta(name="ol-learnedWords" data-type="json" content=learnedWords) 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_project_to_github_explanation": "",
"sync_to_dropbox": "", "sync_to_dropbox": "",
"sync_to_github": "", "sync_to_github": "",
"tab_connecting": "",
"tab_no_longer_connected": "", "tab_no_longer_connected": "",
"tags": "", "tags": "",
"template_approved_by_publisher": "", "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 ControlledDropdown from '../../../shared/components/controlled-dropdown'
import IconEditorOnly from './icon-editor-only' import IconEditorOnly from './icon-editor-only'
import IconPdfOnly from './icon-pdf-only' import IconPdfOnly from './icon-pdf-only'
import { useCompileContext } from '../../../shared/context/compile-context'
import { useLayoutContext } from '../../../shared/context/layout-context' import { useLayoutContext } from '../../../shared/context/layout-context'
import * as eventTracking from '../../../infrastructure/event-tracking' import * as eventTracking from '../../../infrastructure/event-tracking'
@ -59,13 +58,10 @@ function LayoutDropdownButton() {
pdfLayout, pdfLayout,
} = useLayoutContext(layoutContextPropTypes) } = useLayoutContext(layoutContextPropTypes)
const { stopCompile } = useCompileContext()
const handleDetach = useCallback(() => { const handleDetach = useCallback(() => {
detach() detach()
stopCompile()
eventTracking.sendMB('project-layout-detach') eventTracking.sendMB('project-layout-detach')
}, [detach, stopCompile]) }, [detach])
const handleReattach = useCallback(() => { const handleReattach = useCallback(() => {
if (detachRole !== 'detacher') { if (detachRole !== 'detacher') {

View file

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

View file

@ -2,7 +2,7 @@ import Icon from '../../../shared/components/icon'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { memo } from 'react' import { memo } from 'react'
import { useCompileContext } from '../../../shared/context/compile-context' import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfClearCacheButton() { function PdfClearCacheButton() {
const { compiling, clearCache, clearingCache } = useCompileContext() const { compiling, clearCache, clearingCache } = useCompileContext()
@ -14,7 +14,7 @@ function PdfClearCacheButton() {
bsSize="small" bsSize="small"
bsStyle="danger" bsStyle="danger"
className="logs-pane-actions-clear-cache" className="logs-pane-actions-clear-cache"
onClick={clearCache} onClick={() => clearCache()}
disabled={clearingCache || compiling} disabled={clearingCache || compiling}
> >
{clearingCache ? <Icon type="refresh" spin /> : <Icon type="trash-o" />} {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 { useTranslation } from 'react-i18next'
import { memo } from 'react' import { memo } from 'react'
import classnames from 'classnames' 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' import PdfCompileButtonInner from './pdf-compile-button-inner'
function PdfCompileButton() { function PdfCompileButton() {
@ -84,7 +84,7 @@ function PdfCompileButton() {
<MenuItem divider /> <MenuItem divider />
<MenuItem <MenuItem
onSelect={stopCompile} onSelect={() => stopCompile()}
disabled={!compiling} disabled={!compiling}
aria-disabled={!compiling} aria-disabled={!compiling}
> >
@ -92,7 +92,7 @@ function PdfCompileButton() {
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onSelect={recompileFromScratch} onSelect={() => recompileFromScratch()}
disabled={compiling} disabled={compiling}
aria-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 ControlledDropdown from '../../../shared/components/controlled-dropdown'
import { memo } from 'react' import { memo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useCompileContext } from '../../../shared/context/compile-context' import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfDownloadFilesButton() { function PdfDownloadFilesButton() {
const { compiling, fileList } = useCompileContext() const { compiling, fileList } = useCompileContext()

View file

@ -1,24 +1,17 @@
import { memo, useCallback } from 'react' import { memo, useCallback } from 'react'
import { sendMBOnce } from '../../../infrastructure/event-tracking'
import { Button } from 'react-bootstrap' import { Button } from 'react-bootstrap'
import Icon from '../../../shared/components/icon' import Icon from '../../../shared/components/icon'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useCompileContext } from '../../../shared/context/compile-context' import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
function PdfHybridCodeCheckButton() { function PdfHybridCodeCheckButton() {
const { codeCheckFailed, error, setShowLogs } = useCompileContext() const { codeCheckFailed, error, toggleLogs } = useCompileContext()
const { t } = useTranslation() const { t } = useTranslation()
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
setShowLogs(value => { toggleLogs()
if (!value) { }, [toggleLogs])
sendMBOnce('ide-open-logs-once')
}
return !value
})
}, [setShowLogs])
if (!codeCheckFailed) { if (!codeCheckFailed) {
return null return null

View file

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

View file

@ -1,24 +1,17 @@
import { memo, useCallback } from 'react' import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button, Label, OverlayTrigger, Tooltip } from 'react-bootstrap' import { Button, Label, OverlayTrigger, Tooltip } from 'react-bootstrap'
import { sendMBOnce } from '../../../infrastructure/event-tracking'
import Icon from '../../../shared/components/icon' 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() { function PdfHybridLogsButton() {
const { error, logEntries, setShowLogs, showLogs } = useCompileContext() const { error, logEntries, toggleLogs, showLogs } = useCompileContext()
const { t } = useTranslation() const { t } = useTranslation()
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
setShowLogs(value => { toggleLogs()
if (!value) { }, [toggleLogs])
sendMBOnce('ide-open-logs-once')
}
return !value
})
}, [setShowLogs])
const errorCount = Number(logEntries?.errors?.length) const errorCount = Number(logEntries?.errors?.length)
const warningCount = Number(logEntries?.warnings?.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 PDFJSWrapper from '../util/pdf-js-wrapper'
import withErrorBoundary from '../../../infrastructure/error-boundary' import withErrorBoundary from '../../../infrastructure/error-boundary'
import ErrorBoundaryFallback from './error-boundary-fallback' 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' import getMeta from '../../../utils/meta'
function PdfJsViewer({ url }) { function PdfJsViewer({ url }) {

View file

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

View file

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

View file

@ -11,7 +11,7 @@ import withErrorBoundary from '../../../infrastructure/error-boundary'
import ErrorBoundaryFallback from './error-boundary-fallback' import ErrorBoundaryFallback from './error-boundary-fallback'
import PdfCodeCheckFailedNotice from './pdf-code-check-failed-notice' import PdfCodeCheckFailedNotice from './pdf-code-check-failed-notice'
import PdfLogsPaneInfoNotice from './pdf-logs-pane-info-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' import PdfLogEntry from './pdf-log-entry'
function PdfLogsViewer() { 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 { ButtonToolbar } from 'react-bootstrap'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLayoutContext } from '../../../shared/context/layout-context' 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 PdfHybridCodeCheckButton from './pdf-hybrid-code-check-button'
import PdfOrphanRefreshButton from './pdf-orphan-refresh-button' import PdfOrphanRefreshButton from './pdf-orphan-refresh-button'
import { DetachedSynctexControl } from './detach-synctex-control' import { DetachedSynctexControl } from './detach-synctex-control'
import Icon from '../../../shared/components/icon'
const ORPHAN_UI_TIMEOUT_MS = 5000
function PdfPreviewHybridToolbar() { function PdfPreviewHybridToolbar() {
const { detachRole, detachIsLinked } = useLayoutContext() const { detachRole, detachIsLinked } = useLayoutContext()
const uiTimeoutRef = useRef()
const [orphanPdfTabAfterDelay, setOrphanPdfTabAfterDelay] = useState(false)
const orphanPdfTab = !detachIsLinked && detachRole === 'detached' 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 ( return (
<ButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid"> <ButtonToolbar className="toolbar toolbar-pdf toolbar-pdf-hybrid">
{orphanPdfTab ? ( {ToolbarInner}
<PdfPreviewHybridToolbarOrphanInner />
) : (
<PdfPreviewHybridToolbarInner />
)}
</ButtonToolbar> </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) export default memo(PdfPreviewHybridToolbar)

View file

@ -4,7 +4,7 @@ import PdfLogsViewer from './pdf-logs-viewer'
import PdfViewer from './pdf-viewer' import PdfViewer from './pdf-viewer'
import LoadingSpinner from '../../../shared/components/loading-spinner' import LoadingSpinner from '../../../shared/components/loading-spinner'
import PdfHybridPreviewToolbar from './pdf-preview-hybrid-toolbar' 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() { function PdfPreviewPane() {
const { pdfUrl } = useCompileContext() const { pdfUrl } = useCompileContext()

View file

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

View file

@ -1,5 +1,5 @@
import { lazy, memo } from 'react' 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(() => const PdfJsViewer = lazy(() =>
import(/* webpackChunkName: "pdf-js-viewer" */ './pdf-js-viewer') 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 getMeta from '../../../utils/meta'
import { useCompileContext } from '../../../shared/context/compile-context' import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
import { useDetachContext } from '../../../shared/context/detach-context'
import useEventListener from '../../../shared/hooks/use-event-listener' import useEventListener from '../../../shared/hooks/use-event-listener'
import useDetachAction from '../../../shared/hooks/use-detach-action' import useDetachAction from '../../../shared/hooks/use-detach-action'
import usePreviousValue from '../../../shared/hooks/use-previous-value'
const showPdfDetach = getMeta('ol-showPdfDetach') const showPdfDetach = getMeta('ol-showPdfDetach')
const debugPdfDetach = getMeta('ol-debugPdfDetach')
export default function useCompileTriggers() { export default function useCompileTriggers() {
const { startCompile, setChangedAt, cleanupCompileResult, setError } = const { startCompile, setChangedAt } = useCompileContext()
useCompileContext()
const { role: detachRole } = useDetachContext()
// recompile on key press // recompile on key press
const startOrTriggerCompile = useDetachAction( const startOrTriggerCompile = useDetachAction(
@ -43,23 +38,4 @@ export default function useCompileTriggers() {
}, [setOrTriggerChangedAt, setChangedAt]) }, [setOrTriggerChangedAt, setChangedAt])
useEventListener('doc:changed', setChangedAtHandler) useEventListener('doc:changed', setChangedAtHandler)
useEventListener('doc:saved', 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 let type
if (ruleDetails.ruleId != null) { if (ruleDetails.ruleId != null) {
entry.ruleId = ruleDetails.ruleId 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) { if (ruleDetails.newMessage != null) {
entry.message = entry.message.replace( entry.message = entry.message.replace(
@ -54,19 +49,6 @@ export default {
seenErrorTypes[type] = true 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 */ /* 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 = [ const rules = [
{ {
ruleId: 'hint_misplaced_alignment_tab_character',
regexToMatch: /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/, 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 \$\$/, 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./, 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/, 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/, 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 .+/, 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)?/, 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'/, 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/, 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/, 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/, 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: \..+/, 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'/, 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'/, 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/, 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', ruleId: 'hint_mismatched_environment',
types: ['environment'], types: ['environment'],
regexToMatch: /Error: `([^']{2,})' expected, found `([^']{2,})'.*/, regexToMatch: /Error: `([^']{2,})' expected, found `([^']{2,})'.*/,
newMessage: 'Error: environment does not match \\begin{$1} ... \\end{$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', ruleId: 'hint_mismatched_brackets',
types: ['environment'], types: ['environment'],
regexToMatch: /Error: `([^a-zA-Z0-9])' expected, found `([^a-zA-Z0-9])'.*/, regexToMatch: /Error: `([^a-zA-Z0-9])' expected, found `([^a-zA-Z0-9])'.*/,
newMessage: "Error: brackets do not match, found '$2' instead of '$1'", 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/, 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/, 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/, 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/, 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/, 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/, 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/, 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/, 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/, 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/, 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)/, 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/, 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}/, 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', ruleId: 'hint_mismatched_environment2',
@ -518,15 +136,6 @@ const rules = [
regexToMatch: regexToMatch:
/Error: `\\end\{([^\}]+)\}' expected but found `\\end\{([^\}]+)\}'.*/, /Error: `\\end\{([^\}]+)\}' expected but found `\\end\{([^\}]+)\}'.*/,
newMessage: 'Error: environments do not match: \\begin{$1} ... \\end{$2}', 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', ruleId: 'hint_mismatched_environment3',
@ -535,15 +144,6 @@ const rules = [
regexToMatch: regexToMatch:
/Warning: No matching \\end found for `\\begin\{([^\}]+)\}'.*/, /Warning: No matching \\end found for `\\begin\{([^\}]+)\}'.*/,
newMessage: 'Warning: No matching \\end found for \\begin{$1}', 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', ruleId: 'hint_mismatched_environment4',
@ -552,29 +152,7 @@ const rules = [
regexToMatch: regexToMatch:
/Error: Found `\\end\{([^\}]+)\}' without corresponding \\begin.*/, /Error: Found `\\end\{([^\}]+)\}' without corresponding \\begin.*/,
newMessage: 'Error: found \\end{$1} without a corresponding \\begin{$1}', 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 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')}` const SYSEND_CHANNEL = `detach-${getMeta('ol-project_id')}`
export function DetachProvider({ children }) { export function DetachProvider({ children }) {
const [lastDetachedConnectedAt, setLastDetachedConnectedAt] = useState()
const [role, setRole] = useState(() => getMeta('ol-detachRole') || null) const [role, setRole] = useState(() => getMeta('ol-detachRole') || null)
const { const {
addHandler: addEventHandler, addHandler: addEventHandler,
@ -94,15 +95,33 @@ export function DetachProvider({ children }) {
return () => window.removeEventListener('beforeunload', onBeforeUnload) return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [broadcastEvent]) }, [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( const value = useMemo(
() => ({ () => ({
role, role,
setRole, setRole,
broadcastEvent, broadcastEvent,
lastDetachedConnectedAt,
addEventHandler, addEventHandler,
deleteEventHandler, deleteEventHandler,
}), }),
[role, setRole, broadcastEvent, addEventHandler, deleteEventHandler] [
role,
setRole,
broadcastEvent,
lastDetachedConnectedAt,
addEventHandler,
deleteEventHandler,
]
) )
return ( return (

View file

@ -155,7 +155,7 @@ export function EditorProvider({ children, settings }) {
EditorProvider.propTypes = { EditorProvider.propTypes = {
children: PropTypes.any, children: PropTypes.any,
settings: PropTypes.any.isRequired, settings: PropTypes.object,
} }
export function useEditorContext(propTypes) { 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 PropTypes from 'prop-types'
import { getMockIde } from './mock/mock-ide'
const IdeContext = createContext() const IdeContext = createContext()
@ -20,11 +21,13 @@ export function useIdeContext() {
} }
export function IdeProvider({ ide, children }) { 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 = { IdeProvider.propTypes = {
children: PropTypes.any.isRequired, children: PropTypes.any.isRequired,
ide: PropTypes.shape({ ide: PropTypes.shape({
$scope: PropTypes.object.isRequired, $scope: PropTypes.object.isRequired,
}).isRequired, }),
} }

View file

@ -108,11 +108,13 @@ export function LayoutProvider({ children }) {
isLinking: detachIsLinking, isLinking: detachIsLinking,
isLinked: detachIsLinked, isLinked: detachIsLinked,
role: detachRole, role: detachRole,
isRedundant: detachIsRedundant,
} = useDetachLayout() } = useDetachLayout()
useEffect(() => { useEffect(() => {
if (debugPdfDetach) { if (debugPdfDetach) {
console.log('Layout Effect', { console.log('Layout Effect', {
detachIsRedundant,
detachRole, detachRole,
detachIsLinking, detachIsLinking,
detachIsLinked, detachIsLinked,
@ -121,12 +123,23 @@ export function LayoutProvider({ children }) {
if (detachRole !== 'detacher') return // not in a PDF detacher layout if (detachRole !== 'detacher') return // not in a PDF detacher layout
if (detachIsRedundant) {
changeLayout('sideBySide')
return
}
if (detachIsLinking || detachIsLinked) { if (detachIsLinking || detachIsLinked) {
// the tab is linked to a detached tab (or about to be linked); show // the tab is linked to a detached tab (or about to be linked); show
// editor only // editor only
changeLayout('flat', 'editor') changeLayout('flat', 'editor')
} }
}, [detachRole, detachIsLinking, detachIsLinked, changeLayout]) }, [
detachIsRedundant,
detachRole,
detachIsLinking,
detachIsLinked,
changeLayout,
])
const value = useMemo( 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 usePersistedState from '../hooks/use-persisted-state'
import useAbortController from '../hooks/use-abort-controller' import useAbortController from '../hooks/use-abort-controller'
import DocumentCompiler from '../../features/pdf-preview/util/compiler' import DocumentCompiler from '../../features/pdf-preview/util/compiler'
import { send, sendMBSampled } from '../../infrastructure/event-tracking' import {
send,
sendMBOnce,
sendMBSampled,
} from '../../infrastructure/event-tracking'
import { import {
buildLogEntryAnnotations, buildLogEntryAnnotations,
handleLogFiles, handleLogFiles,
@ -24,9 +28,9 @@ import { useProjectContext } from './project-context'
import { useEditorContext } from './editor-context' import { useEditorContext } from './editor-context'
import { buildFileList } from '../../features/pdf-preview/util/file-list' 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({ value: PropTypes.shape({
autoCompile: PropTypes.bool.isRequired, autoCompile: PropTypes.bool.isRequired,
clearingCache: PropTypes.bool.isRequired, clearingCache: PropTypes.bool.isRequired,
@ -52,6 +56,7 @@ CompileContext.Provider.propTypes = {
setHighlights: PropTypes.func.isRequired, setHighlights: PropTypes.func.isRequired,
setPosition: PropTypes.func.isRequired, setPosition: PropTypes.func.isRequired,
setShowLogs: PropTypes.func.isRequired, setShowLogs: PropTypes.func.isRequired,
toggleLogs: PropTypes.func.isRequired,
setStopOnValidationError: PropTypes.func.isRequired, setStopOnValidationError: PropTypes.func.isRequired,
showLogs: PropTypes.bool.isRequired, showLogs: PropTypes.bool.isRequired,
stopOnValidationError: 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 ide = useIdeContext()
const { hasPremiumCompile, isProjectOwner } = useEditorContext() const { hasPremiumCompile, isProjectOwner } = useEditorContext()
@ -111,6 +118,15 @@ export function CompileProvider({ children }) {
// whether the logs should be visible // whether the logs should be visible
const [showLogs, setShowLogs] = useState(false) const [showLogs, setShowLogs] = useState(false)
const toggleLogs = useCallback(() => {
setShowLogs(prev => {
if (!prev) {
sendMBOnce('ide-open-logs-once')
}
return !prev
})
}, [setShowLogs])
// an error that occurred // an error that occurred
const [error, setError] = useState() const [error, setError] = useState()
@ -445,6 +461,7 @@ export function CompileProvider({ children }) {
setHighlights, setHighlights,
setPosition, setPosition,
setShowLogs, setShowLogs,
toggleLogs,
setStopOnValidationError, setStopOnValidationError,
showLogs, showLogs,
startCompile, startCompile,
@ -492,20 +509,29 @@ export function CompileProvider({ children }) {
firstRenderDone, firstRenderDone,
setChangedAt, setChangedAt,
cleanupCompileResult, cleanupCompileResult,
setShowLogs,
toggleLogs,
] ]
) )
return ( return (
<CompileContext.Provider value={value}>{children}</CompileContext.Provider> <LocalCompileContext.Provider value={value}>
{children}
</LocalCompileContext.Provider>
) )
} }
CompileProvider.propTypes = { LocalCompileProvider.propTypes = {
children: PropTypes.any, children: PropTypes.any,
} }
export function useCompileContext(propTypes) { export function useLocalCompileContext(propTypes) {
const data = useContext(CompileContext) const data = useContext(LocalCompileContext)
PropTypes.checkPropTypes(propTypes, data, 'data', 'CompileContext.Provider') PropTypes.checkPropTypes(
propTypes,
data,
'data',
'LocalCompileContext.Provider'
)
return data 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 { UserProvider } from './user-context'
import { IdeProvider } from './ide-context' import { IdeProvider } from './ide-context'
import { EditorProvider } from './editor-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 { LayoutProvider } from './layout-context'
import { DetachProvider } from './detach-context' import { DetachProvider } from './detach-context'
import { ChatProvider } from '../../features/chat/context/chat-context' import { ChatProvider } from '../../features/chat/context/chat-context'
@ -22,9 +23,11 @@ export function ContextRoot({ children, ide, settings }) {
<EditorProvider settings={settings}> <EditorProvider settings={settings}>
<DetachProvider> <DetachProvider>
<LayoutProvider> <LayoutProvider>
<CompileProvider> <LocalCompileProvider>
<ChatProvider>{children}</ChatProvider> <DetachCompileProvider>
</CompileProvider> <ChatProvider>{children}</ChatProvider>
</DetachCompileProvider>
</LocalCompileProvider>
</LayoutProvider> </LayoutProvider>
</DetachProvider> </DetachProvider>
</EditorProvider> </EditorProvider>
@ -38,8 +41,8 @@ export function ContextRoot({ children, ide, settings }) {
ContextRoot.propTypes = { ContextRoot.propTypes = {
children: PropTypes.any, children: PropTypes.any,
ide: PropTypes.any.isRequired, ide: PropTypes.object,
settings: PropTypes.any.isRequired, settings: PropTypes.object,
} }
export const rootContext = createSharedContext(ContextRoot) export const rootContext = createSharedContext(ContextRoot)

View file

@ -1,6 +1,6 @@
import { createContext, useContext } from 'react' import { createContext, useContext } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import useScopeValue from '../hooks/use-scope-value' import getMeta from '../../utils/meta'
export const UserContext = createContext() export const UserContext = createContext()
@ -18,7 +18,7 @@ UserContext.Provider.propTypes = {
} }
export function UserProvider({ children }) { export function UserProvider({ children }) {
const [user] = useScopeValue('user', true) const user = getMeta('ol-user')
return <UserContext.Provider value={user}>{children}</UserContext.Provider> 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() { export default function useCallbackHandlers() {
const [handlers, setHandlers] = useState(new Set()) const handlersRef = useRef(new Set())
const addHandler = useCallback( const addHandler = useCallback(handler => {
handler => { handlersRef.current.add(handler)
setHandlers(prev => new Set(prev.add(handler))) }, [])
},
[setHandlers]
)
const deleteHandler = useCallback( const deleteHandler = useCallback(handler => {
handler => { handlersRef.current.delete(handler)
setHandlers(prev => { }, [])
prev.delete(handler)
return new Set(prev)
})
},
[setHandlers]
)
const callHandlers = useCallback( const callHandlers = useCallback((...args) => {
(...args) => { for (const handler of handlersRef.current) {
for (const handler of handlers) { handler(...args)
handler(...args) }
} }, [])
},
[handlers]
)
return { addHandler, deleteHandler, callHandlers } 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) // isLinked: when the tab is linked to another tab (of different role)
const [isLinked, setIsLinked] = useState(false) 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() const uiTimeoutRef = useRef()
useEffect(() => { useEffect(() => {
@ -76,12 +80,24 @@ export default function useDetachLayout() {
}, [setRole, setIsLinked, broadcastEvent]) }, [setRole, setIsLinked, broadcastEvent])
const detach = useCallback(() => { const detach = useCallback(() => {
setIsRedundant(false)
setRole('detacher') setRole('detacher')
setIsLinking(true) setIsLinking(true)
window.open(buildUrlWithDetachRole('detached').toString(), '_blank') window.open(buildUrlWithDetachRole('detached').toString(), '_blank')
}, [setRole, setIsLinking]) }, [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( const handleEventForDetacherFromDetached = useCallback(
message => { message => {
switch (message.event) { switch (message.event) {
@ -122,7 +138,7 @@ export default function useDetachLayout() {
[setIsLinked, broadcastEvent] [setIsLinked, broadcastEvent]
) )
const handleEventFromSelf = useCallback( const handleEventForDetachedFromDetached = useCallback(
message => { message => {
switch (message.event) { switch (message.event) {
case 'closed': case 'closed':
@ -137,7 +153,7 @@ export default function useDetachLayout() {
message => { message => {
if (role === 'detacher') { if (role === 'detacher') {
if (message.role === 'detacher') { if (message.role === 'detacher') {
handleEventFromSelf(message) handleEventForDetacherFromDetacher(message)
} else if (message.role === 'detached') { } else if (message.role === 'detached') {
handleEventForDetacherFromDetached(message) handleEventForDetacherFromDetached(message)
} }
@ -145,15 +161,16 @@ export default function useDetachLayout() {
if (message.role === 'detacher') { if (message.role === 'detacher') {
handleEventForDetachedFromDetacher(message) handleEventForDetachedFromDetacher(message)
} else if (message.role === 'detached') { } else if (message.role === 'detached') {
handleEventFromSelf(message) handleEventForDetachedFromDetached(message)
} }
} }
}, },
[ [
role, role,
handleEventForDetacherFromDetacher,
handleEventForDetacherFromDetached, handleEventForDetacherFromDetached,
handleEventForDetachedFromDetacher, handleEventForDetachedFromDetacher,
handleEventFromSelf, handleEventForDetachedFromDetached,
] ]
) )
@ -168,5 +185,6 @@ export default function useDetachLayout() {
isLinked, isLinked,
isLinking, isLinking,
role, 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 [value, setValue] = useState(defaultValue)
const { role, broadcastEvent, addEventHandler, deleteEventHandler } = const {
useDetachContext() role,
broadcastEvent,
lastDetachedConnectedAt,
addEventHandler,
deleteEventHandler,
} = useDetachContext()
const eventName = `state-${key}` const eventName = `state-${key}`
// lastDetachedConnectedAt is added as a dependency in order to re-broadcast
// all states when a new detached tab connects
useEffect(() => { useEffect(() => {
if (role === senderRole) { if (role === senderRole) {
broadcastEvent(eventName, { value }) broadcastEvent(eventName, { value })
} }
}, [role, senderRole, eventName, value, broadcastEvent]) }, [
role,
senderRole,
eventName,
value,
broadcastEvent,
lastDetachedConnectedAt,
])
const handleStateEvent = useCallback( const handleStateEvent = useCallback(
message => { message => {

View file

@ -37,7 +37,7 @@ function usePersistedState(key, defaultValue, listen = false) {
if (event.key === key) { if (event.key === key) {
// note: this value is read via getItem rather than from event.newValue // note: this value is read via getItem rather than from event.newValue
// because getItem handles deserializing the JSON that's stored in localStorage. // 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) window.removeEventListener('storage', listener)
} }
} }
}, [key, listen]) }, [key, listen, defaultValue])
return [value, updateFunction] 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 PdfLogsViewer from '../js/features/pdf-preview/components/pdf-logs-viewer'
import PdfPreviewError from '../js/features/pdf-preview/components/pdf-preview-error' import PdfPreviewError from '../js/features/pdf-preview/components/pdf-preview-error'
import PdfPreviewHybridToolbar from '../js/features/pdf-preview/components/pdf-preview-hybrid-toolbar' import PdfPreviewHybridToolbar from '../js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
import { useCompileContext } from '../js/shared/context/compile-context' import { useDetachCompileContext as useCompileContext } from '../js/shared/context/detach-compile-context'
import { import {
dispatchDocChanged, dispatchDocChanged,
mockBuildFile, mockBuildFile,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import DetachCompileButton from '../../../../../frontend/js/features/pdf-preview/components/detach-compile-button' import DetachCompileButton from '../../../../../frontend/js/features/pdf-preview/components/detach-compile-button'
import { renderWithEditorContext } from '../../../helpers/render-with-context' 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 sysendTestHelper from '../../../helpers/sysend'
import { expect } from 'chai' import { expect } from 'chai'
@ -48,23 +48,4 @@ describe('<DetachCompileButton/>', function () {
}) })
).to.not.exist ).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', message: 'LaTeX Error',
content: 'See the LaTeX manual', content: 'See the LaTeX manual',
raw: '', raw: '',
ruleId: 'latex_error', ruleId: 'hint_misplaced_alignment_tab_character',
humanReadableHint: '',
humanReadableHintComponent: <></>,
key: '', key: '',
}, },
] ]
@ -36,6 +34,14 @@ describe('<PdfLogsEntries/>', function () {
fileTreeManager.findEntityByPath.resetHistory() 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 () { it('opens doc on click', async function () {
renderWithEditorContext(<PdfLogsEntries entries={logEntries} />, { renderWithEditorContext(<PdfLogsEntries entries={logEntries} />, {
fileTreeManager, 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 PdfPreviewHybridToolbar from '../../../../../frontend/js/features/pdf-preview/components/pdf-preview-hybrid-toolbar'
import { renderWithEditorContext } from '../../../helpers/render-with-context' import { renderWithEditorContext } from '../../../helpers/render-with-context'
import { screen } from '@testing-library/react' import { screen } from '@testing-library/react'
import sysendTestHelper from '../../../helpers/sysend'
describe('<PdfPreviewHybridToolbar/>', function () { describe('<PdfPreviewHybridToolbar/>', function () {
let clock
beforeEach(function () {
clock = sinon.useFakeTimers()
})
afterEach(function () { afterEach(function () {
window.metaAttributesCache = new Map() window.metaAttributesCache = new Map()
sysendTestHelper.resetHistory()
clock.runAll()
clock.restore()
}) })
it('shows normal mode', async function () { it('shows normal mode', async function () {
@ -15,12 +26,49 @@ describe('<PdfPreviewHybridToolbar/>', function () {
}) })
}) })
it('shows orphan mode', async function () { describe('orphan mode', async function () {
window.metaAttributesCache.set('ol-detachRole', 'detached') it('shows connecting message on load', async function () {
renderWithEditorContext(<PdfPreviewHybridToolbar />) window.metaAttributesCache.set('ol-detachRole', 'detached')
renderWithEditorContext(<PdfPreviewHybridToolbar />)
await screen.getByRole('button', { await screen.getByText(/Connecting with the editor/)
name: 'Redirect to 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 // click the button
clearCacheButton.click() clearCacheButton.click()
expect(clearCacheButton.hasAttribute('disabled')).to.be.true await waitFor(() => {
expect(clearCacheButton.hasAttribute('disabled')).to.be.true
})
await waitFor(() => { await waitFor(() => {
expect(clearCacheButton.hasAttribute('disabled')).to.be.false expect(clearCacheButton.hasAttribute('disabled')).to.be.false
}) })
@ -382,7 +385,7 @@ describe('<PdfPreview/>', function () {
expect(clearCacheButton.hasAttribute('disabled')).to.be.false expect(clearCacheButton.hasAttribute('disabled')).to.be.false
mockValidPdf() mockValidPdf()
mockClearCache() const finishClearCache = mockDelayed(mockClearCache)
const recompileFromScratch = screen.getByRole('menuitem', { const recompileFromScratch = screen.getByRole('menuitem', {
name: 'Recompile from scratch', name: 'Recompile from scratch',
@ -390,7 +393,11 @@ describe('<PdfPreview/>', function () {
}) })
recompileFromScratch.click() recompileFromScratch.click()
expect(clearCacheButton.hasAttribute('disabled')).to.be.true await waitFor(() => {
expect(clearCacheButton.hasAttribute('disabled')).to.be.true
})
finishClearCache()
// wait for compile to finish // wait for compile to finish
await screen.findByRole('button', { name: 'Compiling…' }) 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 fs from 'fs'
import path from 'path' import path from 'path'
import { expect } from 'chai' 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 { useFileTreeData } from '../../../../../frontend/js/shared/context/file-tree-data-context'
import { useEffect } from 'react' import { useEffect } from 'react'
@ -122,7 +122,6 @@ const WithSelectedEntities = ({ mockSelectedEntities = [] }) => {
return null return null
} }
describe('<PdfSynctexControls/>', function () { describe('<PdfSynctexControls/>', function () {
beforeEach(function () { beforeEach(function () {
window.metaAttributesCache = new Map() window.metaAttributesCache = new Map()
@ -185,7 +184,6 @@ describe('<PdfSynctexControls/>', function () {
.true .true
}) })
}) })
it('disables button when multiple entities are selected', async function () { it('disables button when multiple entities are selected', async function () {
renderWithEditorContext( renderWithEditorContext(
<> <>
@ -236,9 +234,14 @@ describe('<PdfSynctexControls/>', function () {
}) })
it('does not have go to PDF location button nor arrow icon', async function () { it('does not have go to PDF location button nor arrow icon', async function () {
const { container } = renderWithEditorContext(<PdfSynctexControls />, { const { container } = renderWithEditorContext(
scope, <>
}) <WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls />
</>,
{ scope }
)
expect( expect(
await screen.queryByRole('button', { await screen.queryByRole('button', {
@ -249,7 +252,50 @@ describe('<PdfSynctexControls/>', function () {
expect(container.querySelector('.synctex-control-icon')).to.not.exist 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( renderWithEditorContext(
<> <>
<WithPosition mockPosition={mockPosition} /> <WithPosition mockPosition={mockPosition} />
@ -259,74 +305,23 @@ describe('<PdfSynctexControls/>', function () {
{ scope } { scope }
) )
sysendTestHelper.resetHistory() await waitFor(() => {
expect(fetchMock.called('express:/project/:projectId/compile')).to.be
const syncToPdfButton = await screen.findByRole('button', { .true
name: 'Go to code location in PDF',
}) })
// 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({ sysendTestHelper.receiveMessage({
role: 'detached', role: 'detached',
event: 'state-sync-to-pdf-inflight', event: 'action-sync-to-code',
data: { value: true }, data: {
args: [mockPosition],
},
}) })
expect(syncToPdfButton.disabled).to.be.true
expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
1
)
sysendTestHelper.receiveMessage({ await waitFor(() => {
role: 'detached', expect(fetchMock.called('express:/project/:projectId/sync/pdf')).to.be
event: 'state-sync-to-pdf-inflight', .true
data: { value: false },
}) })
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 () { it('does not have go to code location button nor arrow icon', async function () {
const { container } = renderWithEditorContext(<PdfSynctexControls />, { const { container } = renderWithEditorContext(
scope, <>
}) <WithPosition mockPosition={mockPosition} />
<PdfSynctexControls />
</>,
{ scope }
)
expect( expect(
await screen.queryByRole('button', { await screen.queryByRole('button', {
@ -349,102 +348,90 @@ describe('<PdfSynctexControls/>', function () {
expect(container.querySelector('.synctex-control-icon')).to.not.exist 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( const { container } = renderWithEditorContext(
<> <>
<WithPosition mockPosition={mockPosition} /> <WithPosition mockPosition={mockPosition} />
<WithSelectedEntities mockSelectedEntities={mockSelectedEntities} />
<PdfSynctexControls /> <PdfSynctexControls />
</>, </>,
{ scope } { scope }
) )
sysendTestHelper.resetHistory()
const syncToCodeButton = await screen.findByRole('button', { const syncToCodeButton = await screen.findByRole('button', {
name: /Go to PDF location in code/, 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() 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(syncToCodeButton.disabled).to.be.false
expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal( expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
0 0
) )
fireEvent.click(syncToCodeButton) sysendTestHelper.receiveMessage({
role: 'detacher',
event: 'state-sync-to-code-inflight',
data: { value: true },
})
expect(syncToCodeButton.disabled).to.be.true expect(syncToCodeButton.disabled).to.be.true
expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal( expect(container.querySelectorAll('.synctex-spin-icon').length).to.equal(
1 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({ sysendTestHelper.receiveMessage({
role: 'detacher', role: 'detacher',
event: 'action-go-to-pdf-location', event: 'state-sync-to-code-inflight',
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',
data: { value: false }, 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 = () => export const mockClearCache = (delayPromise = Promise.resolve()) =>
fetchMock.delete('express:/project/:projectId/output', 204) fetchMock.delete(
'express:/project/:projectId/output',
delayPromise.then(() => ({
body: {
status: 204,
},
}))
)
export const mockValidPdf = () => { export const mockValidPdf = () => {
nock('https://clsi.test-overleaf.com') nock('https://clsi.test-overleaf.com')

View file

@ -83,11 +83,14 @@ describe('<ShareProjectModal/>', function () {
beforeEach(function () { beforeEach(function () {
fetchMock.get('/user/contacts', { contacts }) fetchMock.get('/user/contacts', { contacts })
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
}) })
afterEach(function () { afterEach(function () {
fetchMock.restore() fetchMock.restore()
cleanUpContext() cleanUpContext()
window.metaAttributesCache = new Map()
}) })
it('renders the modal', async function () { it('renders the modal', async function () {
@ -179,7 +182,7 @@ describe('<ShareProjectModal/>', function () {
] ]
// render as admin: actions should be present // render as admin: actions should be present
const { rerender } = render( render(
<EditorProviders <EditorProviders
scope={{ scope={{
project: { project: {
@ -197,7 +200,7 @@ describe('<ShareProjectModal/>', function () {
await screen.findByRole('button', { name: 'Resend' }) await screen.findByRole('button', { name: 'Resend' })
// render as non-admin (non-owner), link sharing on: actions should be missing and message should be present // render as non-admin (non-owner), link sharing on: actions should be missing and message should be present
rerender( render(
<EditorProviders <EditorProviders
scope={{ scope={{
project: { project: {
@ -222,7 +225,7 @@ describe('<ShareProjectModal/>', function () {
expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null 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 // render as non-admin (non-owner), link sharing off: actions should be missing and message should be present
rerender( render(
<EditorProviders <EditorProviders
scope={{ scope={{
project: { project: {
@ -619,10 +622,6 @@ describe('<ShareProjectModal/>', function () {
) )
renderWithEditorContext(<ShareProjectModal {...modalProps} />, { renderWithEditorContext(<ShareProjectModal {...modalProps} />, {
user: {
id: '123abd',
allowedFreeTrial: true,
},
scope: { scope: {
project: { project: {
...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 { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
import { DetachProvider } from '../../../frontend/js/shared/context/detach-context' import { DetachProvider } from '../../../frontend/js/shared/context/detach-context'
import { LayoutProvider } from '../../../frontend/js/shared/context/layout-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 // these constants can be imported in tests instead of
// using magic strings // using magic strings
@ -110,7 +111,9 @@ export function EditorProviders({
<EditorProvider settings={{}}> <EditorProvider settings={{}}>
<DetachProvider> <DetachProvider>
<LayoutProvider> <LayoutProvider>
<CompileProvider>{children}</CompileProvider> <LocalCompileProvider>
<DetachCompileProvider>{children}</DetachCompileProvider>
</LocalCompileProvider>
</LayoutProvider> </LayoutProvider>
</DetachProvider> </DetachProvider>
</EditorProvider> </EditorProvider>

View file

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

View file

@ -46,6 +46,7 @@ describe('useDetachLayout', function () {
}) })
it('detacher role', async function () { it('detacher role', async function () {
sysendTestHelper.spy.broadcast.resetHistory()
window.metaAttributesCache.set('ol-detachRole', 'detacher') window.metaAttributesCache.set('ol-detachRole', 'detacher')
// 1. create hook in detacher mode // 1. create hook in detacher mode
@ -55,6 +56,8 @@ describe('useDetachLayout', function () {
expect(result.current.isLinked).to.be.false expect(result.current.isLinked).to.be.false
expect(result.current.isLinking).to.be.false expect(result.current.isLinking).to.be.false
expect(result.current.role).to.equal('detacher') expect(result.current.role).to.equal('detacher')
const broadcastMessagesCount =
sysendTestHelper.getAllBroacastMessages().length
// 2. simulate connected detached tab // 2. simulate connected detached tab
sysendTestHelper.spy.broadcast.resetHistory() sysendTestHelper.spy.broadcast.resetHistory()
@ -70,6 +73,12 @@ describe('useDetachLayout', function () {
expect(result.current.isLinking).to.be.false expect(result.current.isLinking).to.be.false
expect(result.current.role).to.equal('detacher') 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 // 3. simulate closed detached tab
sysendTestHelper.spy.broadcast.resetHistory() sysendTestHelper.spy.broadcast.resetHistory()
sysendTestHelper.receiveMessage({ sysendTestHelper.receiveMessage({
@ -90,21 +99,7 @@ describe('useDetachLayout', function () {
expect(result.current.isLinking).to.be.false expect(result.current.isLinking).to.be.false
expect(result.current.role).to.equal('detacher') expect(result.current.role).to.equal('detacher')
// 5. simulate closed detacher tab // 5. reattach
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
sysendTestHelper.spy.broadcast.resetHistory() sysendTestHelper.spy.broadcast.resetHistory()
act(() => { act(() => {
result.current.reattach() 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 () { it('detached role', async function () {
window.metaAttributesCache.set('ol-detachRole', 'detached') window.metaAttributesCache.set('ol-detachRole', 'detached')
@ -185,3 +200,9 @@ describe('useDetachLayout', function () {
sinon.assert.called(closeStub) 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', serviceWorker: './frontend/js/serviceWorker.js',
main: './frontend/js/main.js', main: './frontend/js/main.js',
ide: './frontend/js/ide.js', ide: './frontend/js/ide.js',
'ide-detached': './frontend/js/ide-detached.js',
marketing: './frontend/js/marketing.js', marketing: './frontend/js/marketing.js',
style: './frontend/stylesheets/style.less', style: './frontend/stylesheets/style.less',
'ieee-style': './frontend/stylesheets/ieee-style.less', 'ieee-style': './frontend/stylesheets/ieee-style.less',