diff --git a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs index d854af7a9f..0c4d9ddf27 100644 --- a/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs +++ b/services/web/app/src/Features/PublishedPresentation/PublishedPresentationManager.mjs @@ -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, '"') +} + +// 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 ` + + + + +${safeTitle} + + + + + + +` +} + 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') } diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx index e8c69c7270..7dedf73779 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/presentation-preview-button.tsx @@ -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 (
{ diff --git a/services/web/frontend/js/features/source-editor/languages/typst/document-outline.ts b/services/web/frontend/js/features/source-editor/languages/typst/document-outline.ts new file mode 100644 index 0000000000..d72da9bf06 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/languages/typst/document-outline.ts @@ -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 { + 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 '. + 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 +>({ + create(state) { + return computeOutline(state) + }, + update(value, transaction) { + if (transaction.docChanged) { + return computeOutline(transaction.state) + } + return value + }, +}) diff --git a/services/web/frontend/js/features/source-editor/languages/typst/index.ts b/services/web/frontend/js/features/source-editor/languages/typst/index.ts index 3e97d5e873..45fded8cd7 100644 --- a/services/web/frontend/js/features/source-editor/languages/typst/index.ts +++ b/services/web/frontend/js/features/source-editor/languages/typst/index.ts @@ -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, ]) } diff --git a/services/web/frontend/stylesheets/components/nav.scss b/services/web/frontend/stylesheets/components/nav.scss index 78b25c7e11..4804de88ce 100644 --- a/services/web/frontend/stylesheets/components/nav.scss +++ b/services/web/frontend/stylesheets/components/nav.scss @@ -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); diff --git a/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss b/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss index 24ef18cb51..7000192ea1 100644 --- a/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss +++ b/services/web/frontend/stylesheets/pages/project-list-ds-nav.scss @@ -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; }