Merge pull request #33341 from overleaf/mj-two-step-export-web

[web] Add two-step pandoc conversion download

GitOrigin-RevId: 093f435a497a7583d2b4d23558415cc442f84553
This commit is contained in:
Mathias Jakobsen
2026-05-12 08:06:13 +00:00
committed by Copybot
parent 64d706f114
commit 62d92b70dd
12 changed files with 554 additions and 82 deletions
@@ -40,7 +40,7 @@ let toastCounter = 1
export const GlobalToasts = memo(function GlobalToasts() {
const [toasts, setToasts] = useState<
{ component: ReactElement; id: string }[]
{ component: ReactElement; id: string; handle?: string }[]
>([])
const removeToast = useCallback((id: string) => {
@@ -72,13 +72,13 @@ export const GlobalToasts = memo(function GlobalToasts() {
)
const addToast = useCallback(
(key: string, data?: any) => {
(key: string, handle?: string, data?: any) => {
const id = `toast-${toastCounter++}`
const component = createToast(id, key, data)
if (!component) {
return
}
setToasts(current => [...current, { id, component }])
setToasts(current => [...current, { id, handle, component }])
},
[createToast]
)
@@ -89,14 +89,25 @@ export const GlobalToasts = memo(function GlobalToasts() {
debugConsole.error('No key provided for toast')
return
}
const { key, ...rest } = event.detail
addToast(key, rest)
const { key, handle, ...rest } = event.detail
addToast(key, handle, rest)
},
[addToast]
)
useEventListener('ide:show-toast', showToastListener)
const dismissToastListener = useCallback((event: CustomEvent) => {
const { handle } = event.detail || {}
if (!handle) {
debugConsole.error('No handle provided for dismissing toast')
return
}
setToasts(current => current.filter(toast => toast.handle !== handle))
}, [])
useEventListener('ide:dismiss-toast', dismissToastListener)
return (
<OLToastContainer className="global-toasts">
{toasts.map(({ component, id }) => (
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
import getMeta from '@/utils/meta'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import useConvertProject from '../../hooks/use-convert-project'
export const DownloadProjectZip = () => {
const { t } = useTranslation()
@@ -105,11 +106,11 @@ export const DownloadProjectPDF = () => {
export const ExportProjectDocx = () => {
const { t } = useTranslation()
const { projectId } = useProjectContext()
const exportDocxEnabled = useFeatureFlag('export-docx')
const enablePandocConversions =
getMeta('ol-ExposedSettings')?.enablePandocConversions
const anonymous = getMeta('ol-anonymous')
const downloadConversion = useConvertProject('docx')
const showExportDocx =
exportDocxEnabled && enablePandocConversions && !anonymous
@@ -120,12 +121,12 @@ export const ExportProjectDocx = () => {
? [
{
id: 'export-as-docx',
href: `/project/${projectId}/download/conversion/docx`,
handler: downloadConversion,
label: t('export_as_docx'),
},
]
: [],
[t, showExportDocx, projectId]
[t, showExportDocx, downloadConversion]
)
if (!showExportDocx) {
@@ -133,11 +134,7 @@ export const ExportProjectDocx = () => {
}
return (
<OLDropdownMenuItem
href={`/project/${projectId}/download/conversion/docx`}
target="_blank"
rel="noreferrer"
>
<OLDropdownMenuItem onClick={downloadConversion}>
{t('export_as_docx')}
</OLDropdownMenuItem>
)
@@ -145,11 +142,11 @@ export const ExportProjectDocx = () => {
export const ExportProjectMarkdown = () => {
const { t } = useTranslation()
const { projectId } = useProjectContext()
const exportMarkdownEnabled = useFeatureFlag('export-markdown')
const enablePandocConversions =
getMeta('ol-ExposedSettings')?.enablePandocConversions
const anonymous = getMeta('ol-anonymous')
const downloadConversion = useConvertProject('markdown')
const showExportMarkdown =
exportMarkdownEnabled && enablePandocConversions && !anonymous
@@ -160,12 +157,12 @@ export const ExportProjectMarkdown = () => {
? [
{
id: 'export-as-markdown',
href: `/project/${projectId}/download/conversion/markdown`,
handler: downloadConversion,
label: t('export_as_markdown'),
},
]
: [],
[t, showExportMarkdown, projectId]
[t, showExportMarkdown, downloadConversion]
)
if (!showExportMarkdown) {
@@ -173,11 +170,7 @@ export const ExportProjectMarkdown = () => {
}
return (
<OLDropdownMenuItem
href={`/project/${projectId}/download/conversion/markdown`}
target="_blank"
rel="noreferrer"
>
<OLDropdownMenuItem onClick={downloadConversion}>
{t('export_as_markdown')}
</OLDropdownMenuItem>
)
@@ -1,9 +1,27 @@
import { GlobalToastGeneratorEntry } from '@/features/ide-react/components/global-toasts'
import { useTranslation } from 'react-i18next'
import { Trans, useTranslation } from 'react-i18next'
const PreparingExportToast = () => {
const { t } = useTranslation()
return <span>{t('preparing_for_export')}</span>
}
const ExportDocumentErrorToast = () => {
const { t } = useTranslation()
return <span>{t('export_document_error')}</span>
return (
<>
<p>
<b>{t('we_couldnt_export_this_document')}</b>
</p>
<Trans
i18nKey="the_document_contains_formatting_we_werent_able_to_convert_contact_support_if_you_need_help"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/contact" target="_BLANK" rel="noopener noreferrer" />,
]}
/>
</>
)
}
const generators: GlobalToastGeneratorEntry[] = [
@@ -17,6 +35,41 @@ const generators: GlobalToastGeneratorEntry[] = [
isDismissible: true,
}),
},
{
key: 'export-document:preparing',
generator: () => ({
content: <PreparingExportToast />,
type: 'info',
autoHide: false,
isDismissible: true,
}),
},
]
export default generators
export const showExportDocumentError = () => {
window.dispatchEvent(
new CustomEvent('ide:show-toast', {
detail: { key: 'export-document:error' },
})
)
}
export const showPreparingExportToast = () => {
const handle = `export-document-preparing-${Date.now()}`
window.dispatchEvent(
new CustomEvent('ide:show-toast', {
detail: { key: 'export-document:preparing', handle },
})
)
return handle
}
export const hidePreparingExportToast = (handle: string) => {
window.dispatchEvent(
new CustomEvent('ide:dismiss-toast', {
detail: { key: 'export-document:preparing', handle },
})
)
}
@@ -0,0 +1,44 @@
import { getJSON } from '@/infrastructure/fetch-json'
import { useLocation } from '@/shared/hooks/use-location'
import { debugConsole } from '@/utils/debugging'
import { useProjectContext } from '@/shared/context/project-context'
import { useCallback } from 'react'
import {
hidePreparingExportToast,
showExportDocumentError,
showPreparingExportToast,
} from '../components/toolbar/export-document-toasts'
const SLOW_CONVERSION_THRESHOLD = 2000
export default function useConvertProject(type: 'docx' | 'markdown') {
const { projectId } = useProjectContext()
const location = useLocation()
const triggerConversion = useCallback(async () => {
let handle: string | undefined
const toastTimer = setTimeout(() => {
handle = showPreparingExportToast()
}, SLOW_CONVERSION_THRESHOLD)
const hidePreparingToast = () => {
clearTimeout(toastTimer)
if (handle) hidePreparingExportToast(handle)
}
try {
const response = await getJSON(
`/project/${projectId}/download/conversion/${type}?responseFormat=json`
)
hidePreparingToast()
const { downloadUrl } = response
if (downloadUrl) {
const url = new URL(downloadUrl, window.location.origin)
location.assign(url.toString())
}
} catch (error) {
hidePreparingToast()
showExportDocumentError()
debugConsole.error(error)
}
}, [projectId, type, location])
return triggerConversion
}