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,