From a5ca4323960f894f1d78f9829d1e26940359350c Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 14:22:16 +0000 Subject: [PATCH] Fix heading bleeding and smooth_pdf_transition translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes: 1. Heading style bleeding (Typst): the HeadingTitle external token approach was unreliable — even with contextual:true and canShift(), body text was being styled as headings. Remove HeadingTitle from the grammar entirely. Instead, a ViewPlugin (headingLinePlugin in languages/typst/index.ts) walks the syntax tree, finds HeadingMark nodes, and decorates the rest of the line with tok-heading class + bold. This is unconditionally correct because it is based on the syntax tree rather than the LR tokenizer state. 2. smooth_pdf_transition raw key shown in all locales: the key was in the JSON locale files but missing from extracted-translations.json, which is the allowlist the webpack translation loader uses to decide what to bundle. Add it there so all locales (including fr, es, de already added) resolve to their translated strings. Co-Authored-By: Claude Sonnet 4.6 --- TODO.md | 19 +++++++ .../web/frontend/extracted-translations.json | 1 + .../source-editor/languages/typst/index.ts | 54 ++++++++++++++++++- .../source-editor/lezer-typst/tokens.mjs | 30 ----------- .../source-editor/lezer-typst/typst.grammar | 23 ++++---- 5 files changed, 81 insertions(+), 46 deletions(-) diff --git a/TODO.md b/TODO.md index d3c03110bc..ce06a1c1a6 100644 --- a/TODO.md +++ b/TODO.md @@ -20,3 +20,22 @@ Ideas and features deferred from the current alpha. language server) behind a WebSocket proxy. Would give Typst files first-class autocomplete, hover docs, go-to-definition, and inline error diagnostics — the main editing comfort gap vs. a native editor. + +### Editor UX for non-LaTeX formats (.typ, .qmd, .md) + +- **Visual/rich-text editing mode** — A toggle between raw source and a + rendered-in-place view for `.typ`, `.qmd`, and `.md` files (similar to + Overleaf's rich-text mode for LaTeX). Users who don't know Typst or + Markdown syntax should be able to edit content without seeing markup. + CodeMirror 6 already supports this pattern via a custom `NodeView` layer + or a separate Prosemirror bridge. + +- **Toolbar / insertion shortcuts** — A formatting toolbar and keyboard + shortcuts for common operations, adapted per file type: + - **All formats**: bold, italic, underline, headings, bullet/numbered + lists, inline code, links. + - **Quarto / Markdown**: insert image, insert table, insert code block + with language tag. + - **Quarto RevealJS**: insert slide divider (`---`), insert speaker + notes (`::: notes`), insert columns layout, insert video embed + (using Quarto's `{{< video >}}` shortcode). diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index b04818628a..7c99b93747 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1883,6 +1883,7 @@ "size": "", "skip": "", "skip_welcome_animation": "", + "smooth_pdf_transition": "", "some_dois_could_not_be_resolved": "", "something_not_right": "", "something_went_wrong": "", 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 29c6d3fba0..dd8fa90cf1 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,8 +5,17 @@ import { foldInside, HighlightStyle, syntaxHighlighting, + syntaxTree, } from '@codemirror/language' import { styleTags, tags as t } from '@lezer/highlight' +import { RangeSetBuilder } from '@codemirror/state' +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate, +} from '@codemirror/view' import { parser } from '../../lezer-typst/typst.mjs' import { typstCompletions } from './complete' import { typstDocumentOutline } from './document-outline' @@ -30,8 +39,9 @@ export const TypstLanguage = LRLanguage.define({ CodeArgs: foldInside, }), styleTags({ - // Headings (HeadingTitle is an external token, one terminal per line) - 'HeadingMark HeadingTitle': t.heading, + // HeadingMark covers the "=+ " prefix; heading line content is handled + // by the headingLinePlugin ViewPlugin below (not a grammar token). + HeadingMark: t.heading, // Comments 'LineComment LineCommentContent': t.comment, @@ -100,10 +110,50 @@ const typstHighlightStyle = HighlightStyle.define([ { tag: t.emphasis, fontStyle: 'italic' }, ]) +// Heading title decoration: the grammar only has a HeadingMark token (the "=+ " +// prefix). This plugin walks the syntax tree, finds each HeadingMark, and +// extends a heading-style decoration to the end of the line so the title text +// gets the same blue + bold treatment without needing a HeadingTitle token +// (which had LALR state-merging issues causing body text to bleed into heading +// style in the previous implementation). +const headingTitleMark = Decoration.mark({ + class: 'tok-heading', + attributes: { style: 'font-weight:bold' }, +}) + +function buildHeadingDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder() + syntaxTree(view.state).cursor().iterate(node => { + if (node.name === 'HeadingMark') { + const line = view.state.doc.lineAt(node.from) + if (node.to < line.to) { + builder.add(node.to, line.to, headingTitleMark) + } + } + }) + return builder.finish() +} + +const headingLinePlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet + constructor(view: EditorView) { + this.decorations = buildHeadingDecorations(view) + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildHeadingDecorations(update.view) + } + } + }, + { decorations: v => v.decorations } +) + export const typst = () => { return new LanguageSupport(TypstLanguage, [ TypstLanguage.data.of({ autocomplete: typstCompletions }), typstDocumentOutline, syntaxHighlighting(typstHighlightStyle), + headingLinePlugin, ]) } diff --git a/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs b/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs index 26710b6ed3..57f1cfa2ec 100644 --- a/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs +++ b/services/web/frontend/js/features/source-editor/lezer-typst/tokens.mjs @@ -3,7 +3,6 @@ import { ExternalTokenizer } from '@lezer/lr' import { HeadingMark, - HeadingTitle, RawBlockOpen, RawBlockBody, RawBlockClose, @@ -51,35 +50,6 @@ export const headingTokenizer = new ExternalTokenizer( { contextual: false } ) -// ── headingTitleTokenizer ──────────────────────────────────────────────── -// Emits HeadingTitle — everything from the current position to end-of-line. -// Using an external token (terminal) instead of grammar sub-items avoids -// LALR(1) conflicts: any token shared between headingTitleItem and document- -// level item causes the automaton to merge the two contexts. -// -// contextual: true — the tokenizer checks stack.canShift(HeadingTitle) before -// doing anything, so it ONLY fires in the LR state reached after HeadingMark. -// With contextual: false, Lezer calls the tokenizer speculatively at any -// position where HeadingTitle is *conceivably* valid (due to LALR state -// merging), which can cause whole lines of body text to be consumed as -// HeadingTitle tokens and styled as headings. -export const headingTitleTokenizer = new ExternalTokenizer( - (input, stack) => { - if (!stack.canShift(HeadingTitle)) return - let hasContent = false - while (input.next !== -1 && input.next !== NEWLINE) { - // Stop before a line comment (//) or block comment (/*) so that - // '= Heading // note' leaves the comment for the LineComment rule. - if (input.next === SLASH && - (input.peek(1) === SLASH || input.peek(1) === STAR)) break - input.advance() - hasContent = true - } - if (hasContent) input.acceptToken(HeadingTitle) - }, - { contextual: true } -) - // ── rawTokenizer ──────────────────────────────────────────────────────── // Handles all three raw-block tokens (contextual: uses stack.canShift). // diff --git a/services/web/frontend/js/features/source-editor/lezer-typst/typst.grammar b/services/web/frontend/js/features/source-editor/lezer-typst/typst.grammar index f2ee06cb71..5c01db7a00 100644 --- a/services/web/frontend/js/features/source-editor/lezer-typst/typst.grammar +++ b/services/web/frontend/js/features/source-editor/lezer-typst/typst.grammar @@ -1,11 +1,13 @@ // typst.grammar — Lezer LR grammar for the Typst typesetting language. // Covers markup mode (top-level), code mode (#expr) and math mode ($...$). // External tokenizers handle constructs requiring context-sensitive lexing: -// headingTokenizer — start-of-line detection for heading markers -// rawTokenizer — triple-backtick raw block open/body/close -// rawInlineTokenizer — single-backtick raw inline content -// codeBlockTokenizer — brace-depth tracking inside #{ ... } +// headingTokenizer — start-of-line detection for heading markers (=+ ) +// rawTokenizer — triple-backtick raw block open/body/close +// rawInlineTokenizer — single-backtick raw inline content +// codeBlockTokenizer — brace-depth tracking inside #{ ... } // blockCommentTokenizer — depth-tracked nested /* ... */ comments +// Note: heading title text is NOT a grammar token. The ViewPlugin in +// languages/typst/index.ts decorates heading lines via the syntax tree. @top Document { item* } @@ -30,12 +32,9 @@ item { // ── Headings ────────────────────────────────────────────────────────────── // HeadingMark is produced by an external tokenizer that enforces the // start-of-line constraint and captures the "=+" prefix + trailing space. -Heading { HeadingMark HeadingTitle? } -// HeadingTitle is an external token (terminal) that reads everything to EOL. -// Using an external token is the only reliable fix: any token that can start -// both a headingTitleItem and a document-level item causes an LALR(1) conflict -// after headingTitleItem+ because the automaton merges the two contexts. -// External tokens are context-isolated by the LR state machine — no merging. +// The heading title (text after the marker) is NOT a grammar token; a +// ViewPlugin in index.ts decorates the rest of the line via the syntax tree. +Heading { HeadingMark } // ── Comments ────────────────────────────────────────────────────────────── LineComment { "//" LineCommentContent? } @@ -129,10 +128,6 @@ Escape { "\\" EscapeChar } HeadingMark } -@external tokens headingTitleTokenizer from "./tokens.mjs" { - HeadingTitle -} - @external tokens rawTokenizer from "./tokens.mjs" { RawBlockOpen, RawBlockBody,