Editor/dashboard polish: PDF publish, Typst outline, bigger branding
Build and Deploy Verso / deploy (push) Successful in 9m29s

- Hide the Present button when the current output is a PDF (it only makes
  sense for HTML/RevealJS decks).
- Publish now supports PDF projects: snapshot output.pdf and serve it inline
  via a small index.html wrapper at /p/:token, so link holders can view the
  PDF straight from the published version.
- Add a Typst document outline (scans '=' headings) wired into the file
  outline panel.
- Dashboard branding: enlarge the instance-name/version text and let the
  sidebar Verso wordmark span the full column width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-03 08:33:56 +00:00
parent 4fc86ebd3d
commit 8272d6de88
7 changed files with 151 additions and 10 deletions
@@ -10,8 +10,40 @@ import CompileManager from '../Compile/CompileManager.mjs'
import { getOutputFileURL } from '../Compile/ClsiURLHelpers.mjs'
import { userCanInstallPython } from '../Compile/PythonVenvGate.mjs'
import { PublishedPresentation } from '../../models/PublishedPresentation.mjs'
import ProjectGetter from '../Project/ProjectGetter.mjs'
import Errors from '../Errors/Errors.js'
function _escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// Wrapper page so a published PDF opens inline (browser PDF viewer) at the
// snapshot root /p/:token, the same way an HTML deck does. The raw file stays
// reachable at /p/:token/output.pdf for direct download.
function _pdfIndexHtml(title) {
const safeTitle = _escapeHtml(title || 'Document')
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${safeTitle}</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #525659; }
iframe { display: block; border: 0; width: 100%; height: 100%; }
</style>
</head>
<body>
<iframe src="output.pdf" title="${safeTitle}"></iframe>
</body>
</html>
`
}
const PUBLISHED_DIR = Settings.path.publishedPresentationsFolder
// Output files we never want in a published deck: compile logs and LaTeX aux.
@@ -60,9 +92,11 @@ async function publish(projectId, userId) {
allowPythonInstall: await userCanInstallPython(userId, projectId),
})
if (!outputFiles?.some(f => f.path === 'output.html')) {
const hasHtml = outputFiles?.some(f => f.path === 'output.html')
const hasPdf = outputFiles?.some(f => f.path === 'output.pdf')
if (!hasHtml && !hasPdf) {
throw new Errors.InvalidError(
`project did not produce an HTML presentation (compile status: ${status})`
`project did not produce an HTML or PDF output (compile status: ${status})`
)
}
if (!buildId) {
@@ -101,12 +135,24 @@ async function publish(projectId, userId) {
)
}
// Serve the deck at the snapshot root: /p/:token → output.html.
// Serve at the snapshot root: /p/:token → index.html. An HTML deck is its
// own index; a PDF gets a tiny wrapper page that embeds output.pdf inline.
try {
await fs.promises.copyFile(
Path.join(destDir, 'output.html'),
Path.join(destDir, 'index.html')
)
if (hasHtml) {
await fs.promises.copyFile(
Path.join(destDir, 'output.html'),
Path.join(destDir, 'index.html')
)
} else {
const project = await ProjectGetter.promises.getProject(projectId, {
name: 1,
})
await fs.promises.writeFile(
Path.join(destDir, 'index.html'),
_pdfIndexHtml(project?.name),
'utf8'
)
}
} catch (err) {
logger.warn({ err, projectId }, 'could not create index.html for deck')
}
@@ -5,14 +5,20 @@ import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { postJSON } from '@/infrastructure/fetch-json'
import getMeta from '@/utils/meta'
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
// One-click shortcut: publish the compiled presentation as a private
// (logged-in-users-only) standalone page and open it in a new tab. For finer
// control (public links, unpublish) use the Share dialog.
//
// Only meaningful for HTML/RevealJS decks — a PDF compile has nothing to
// "present", so the button hides itself when the current output isn't HTML.
export default function PresentationPreviewButton() {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const projectId = getMeta('ol-project_id')
const { pdfFile } = useCompileContext()
const isHtmlPresentation = pdfFile?.path === 'output.html'
const handleClick = useCallback(() => {
setLoading(true)
@@ -36,6 +42,10 @@ export default function PresentationPreviewButton() {
.finally(() => setLoading(false))
}, [projectId])
if (!isHtmlPresentation) {
return null
}
return (
<div className="ide-redesign-toolbar-button-container">
<OLTooltip
@@ -2,6 +2,7 @@ import { useCodeMirrorStateContext } from './codemirror-context'
import React, { useEffect } from 'react'
import { documentOutline } from '../languages/latex/document-outline'
import { markdownDocumentOutline } from '../languages/markdown/document-outline'
import { typstDocumentOutline } from '../languages/typst/document-outline'
import { ProjectionStatus } from '../utils/tree-operations/projection'
import useDebounce from '../../../shared/hooks/use-debounce'
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
@@ -14,7 +15,8 @@ export const CodemirrorOutline = React.memo(function CodemirrorOutline() {
// Use whichever outline StateField is active for the current language
const outlineResult =
debouncedState.field(documentOutline, false) ??
debouncedState.field(markdownDocumentOutline, false)
debouncedState.field(markdownDocumentOutline, false) ??
debouncedState.field(typstDocumentOutline, false)
// when the outline projection changes, calculate the flat outline
useEffect(() => {
@@ -0,0 +1,69 @@
import { EditorState, StateField } from '@codemirror/state'
import {
ProjectionResult,
ProjectionStatus,
} from '../../utils/tree-operations/projection'
import {
FlatOutlineItem,
NestingLevel,
} from '../../utils/tree-operations/outline'
// Typst headings are '='-prefixed lines: '=' is a section, '==' a subsection,
// and so on. Map heading depth to the shared outline nesting levels.
const LEVELS: NestingLevel[] = [
NestingLevel.Section,
NestingLevel.SubSection,
NestingLevel.SubSubSection,
NestingLevel.Paragraph,
NestingLevel.SubParagraph,
]
// A heading is one or more leading '=' at column 0, a space, then the title.
// '==' as an equality operator never starts a line at column 0 with a space
// after it, so this stays clear of code.
const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/
function computeOutline(
state: EditorState
): ProjectionResult<FlatOutlineItem> {
const items: FlatOutlineItem[] = []
for (let n = 1; n <= state.doc.lines; n++) {
const line = state.doc.line(n)
const match = HEADING_REGEX.exec(line.text)
if (!match) continue
const depth = match[1].length
const level = LEVELS[Math.min(depth, LEVELS.length) - 1]
// Strip a trailing label, e.g. '= Introduction <intro>'.
const title = match[2].replace(/\s*<[\w-]+>\s*$/, '').trim()
items.push({
line: n,
toLine: n,
title,
from: line.from,
to: line.to,
level,
} as FlatOutlineItem)
}
return { items, status: ProjectionStatus.Complete }
}
// The Typst language uses a StreamLanguage, whose syntax tree has no structural
// heading nodes to project from, so we scan the document text directly. Typst
// files are small, so a full rescan on each edit is cheap.
export const typstDocumentOutline = StateField.define<
ProjectionResult<FlatOutlineItem>
>({
create(state) {
return computeOutline(state)
},
update(value, transaction) {
if (transaction.docChanged) {
return computeOutline(transaction.state)
}
return value
},
})
@@ -5,6 +5,7 @@ import {
} from '@codemirror/language'
import { tags as t } from '@lezer/highlight'
import { typstCompletions } from './complete'
import { typstDocumentOutline } from './document-outline'
const keywords = new Set([
'let',
@@ -153,5 +154,6 @@ export const TypstLanguage = StreamLanguage.define(parser)
export const typst = () => {
return new LanguageSupport(TypstLanguage, [
TypstLanguage.data.of({ autocomplete: typstCompletions }),
typstDocumentOutline,
])
}
@@ -13,8 +13,8 @@
url('../../../public/img/ol-brand/overleaf-white.svg')
);
// Title, when used instead of a logo
--navbar-title-font-size: var(--font-size-05);
// Title, when used instead of a logo (the Verso instance name + version)
--navbar-title-font-size: var(--font-size-07);
--navbar-title-color: var(--neutral-20);
--navbar-title-color-hover: var(--neutral-40);
@@ -245,6 +245,18 @@ body {
}
}
// Let the Verso wordmark bleed past the lower section's asymmetric
// padding so it spans the full width of the sidebar column.
.ds-nav-verso-logo {
margin-left: calc(-1 * var(--spacing-05));
margin-right: calc(-1 * var(--spacing-08));
img {
width: 100%;
height: auto;
}
}
.project-list-sidebar-survey-link {
color: var(--content-secondary) !important;
}