Editor/dashboard polish: PDF publish, Typst outline, bigger branding
Build and Deploy Verso / deploy (push) Successful in 9m29s
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:
+53
-7
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// 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')
|
||||
}
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user