feat(typst): browser-side live preview via typst.ts WASM
Build and Deploy Verso / deploy (push) Successful in 12m51s

Adds a dual-mode Typst preview: a new "Live (browser)" mode compiles and
renders Typst documents entirely in-browser using typst.ts WASM (28 MB
compiler + 1 MB renderer). The existing server-side PDF mode is preserved
and selectable via a new "Preview mode" section in the recompile dropdown,
visible only for Typst projects.

Architecture:
- Web Worker (typst-preview-worker.ts) runs the WASM compiler; queues
  compile requests so only the latest compile runs after each keypress
- TypstWasmPreview component initialises the renderer on the main thread,
  listens to changedAt from the compile context, debounces at 400 ms, and
  renders SVG into a container div via renderToSvg
- typstPreviewMode ('wasm'|'pdf') is persisted per-project in localStorage
- isTypstProject, changedAt, typstPreviewMode, setTypstPreviewMode are
  exposed through both LocalCompileContext and DetachCompileContext
- Fonts loaded from jsDelivr CDN (text subset only) on first use
- Phase 1: single-file Typst only (no #include, no images)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-19 13:42:39 +00:00
parent b5cf5f9e7b
commit 200bff4ecb
13 changed files with 562 additions and 4036 deletions
@@ -2265,6 +2265,11 @@
"turn_on": "",
"turn_on_link_sharing": "",
"typst_export_feedback_message": "",
"typst_preview_mode": "",
"typst_preview_pdf": "",
"typst_preview_wasm": "",
"typst_wasm_error": "",
"typst_wasm_loading": "",
"unarchive": "",
"uncategorized": "",
"uncategorized_projects": "",
@@ -51,6 +51,9 @@ function PdfCompileButton() {
smoothPdfTransition,
setSmoothPdfTransition,
isLatexProject,
isTypstProject,
typstPreviewMode,
setTypstPreviewMode,
} = useCompileContext()
const { enableStopOnFirstError, disableStopOnFirstError } =
useStopOnFirstError({ eventSource: 'dropdown' })
@@ -172,6 +175,30 @@ function PdfCompileButton() {
{t('off')}
</DropdownItem>
</li>
{isTypstProject && (
<>
<DropdownDivider />
<DropdownHeader>{t('typst_preview_mode')}</DropdownHeader>
<li role="none">
<DropdownItem
as="button"
onClick={() => setTypstPreviewMode('wasm')}
trailingIcon={typstPreviewMode === 'wasm' ? 'check' : null}
>
{t('typst_preview_wasm')}
</DropdownItem>
</li>
<li role="none">
<DropdownItem
as="button"
onClick={() => setTypstPreviewMode('pdf')}
trailingIcon={typstPreviewMode === 'pdf' ? 'check' : null}
>
{t('typst_preview_pdf')}
</DropdownItem>
</li>
</>
)}
{isLatexProject && (
<>
<DropdownDivider />
@@ -1,4 +1,4 @@
import { ElementType, memo, Suspense } from 'react'
import { ElementType, lazy, memo, Suspense } from 'react'
import classNames from 'classnames'
import PdfViewer from './pdf-viewer'
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
@@ -12,12 +12,22 @@ import PdfCodeCheckFailedBanner from '@/features/pdf-preview/components/pdf-code
import getMeta from '@/utils/meta'
import PdfLogsViewer from '@/features/pdf-preview/components/pdf-logs-viewer'
const TypstWasmPreview = lazy(
() =>
import(
/* webpackChunkName: "typst-wasm-preview" */
'@/features/typst-preview/components/typst-wasm-preview'
)
)
function PdfPreviewPane() {
const {
pdfUrl,
pdfViewer,
darkModePdf: darkModeSetting,
activeOverallTheme,
isTypstProject,
typstPreviewMode,
} = useCompileContext()
const { compileTimeout } = getMeta('ol-compileSettings')
const isHtmlOutput = pdfUrl?.includes('output.html')
@@ -26,8 +36,9 @@ function PdfPreviewPane() {
activeOverallTheme === 'dark' &&
darkModeSetting
const isWasmMode = isTypstProject && typstPreviewMode === 'wasm'
const classes = classNames('pdf', 'full-size', {
'pdf-empty': !pdfUrl,
'pdf-empty': !pdfUrl && !isWasmMode,
'pdf-dark-mode': darkModePdf,
})
@@ -46,7 +57,11 @@ function PdfPreviewPane() {
</PdfPreviewMessages>
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
<div className="pdf-viewer" data-testid="pdf-viewer">
{isWasmMode ? (
<TypstWasmPreview />
) : (
<PdfViewer />
)}
</div>
</Suspense>
<PdfLogsViewer />
@@ -0,0 +1,161 @@
import {
useEffect,
useRef,
useState,
useCallback,
memo,
type FC,
} from 'react'
import { useTranslation } from 'react-i18next'
import { createTypstRenderer } from '@myriaddreamin/typst.ts'
import type { TypstRenderer } from '@myriaddreamin/typst.ts'
import rendererWasmUrl from '@myriaddreamin/typst-ts-renderer/pkg/typst_ts_renderer_bg.wasm'
import { useLocalCompileContext } from '@/shared/context/local-compile-context'
import { useEditorViewContext } from '@/features/ide-react/context/editor-view-context'
type Status = 'initializing' | 'ready' | 'compiling' | 'error'
const createTypstWorker = () =>
new Worker(
/* webpackChunkName: "typst-preview-worker" */
new URL('../typst-preview-worker.ts', import.meta.url),
{ type: 'module' }
)
const TypstWasmPreview: FC = () => {
const { t } = useTranslation()
const { changedAt } = useLocalCompileContext()
const { view } = useEditorViewContext()
const containerRef = useRef<HTMLDivElement>(null)
const workerRef = useRef<Worker | null>(null)
const rendererRef = useRef<TypstRenderer | null>(null)
const isReadyRef = useRef(false)
const compileTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [status, setStatus] = useState<Status>('initializing')
const [errorMsg, setErrorMsg] = useState('')
const triggerCompile = useCallback(() => {
const worker = workerRef.current
if (!worker || !isReadyRef.current) return
const content = view?.state.doc.toString() ?? ''
if (!content) return
if (compileTimerRef.current) clearTimeout(compileTimerRef.current)
compileTimerRef.current = setTimeout(() => {
setStatus('compiling')
worker.postMessage({
type: 'compile',
content,
mainFilePath: '/main.typ',
})
}, 400)
}, [view])
// Init renderer (main thread, needs DOM)
useEffect(() => {
let cancelled = false
const renderer = createTypstRenderer()
renderer
.init({ getModule: () => fetch(rendererWasmUrl) })
.then(() => {
if (!cancelled) rendererRef.current = renderer
})
.catch(err => {
if (!cancelled) {
setStatus('error')
setErrorMsg(`Renderer init failed: ${err}`)
}
})
return () => {
cancelled = true
}
}, [])
// Init worker and wire message handler
useEffect(() => {
const worker = createTypstWorker()
workerRef.current = worker
worker.addEventListener('message', async event => {
const msg = event.data as
| { type: 'ready' }
| { type: 'compiled'; vectorData: Uint8Array; diagnostics: any[] }
| { type: 'error'; message: string }
if (msg.type === 'ready') {
isReadyRef.current = true
setStatus('ready')
triggerCompile()
}
if (msg.type === 'compiled') {
const renderer = rendererRef.current
const container = containerRef.current
if (!renderer || !container) {
setStatus('ready')
return
}
try {
await renderer.runWithSession(async session => {
session.manipulateData({
action: 'reset',
data: msg.vectorData,
})
await renderer.renderToSvg({
renderSession: session,
container,
})
})
setStatus('ready')
setErrorMsg('')
} catch (e) {
setStatus('error')
setErrorMsg(`Render failed: ${e}`)
}
}
if (msg.type === 'error') {
setStatus('error')
setErrorMsg(msg.message)
}
})
return () => {
if (compileTimerRef.current) clearTimeout(compileTimerRef.current)
worker.terminate()
workerRef.current = null
isReadyRef.current = false
}
}, [triggerCompile])
// Re-compile when document changes
useEffect(() => {
if (changedAt > 0) triggerCompile()
}, [changedAt, triggerCompile])
return (
<div className="typst-wasm-preview">
{status === 'initializing' && (
<div className="typst-wasm-preview-status">
<div className="typst-wasm-preview-spinner" />
<span>{t('typst_wasm_loading')}</span>
</div>
)}
{status === 'error' && (
<div className="typst-wasm-preview-error">
<strong>{t('typst_wasm_error')}</strong>
{errorMsg && <pre>{errorMsg}</pre>}
</div>
)}
<div
ref={containerRef}
className="typst-wasm-preview-container"
style={{ display: status === 'initializing' ? 'none' : 'block' }}
/>
</div>
)
}
export default memo(TypstWasmPreview)
@@ -0,0 +1,87 @@
import { createTypstCompiler, preloadRemoteFonts } from '@myriaddreamin/typst.ts'
import type { TypstCompiler } from '@myriaddreamin/typst.ts'
import compilerWasmUrl from '@myriaddreamin/typst-ts-web-compiler/pkg/typst_ts_web_compiler_bg.wasm'
type IncomingMessage = {
type: 'compile'
content: string
mainFilePath: string
}
type OutgoingMessage =
| { type: 'ready' }
| { type: 'compiled'; vectorData: Uint8Array; diagnostics: any[] }
| { type: 'error'; message: string }
let compiler: TypstCompiler | null = null
let compileQueue: IncomingMessage | null = null
let isCompiling = false
async function runCompile(msg: IncomingMessage) {
if (!compiler) return
isCompiling = true
try {
compiler.resetShadow()
compiler.addSource(msg.mainFilePath, msg.content)
const result = await (compiler as any).compile({
mainFilePath: msg.mainFilePath,
format: 0, // CompileFormatEnum.vector
diagnostics: 'full',
})
if (result?.result) {
self.postMessage(
{
type: 'compiled',
vectorData: result.result,
diagnostics: result.diagnostics ?? [],
} satisfies OutgoingMessage,
[result.result.buffer]
)
} else {
self.postMessage({
type: 'error',
message: 'Compilation produced no output',
} satisfies OutgoingMessage)
}
} catch (e) {
self.postMessage({
type: 'error',
message: String(e),
} satisfies OutgoingMessage)
} finally {
isCompiling = false
if (compileQueue) {
const next = compileQueue
compileQueue = null
await runCompile(next)
}
}
}
const initPromise = (async () => {
const c = createTypstCompiler()
await c.init({
getModule: () => fetch(compilerWasmUrl),
beforeBuild: [preloadRemoteFonts([], { assets: ['text'] })],
})
compiler = c
self.postMessage({ type: 'ready' } satisfies OutgoingMessage)
})().catch(err => {
compiler = null
self.postMessage({
type: 'error',
message: `Typst compiler init failed: ${err}`,
} satisfies OutgoingMessage)
})
self.addEventListener('message', async (event: MessageEvent<IncomingMessage>) => {
if (event.data.type === 'compile') {
await initPromise
if (!compiler) return
if (isCompiling) {
compileQueue = event.data
} else {
await runCompile(event.data)
}
}
})
@@ -77,6 +77,10 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
smoothPdfTransition: _smoothPdfTransition,
setSmoothPdfTransition: _setSmoothPdfTransition,
isLatexProject: _isLatexProject,
isTypstProject: _isTypstProject,
changedAt: _changedAt,
typstPreviewMode: _typstPreviewMode,
setTypstPreviewMode: _setTypstPreviewMode,
} = localCompileContext
const [animateCompileDropdownArrow] = useDetachStateWatcher(
@@ -159,6 +163,24 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
'detacher',
'detached'
)
const [isTypstProject] = useDetachStateWatcher(
'isTypstProject',
_isTypstProject,
'detacher',
'detached'
)
const [changedAt] = useDetachStateWatcher(
'changedAt',
_changedAt,
'detacher',
'detached'
)
const [typstPreviewMode] = useDetachStateWatcher(
'typstPreviewMode',
_typstPreviewMode,
'detacher',
'detached'
)
const [lastCompileOptions] = useDetachStateWatcher(
'lastCompileOptions',
_lastCompileOptions,
@@ -418,6 +440,13 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
'detacher'
)
const setTypstPreviewMode = useDetachAction(
'setTypstPreviewMode',
_setTypstPreviewMode,
'detached',
'detacher'
)
useCompileTriggers(startCompile, setChangedAt)
useLogEvents(setShowLogs)
@@ -482,6 +511,10 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
smoothPdfTransition,
setSmoothPdfTransition,
isLatexProject,
isTypstProject,
changedAt,
typstPreviewMode,
setTypstPreviewMode,
}),
[
animateCompileDropdownArrow,
@@ -541,6 +574,10 @@ export const DetachCompileProvider: FC<React.PropsWithChildren> = ({
smoothPdfTransition,
setSmoothPdfTransition,
isLatexProject,
isTypstProject,
changedAt,
typstPreviewMode,
setTypstPreviewMode,
]
)
@@ -133,6 +133,10 @@ export type CompileContext = {
setDarkModePdf: (value: boolean) => void
activeOverallTheme: ActiveOverallTheme
isLatexProject: boolean
isTypstProject: boolean
changedAt: number
typstPreviewMode: 'wasm' | 'pdf'
setTypstPreviewMode: (value: 'wasm' | 'pdf') => void
}
export const LocalCompileContext = createContext<CompileContext | undefined>(
@@ -305,6 +309,9 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
const [isTypstProject, setIsTypstProject] = useState(false)
const [isQuartoProject, setIsQuartoProject] = useState(false)
const isLatexProject = !isTypstProject && !isQuartoProject
const [typstPreviewMode, setTypstPreviewMode] = usePersistedState<
'wasm' | 'pdf'
>(`typst_preview_mode:${projectId}`, 'wasm', { listen: true })
const smoothPdfTransition = isTypstProject
? smoothTransitionTypst
: smoothTransitionLatex
@@ -879,6 +886,10 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
smoothPdfTransition,
setSmoothPdfTransition,
isLatexProject,
isTypstProject,
changedAt,
typstPreviewMode,
setTypstPreviewMode,
}),
[
animateCompileDropdownArrow,
@@ -937,6 +948,10 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
smoothPdfTransition,
setSmoothPdfTransition,
isLatexProject,
isTypstProject,
changedAt,
typstPreviewMode,
setTypstPreviewMode,
]
)
@@ -520,3 +520,64 @@
display: flex;
gap: 20px;
}
.typst-wasm-preview {
width: 100%;
height: 100%;
overflow-y: auto;
background: var(--pdf-bg);
display: flex;
flex-direction: column;
align-items: center;
}
.typst-wasm-preview-status {
display: flex;
align-items: center;
gap: var(--spacing-04);
padding: var(--spacing-08);
color: var(--content-secondary);
}
.typst-wasm-preview-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--neutral-40);
border-top-color: var(--content-primary);
border-radius: 50%;
animation: typst-spin 0.8s linear infinite;
}
@keyframes typst-spin {
to { transform: rotate(360deg); }
}
.typst-wasm-preview-error {
padding: var(--spacing-06);
color: var(--content-danger);
max-width: 100%;
pre {
font-size: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
margin-top: var(--spacing-02);
background: var(--bg-dark-tertiary);
padding: var(--spacing-03);
border-radius: var(--border-radius-base);
}
}
.typst-wasm-preview-container {
width: 100%;
padding: var(--spacing-04);
svg {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto var(--spacing-04);
background: #fff;
box-shadow: 0 2px 8px rgb(0 0 0 / 20%);
}
}
+5
View File
@@ -2899,6 +2899,11 @@
"turn_on_password_visibility": "Turn on password visibility",
"tutorials": "Tutorials",
"typst_export_feedback_message": "This conversion may contain errors — pandoc does not support all LaTeX constructs.",
"typst_preview_mode": "Preview mode",
"typst_preview_pdf": "PDF (server)",
"typst_preview_wasm": "Live (browser)",
"typst_wasm_error": "Preview error",
"typst_wasm_loading": "Loading Typst compiler…",
"uk": "Ukrainian",
"unable_to_extract_the_supplied_zip_file": "Opening this content on Overleaf failed because the zip file could not be extracted. Please ensure that it is a valid zip file. If this keeps happening for links on a particular site, please report this to them.",
"unarchive": "Restore",
+5
View File
@@ -2904,6 +2904,11 @@
"turn_on_password_visibility": "Activer la visibilité du mot de passe",
"tutorials": "Tutoriels",
"typst_export_feedback_message": "Cette conversion peut contenir des erreurs — pandoc ne supporte pas toutes les constructions LaTeX.",
"typst_preview_mode": "Mode de prévisualisation",
"typst_preview_pdf": "PDF (serveur)",
"typst_preview_wasm": "Direct (navigateur)",
"typst_wasm_error": "Erreur de prévisualisation",
"typst_wasm_loading": "Chargement du compilateur Typst…",
"uk": "Ukrainien",
"unable_to_extract_the_supplied_zip_file": "Louverture de ce contenu sur Overleaf a échoué car larchive na pas pu être extraite. Veuillez vous assurer de la validité de cette archive. Si cela se produit régulièrement pour un site donné, veuillez leur faire part du problème.",
"unarchive": "Restaurer",
+3
View File
@@ -84,6 +84,9 @@
"@customerio/cdp-analytics-node": "^0.3.9",
"@google-cloud/bigquery": "^8.1.1",
"@google-cloud/storage": "^7.19.0",
"@myriaddreamin/typst-ts-renderer": "0.7.0",
"@myriaddreamin/typst-ts-web-compiler": "0.7.0",
"@myriaddreamin/typst.ts": "0.7.0",
"@node-oauth/oauth2-server": "^5.3.0",
"@node-saml/passport-saml": "^5.1.0",
"@overleaf/access-token-encryptor": "workspace:*",
@@ -750,6 +750,13 @@ const BASE_COMPILE_CONTEXT_MOCK = {
darkModePdf: false,
setDarkModePdf: () => {},
activeOverallTheme: 'light',
smoothPdfTransition: false,
setSmoothPdfTransition: () => {},
isLatexProject: true,
isTypstProject: false,
changedAt: 0,
typstPreviewMode: 'wasm' as const,
setTypstPreviewMode: () => {},
} as const
const makeDetachCompileProvider = (mockCompileOnLoad: boolean = false) => {
+131 -4033
View File
File diff suppressed because it is too large Load Diff